Module: Mpp::X402::Server

Extended by:
T::Sig
Defined in:
lib/mpp/x402/server.rb

Overview

Server-side x402 exact binding, challenge decoration, and header merge.

Class Method Summary collapse

Class Method Details

.bind_credential(payment_payload:, requirements:, resource_url:, challenge:, body:, route_binding:, http_method: nil) ⇒ Object



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/mpp/x402/server.rb', line 141

def bind_credential(payment_payload:, requirements:, resource_url:, challenge:, body:, route_binding:, http_method: nil)
  unless canonical_equal?(payment_payload["accepted"], requirements)
    Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload does not match route requirements")
  end

  assert_body_digest!(challenge, body)

  expected_resource = {"url" => resource_url}
  client_nonce = payment_payload.dig("extensions", MPPX_EXTENSION_KEY, "info", "nonce")
  route_bound = !client_nonce.nil?
  route_requires_binding = !challenge.digest.nil? || !challenge.opaque.nil?

  if route_requires_binding && !route_bound && route_binding == :required
    Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload does not bind required route metadata")
  end

  payload_resource = payment_payload["resource"]
  if route_bound
    unless payload_resource.is_a?(Hash) && payload_resource["url"] == expected_resource["url"]
      Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload resource does not match route resource")
    end
    expected_extensions = route_extensions(challenge, http_method) || {}
    unless contains_extensions?(payment_payload["extensions"], expected_extensions)
      Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload extensions do not match route binding")
    end
  elsif resource_url
    if payload_resource.is_a?(Hash) && payload_resource["url"] != resource_url
      Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload resource does not match route resource")
    end
    if route_requires_binding && (payload_resource.nil? || payload_resource["url"] != resource_url)
      Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 payment payload resource does not match route resource")
    end
  end

  authorization = payload_to_authorization(payment_payload)
  if route_bound
    expected_nonce = route_nonce(
      accepted: requirements,
      extensions: payment_payload["extensions"],
      resource: expected_resource
    )
    unless authorization["nonce"].to_s.downcase == expected_nonce.downcase
      Kernel.raise Mpp::VerificationFailedError.new(reason: "x402 authorization nonce does not match route binding")
    end
  end

  authorization
end

.canonical_equal?(left, right) ⇒ Boolean

Returns:

  • (Boolean)


211
212
213
# File 'lib/mpp/x402/server.rb', line 211

def canonical_equal?(left, right)
  Mpp::Json.compact_encode(left) == Mpp::Json.compact_encode(right)
end

.merge_payment_required(values) ⇒ Object



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/mpp/x402/server.rb', line 17

def merge_payment_required(values)
  decoded = values.map { |value| Header.decode_payment_required(value) }
  first = decoded.first
  Kernel.raise ArgumentError, "Expected at least one x402 payment-required header." unless first

  rest = decoded.drop(1)
  incompatible = rest.any? do |value|
    value["resource"] != first["resource"] || value["extensions"] != first["extensions"]
  end
  if incompatible
    error = [first["error"], "Cannot merge x402 payment requirements with different resources."]
      .compact
      .reject { |item| item.to_s.empty? }
      .join("; ")
    return Header.encode_payment_required(first.merge("error" => error))
  end

  errors = [first["error"], *rest.map { |value| value["error"] }]
    .compact
    .reject { |item| item.to_s.empty? }
  merged = first.merge("accepts" => decoded.flat_map { |value| value["accepts"] })
  merged["error"] = errors.join("; ") unless errors.empty?
  Header.encode_payment_required(merged)
end

.payload_to_authorization(payment_payload) ⇒ Object



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/mpp/x402/server.rb', line 191

def payload_to_authorization(payment_payload)
  payload = payment_payload["payload"]
  unless payload.is_a?(Hash) && payload["authorization"].is_a?(Hash)
    Kernel.raise Mpp::VerificationFailedError.new(reason: "EVM charge only supports x402 EIP-3009 authorization payloads")
  end

  authorization = payload["authorization"]
  {
    "from" => authorization["from"].to_s,
    "nonce" => authorization["nonce"].to_s,
    "signature" => payload["signature"].to_s,
    "to" => authorization["to"].to_s,
    "type" => "authorization",
    "validAfter" => authorization["validAfter"].to_s,
    "validBefore" => authorization["validBefore"].to_s,
    "value" => authorization["value"].to_s
  }
end

.payment_required_body(requirements:, resource_url:, extensions: nil, error: nil) ⇒ Object



76
77
78
79
80
81
82
83
84
85
# File 'lib/mpp/x402/server.rb', line 76

def payment_required_body(requirements:, resource_url:, extensions: nil, error: nil)
  body = {
    "accepts" => [requirements],
    "resource" => {"url" => resource_url},
    "x402Version" => VERSION
  }
  body["extensions"] = extensions if extensions
  body["error"] = error if error
  body
end

.route_extensions(challenge, http_method) ⇒ Object



93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/mpp/x402/server.rb', line 93

def route_extensions(challenge, http_method)
  return nil if http_method.nil? || http_method.empty?

  binding = {"method" => http_method}
  binding["digest"] = challenge.digest if challenge.digest
  binding["opaque"] = Mpp::Parsing.b64_encode(challenge.opaque) if challenge.opaque
  {
    MPPX_EXTENSION_KEY => {
      "info" => binding,
      "schema" => MPPX_ROUTE_BINDING_SCHEMA
    }
  }
end

.route_nonce(accepted:, resource:, extensions:) ⇒ Object



114
115
116
117
118
119
120
121
122
# File 'lib/mpp/x402/server.rb', line 114

def route_nonce(accepted:, resource:, extensions:)
  input = [
    serialize_request(accepted),
    serialize_request(resource),
    serialize_request(extensions)
  ].join("|")
  digest = Digest::SHA256.digest(input)
  "0x#{digest.unpack1("H*")}"
end

.serialize_request(value) ⇒ Object



125
126
127
128
# File 'lib/mpp/x402/server.rb', line 125

def serialize_request(value)
  json = Mpp::Json.compact_encode(value)
  Mpp.b64url_encode(json)
end

.to_payment_requirements(request, authorization:, max_timeout_seconds:) ⇒ Object



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/mpp/x402/server.rb', line 49

def to_payment_requirements(request, authorization:, max_timeout_seconds:)
  chain_id = request.dig("methodDetails", "chainId")
  Kernel.raise ArgumentError, "EVM charge request requires methodDetails.chainId" if chain_id.nil?

  {
    "amount" => request["amount"].to_s,
    "asset" => request["currency"].to_s,
    "extra" => {
      "assetTransferMethod" => ASSET_TRANSFER_EIP3009,
      "name" => authorization["name"] || authorization[:name],
      "version" => authorization["version"] || authorization[:version]
    },
    "maxTimeoutSeconds" => max_timeout_seconds,
    "network" => "#{EVM_NETWORK_PREFIX}#{chain_id}",
    "payTo" => request["recipient"].to_s,
    "scheme" => SCHEME_EXACT
  }
end