mpp-rb 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c71efa5e58021d08e4ed10c0156ed9b140923b0f3ebeb5ce6b42839594c27dd6
4
- data.tar.gz: d40e3c4994086eea6f2c11023f47fda635637ae6815dc263d63c12ca10235ae8
3
+ metadata.gz: c38b87554f2c1cbef8acfb7d2ba2197b14140500e52bff79276602c76051f8ed
4
+ data.tar.gz: c11b04aef7e116a9b298e3b4b4286dfc4194348b6bd2aca60b1d3f235911ac25
5
5
  SHA512:
6
- metadata.gz: af6ba306f236fb01dd140f63ce51c764fdee655a7f1282d7adad87d8586e4838d43f096ca79a4053dbab703afdc6fa1806d3e20ea6b6afa75c7c70bf6d3885f3
7
- data.tar.gz: db9bc204d61dcdb4c3e9d0b8f9034f935dd38d4291b71b37e61a29889240b27667400e5c48333d2cf39e2d6513b5af70368a354a8a20fd5a543558b07b68f700
6
+ metadata.gz: 795895e5b9b8a768d96468c7f34b468b9a205e694c2b212dc1fca00ecfd2e5906c95ec95455ac79432f1ba95b21a91ba31cd71ccecbb1dbc1226a3cf274ccbf8
7
+ data.tar.gz: 81855362a29e248846445b3cebb0a1bce2a4f2cc2164c49e462ac923ade2fc1d30ef527786579313ffe270d02c89ec8b32a0dd0c09a74403bc69820de112ee10
data/README.md CHANGED
@@ -49,6 +49,61 @@ else
49
49
  end
50
50
  ```
51
51
 
52
+ ### Multiple methods
53
+
54
+ ```ruby
55
+ server = Mpp.create(methods: [tempo, evm, stripe])
56
+
57
+ paid = server.compose(
58
+ [tempo, {amount: "0.01"}],
59
+ [evm, {amount: "0.01"}],
60
+ [stripe, {amount: "0.01", currency: "usd"}]
61
+ )
62
+
63
+ result = paid.call(
64
+ authorization: env["HTTP_AUTHORIZATION"],
65
+ payment_signature: env["HTTP_PAYMENT_SIGNATURE"],
66
+ accept_payment: env["HTTP_ACCEPT_PAYMENT"],
67
+ url: request.url,
68
+ http_method: request.request_method
69
+ )
70
+
71
+ if result.payment_required?
72
+ resp = result.to_response
73
+ else
74
+ credential, receipt = result.payment
75
+ end
76
+ ```
77
+
78
+ `evm.charge` additionally emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` (x402 v2 exact) when a facilitator is configured:
79
+
80
+ ```ruby
81
+ # Public / testnet facilitator
82
+ x402: {facilitator: "https://x402.org/facilitator"}
83
+
84
+ # Per-request headers (bearer token, CDP JWT, etc.)
85
+ x402: {facilitator: {url: facilitator_url, headers: -> { {"Authorization" => "Bearer #{token}"} }}}
86
+
87
+ # The proc may take the request path (`/verify`, `/settle`) when headers differ per call
88
+ x402: {facilitator: {url: cdp_url, headers: ->(path) { cdp_headers(path) }}}
89
+
90
+ # Any client with #verify / #settle
91
+ x402: {facilitator: cdp_client}
92
+ ```
93
+
94
+ Tempo charge can sponsor gas through a hosted fee payer, or skip local RPC by sending credentials to a Tempo API-compatible relay. Both use the same `{url:, headers:}` shape as the x402 facilitator:
95
+
96
+ ```ruby
97
+ # Hosted fee payer (JSON-RPC eth_signRawTransaction)
98
+ fee_payer: {url: sponsor_url, headers: -> { {"Authorization" => "Bearer #{token}"} }}
99
+
100
+ # Local co-sign
101
+ fee_payer: Mpp::Methods::Tempo::Account.from_key(ENV.fetch("FEE_PAYER_KEY"))
102
+
103
+ # Relay (POST /v1/mpp/validate then /v1/mpp/broadcast)
104
+ relay: {url: "https://api.tempo.xyz", headers: -> { {"tempo-api-key" => ENV.fetch("TEMPO_API_KEY")} }}
105
+ ```
106
+
52
107
  ### Client
53
108
 
54
109
  ```ruby
@@ -122,6 +177,10 @@ env["mpp.charge"] = { amount: "0.50", description: "Paid endpoint" }
122
177
  |---------|-------------|
123
178
  | [tempo_charge](./examples/tempo_charge/) | Tempo testnet payments via Sinatra |
124
179
  | [stripe_charge](./examples/stripe_charge/) | Stripe payments via Shared Payment Tokens |
180
+ | [compose](./examples/compose/) | Tempo + Base USDC + Stripe SPTs on one endpoint |
181
+ | [evm_x402](./examples/evm_x402/) | EVM charge with x402 exact compatibility |
182
+ | [tempo_feepayer](./examples/tempo_feepayer/) | Tempo charge with a hosted fee-payer `{url:, headers:}` |
183
+ | [tempo_relay](./examples/tempo_relay/) | Tempo charge delegated to an MPP relay `{url:, headers:}` |
125
184
 
126
185
  Each example is a standalone Sinatra app with `/free` and `/paid` endpoints. To run one:
127
186
 
@@ -143,8 +202,11 @@ npx mppx http://localhost:4567/paid
143
202
  |--------|---------------|---------------|
144
203
  | Tempo | Yes | Yes |
145
204
  | Stripe | Yes | Yes |
205
+ | EVM (`evm.charge`, x402 exact) | No | Yes |
206
+
207
+ Tempo charge transaction construction is implemented directly in Ruby. Runtime dependency: `keccak` (Tempo attribution memos). Optional dependencies: `eth` (account signing, EIP-3009 recovery) and `rlp` (fee payer envelope).
146
208
 
147
- Tempo charge transaction construction is implemented directly in Ruby. Optional dependencies: `eth` (account signing) and `rlp` (fee payer envelope).
209
+ `Mpp.create` accepts a single `method:` (unchanged) or `methods:` to register several payment methods. `server.compose` presents every method as multiple `WWW-Authenticate` challenges; `evm.charge` also emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` when a facilitator is configured. The Ruby HTTP client does not yet sign EVM or x402 credentials.
148
210
 
149
211
  ## Protocol
150
212
 
data/lib/mpp/errors.rb CHANGED
@@ -30,6 +30,7 @@ module Mpp
30
30
  end
31
31
  subclass.instance_variable_set(:@title, to_title(name)) unless subclass.instance_variable_defined?(:@title)
32
32
  subclass.instance_variable_set(:@status, 402) unless subclass.instance_variable_defined?(:@status)
33
+ subclass.instance_variable_set(:@hint, nil) unless subclass.instance_variable_defined?(:@hint)
33
34
  end
34
35
 
35
36
  class << self
@@ -44,6 +45,9 @@ module Mpp
44
45
  sig { returns(T.nilable(String)) }
45
46
  attr_reader :title
46
47
 
48
+ sig { returns(T.nilable(String)) }
49
+ attr_reader :hint
50
+
47
51
  private
48
52
 
49
53
  sig { params(name: String).returns(String) }
@@ -60,6 +64,7 @@ module Mpp
60
64
  @status = T.let(402, Integer)
61
65
  @type = T.let("#{BASE_URI}/payment-error", String)
62
66
  @title = T.let("Payment Error", String)
67
+ @hint = T.let(nil, T.nilable(String))
63
68
 
64
69
  sig { returns(T.untyped) }
65
70
  def status = self.class.status
@@ -67,6 +72,8 @@ module Mpp
67
72
  def type = self.class.type
68
73
  sig { returns(T.untyped) }
69
74
  def title = self.class.title
75
+ sig { returns(T.untyped) }
76
+ def hint = self.class.hint
70
77
 
71
78
  # Convert to RFC 9457 Problem Details format.
72
79
  sig { params(challenge_id: T.untyped).returns(T::Hash[T.untyped, T.untyped]) }
@@ -77,14 +84,26 @@ module Mpp
77
84
  "status" => status,
78
85
  "detail" => message
79
86
  }
87
+ details["hint"] = hint if hint
80
88
  details["challengeId"] = challenge_id if challenge_id
81
89
  details
82
90
  end
83
91
  end
84
92
 
93
+ # Default hint messages for payment errors.
94
+ module Hints
95
+ PAYMENT_REQUIRED = "Use a supported wallet to pay for this resource using one of the supported " \
96
+ "payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md"
97
+ MALFORMED_CREDENTIAL = "Use a supported wallet to construct valid credentials for one of the supported " \
98
+ "payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md"
99
+ METHOD_UNSUPPORTED = PAYMENT_REQUIRED
100
+ end
101
+
85
102
  class PaymentRequiredError < PaymentError
86
103
  extend T::Sig
87
104
 
105
+ @hint = T.let(Hints::PAYMENT_REQUIRED, T.nilable(String))
106
+
88
107
  sig { params(realm: T.untyped, description: T.untyped).void }
89
108
  def initialize(realm: nil, description: nil)
90
109
  parts = ["Payment is required"]
@@ -97,6 +116,8 @@ module Mpp
97
116
  class MalformedCredentialError < PaymentError
98
117
  extend T::Sig
99
118
 
119
+ @hint = T.let(Hints::MALFORMED_CREDENTIAL, T.nilable(String))
120
+
100
121
  sig { params(reason: T.untyped).void }
101
122
  def initialize(reason: nil)
102
123
  msg = reason ? "Credential is malformed: #{reason}." : "Credential is malformed."
@@ -173,6 +194,7 @@ module Mpp
173
194
  @status = T.let(400, Integer)
174
195
  @type = T.let("#{BASE_URI}/method-unsupported", String)
175
196
  @title = T.let("Method Unsupported", String)
197
+ @hint = T.let(Hints::METHOD_UNSUPPORTED, T.nilable(String))
176
198
 
177
199
  sig { params(method: T.untyped).void }
178
200
  def initialize(method: nil)
@@ -0,0 +1,61 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Http
6
+ # Shared `{url:, headers:}` helpers used by the x402 facilitator and Tempo
7
+ # fee-payer and relay HTTP clients.
8
+ #
9
+ # `headers` may be:
10
+ # * a Hash of static header names/values
11
+ # * a zero-arity proc returning that Hash
12
+ # * a proc receiving the request path (or JSON-RPC method) and returning
13
+ # that Hash
14
+ # * a nested Hash keyed by operation (`"verify"`, `"broadcast"`, …)
15
+ module Headers
16
+ extend T::Sig
17
+
18
+ module_function
19
+
20
+ sig { params(url: T.untyped).returns(String) }
21
+ def normalize_base_url(url)
22
+ base = url.to_s
23
+ base = base.chomp("/") while base.end_with?("/")
24
+ base
25
+ end
26
+
27
+ sig { params(config: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
28
+ def symbolize(config)
29
+ config.each_with_object({}) do |(key, value), acc|
30
+ acc[key.to_sym] = value
31
+ end
32
+ end
33
+
34
+ sig { params(headers: T.nilable(T::Hash[T.untyped, T.untyped])).returns(T::Hash[String, String]) }
35
+ def stringify(headers)
36
+ return {} if headers.nil?
37
+
38
+ headers.each_with_object({}) do |(key, value), acc|
39
+ acc[key.to_s] = value.to_s
40
+ end
41
+ end
42
+
43
+ sig { params(source: T.untyped, path: String).returns(T::Hash[String, String]) }
44
+ def resolve(source, path)
45
+ return {} if source.nil?
46
+
47
+ if source.respond_to?(:call)
48
+ arity = source.respond_to?(:arity) ? source.arity : 1
49
+ source = (arity == 0) ? source.call : source.call(path)
50
+ end
51
+ return {} unless source.is_a?(Hash)
52
+
53
+ operation = path.to_s.delete_prefix("/")
54
+ last = T.let(operation.split("/").last, T.nilable(String))
55
+ keyed = source[operation] || source[operation.to_sym]
56
+ keyed ||= source[last] || source[last.to_sym] if last && last != operation
57
+ stringify(keyed.is_a?(Hash) ? keyed : source)
58
+ end
59
+ end
60
+ end
61
+ end
data/lib/mpp/http.rb ADDED
@@ -0,0 +1,8 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Http
6
+ autoload :Headers, "mpp/http/headers"
7
+ end
8
+ end
@@ -0,0 +1,103 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Evm
7
+ # Known EIP-3009 assets plus helpers to resolve a currency config.
8
+ module Assets
9
+ Asset = Struct.new(:address, :decimals, :chain_id, :name, :version, keyword_init: true) do
10
+ def network
11
+ "eip155:#{chain_id}"
12
+ end
13
+ end
14
+
15
+ def self.hex_address(*chunks)
16
+ "0x#{chunks.join}"
17
+ end
18
+
19
+ BASE_USDC = Asset.new(
20
+ address: hex_address("833589fCD6eDb6E08f", "4c7C32D4f71b54bdA02913"),
21
+ decimals: 6,
22
+ chain_id: 8453,
23
+ name: "USD Coin",
24
+ version: "2"
25
+ )
26
+ BASE_SEPOLIA_USDC = Asset.new(
27
+ address: hex_address("036CbD53842c542663", "4e7929541eC2318f3dCF7e"),
28
+ decimals: 6,
29
+ chain_id: 84532,
30
+ name: "USDC",
31
+ version: "2"
32
+ )
33
+ CELO_USDC = Asset.new(
34
+ address: hex_address("cebA9300f2b948710d", "2653dD7B07f33A8B32118C"),
35
+ decimals: 6,
36
+ chain_id: 42220,
37
+ name: "USDC",
38
+ version: "2"
39
+ )
40
+ CELO_USDT = Asset.new(
41
+ address: hex_address("48065fbBE25f71C928", "2ddf5e1cD6D6A887483D5e"),
42
+ decimals: 6,
43
+ chain_id: 42220,
44
+ name: "Tether USD",
45
+ version: "1"
46
+ )
47
+ CELO_SEPOLIA_USDC = Asset.new(
48
+ address: hex_address("01C5C0122039549AD1", "493B8220cABEdD739BC44E"),
49
+ decimals: 6,
50
+ chain_id: 11142220,
51
+ name: "USDC",
52
+ version: "2"
53
+ )
54
+
55
+ private_class_method :hex_address
56
+
57
+ module_function
58
+
59
+ def resolve(currency, authorization: nil, chain_id: nil, decimals: nil)
60
+ if currency.is_a?(Asset)
61
+ auth = authorization_hash(authorization) || {"name" => currency.name, "version" => currency.version}
62
+ return {
63
+ address: checksum(currency.address),
64
+ chain_id: chain_id || currency.chain_id,
65
+ decimals: decimals || currency.decimals,
66
+ authorization: auth
67
+ }
68
+ end
69
+
70
+ address = currency.to_s
71
+ Kernel.raise ArgumentError, "EVM currency must be a known asset or 0x-prefixed address" unless address.match?(/\A0x[a-fA-F0-9]{40}\z/)
72
+ Kernel.raise ArgumentError, "EVM authorization requires `chain_id`." if chain_id.nil?
73
+ Kernel.raise ArgumentError, "EVM authorization requires `decimals`." if decimals.nil?
74
+
75
+ auth = authorization_hash(authorization)
76
+ Kernel.raise ArgumentError, "EVM authorization requires `authorization` metadata." unless auth
77
+
78
+ {
79
+ address: checksum(address),
80
+ chain_id: Kernel.Integer(chain_id),
81
+ decimals: Kernel.Integer(decimals),
82
+ authorization: auth
83
+ }
84
+ end
85
+
86
+ def checksum(address)
87
+ Authorization.checksum_address(address)
88
+ end
89
+
90
+ def authorization_hash(authorization)
91
+ return nil if authorization.nil?
92
+
93
+ name = authorization[:name] || authorization["name"]
94
+ version = authorization[:version] || authorization["version"]
95
+ Kernel.raise ArgumentError, "authorization requires name and version" if name.to_s.empty? || version.to_s.empty?
96
+
97
+ {"name" => name.to_s, "version" => version.to_s}
98
+ end
99
+ private_class_method :authorization_hash
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,140 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Evm
7
+ # EIP-712 TransferWithAuthorization hashing and recovery.
8
+ module Authorization
9
+ DOMAIN_TYPE_HASH = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
10
+ TRANSFER_TYPE_HASH = "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
11
+
12
+ module_function
13
+
14
+ def keccak256(data)
15
+ Kernel.require "eth"
16
+ Eth::Util.keccak256(data)
17
+ rescue Gem::LoadError
18
+ raise LoadError, "eth gem is not available"
19
+ end
20
+
21
+ def challenge_hash(challenge)
22
+ digest = keccak256("#{challenge.id}#{challenge.realm}")
23
+ "0x#{digest.unpack1("H*")}"
24
+ end
25
+
26
+ def checksum_address(address)
27
+ raw = address.to_s
28
+ hex = raw.delete_prefix("0x")
29
+ raise ArgumentError, "invalid address: #{address}" unless hex.match?(/\A[a-fA-F0-9]{40}\z/)
30
+
31
+ begin
32
+ hash = keccak256(hex.downcase).unpack1("H*")
33
+ rescue LoadError
34
+ return raw.start_with?("0x") ? raw : "0x#{hex}"
35
+ end
36
+
37
+ checksummed = hex.downcase.chars.each_with_index.map { |char, index|
38
+ (char.match?(/[a-f]/) && hash[index].to_i(16) >= 8) ? char.upcase : char
39
+ }.join
40
+ "0x#{checksummed}"
41
+ end
42
+
43
+ def signing_hash(authorization:, chain_id:, currency:, from:, to:, value:, valid_after:, valid_before:, nonce:)
44
+ domain = domain_separator(
45
+ name: authorization["name"] || authorization[:name],
46
+ version: authorization["version"] || authorization[:version],
47
+ chain_id: chain_id,
48
+ verifying_contract: currency
49
+ )
50
+ struct = struct_hash(
51
+ from: from,
52
+ to: to,
53
+ value: value,
54
+ valid_after: valid_after,
55
+ valid_before: valid_before,
56
+ nonce: nonce
57
+ )
58
+ keccak256("\x19\x01".b + domain + struct)
59
+ end
60
+
61
+ def recover(authorization:, chain_id:, currency:, payload:)
62
+ hash = signing_hash(
63
+ authorization: authorization,
64
+ chain_id: chain_id,
65
+ currency: currency,
66
+ from: payload["from"],
67
+ to: payload["to"],
68
+ value: payload["value"],
69
+ valid_after: payload["validAfter"],
70
+ valid_before: payload["validBefore"],
71
+ nonce: payload["nonce"]
72
+ )
73
+ recover_address(hash, payload["signature"])
74
+ end
75
+
76
+ def domain_separator(name:, version:, chain_id:, verifying_contract:)
77
+ keccak256(
78
+ abi_encode(
79
+ keccak256(DOMAIN_TYPE_HASH),
80
+ keccak256(name),
81
+ keccak256(version),
82
+ uint256(chain_id),
83
+ address_word(verifying_contract)
84
+ )
85
+ )
86
+ end
87
+
88
+ def struct_hash(from:, to:, value:, valid_after:, valid_before:, nonce:)
89
+ keccak256(
90
+ abi_encode(
91
+ keccak256(TRANSFER_TYPE_HASH),
92
+ address_word(from),
93
+ address_word(to),
94
+ uint256(value),
95
+ uint256(valid_after),
96
+ uint256(valid_before),
97
+ bytes32_word(nonce)
98
+ )
99
+ )
100
+ end
101
+
102
+ def abi_encode(*values)
103
+ values.join
104
+ end
105
+
106
+ def uint256(value)
107
+ value = Integer(value)
108
+ raise ArgumentError, "uint256 out of range" if value.negative? || value >= (1 << 256)
109
+
110
+ [value.to_s(16).rjust(64, "0")].pack("H*")
111
+ end
112
+
113
+ def address_word(value)
114
+ hex = value.to_s.delete_prefix("0x")
115
+ raise ArgumentError, "invalid address: #{value}" unless hex.match?(/\A[a-fA-F0-9]{40}\z/)
116
+
117
+ [hex].pack("H*").rjust(32, "\x00".b)
118
+ end
119
+
120
+ def bytes32_word(value)
121
+ hex = value.to_s.delete_prefix("0x")
122
+ raise ArgumentError, "invalid bytes32: #{value}" unless hex.match?(/\A[a-fA-F0-9]{64}\z/)
123
+
124
+ [hex].pack("H*")
125
+ end
126
+
127
+ def recover_address(hash, signature)
128
+ Kernel.require "eth"
129
+
130
+ sig = signature.to_s
131
+ sig = "0x#{sig}" unless sig.start_with?("0x")
132
+ recovered_key = Eth::Signature.recover(hash, sig)
133
+ Eth::Util.public_key_to_address(recovered_key).to_s
134
+ rescue
135
+ nil
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,129 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+
6
+ module Mpp
7
+ module Methods
8
+ module Evm
9
+ # Server-side EVM charge intent: recover EIP-3009, then settle via facilitator.
10
+ class ChargeIntent
11
+ attr_reader :name, :facilitator, :authorization, :max_timeout_seconds, :route_binding
12
+
13
+ def initialize(authorization:, facilitator:, max_timeout_seconds: 300, route_binding: :resource)
14
+ @name = "charge"
15
+ @authorization = authorization
16
+ @facilitator = facilitator
17
+ @max_timeout_seconds = max_timeout_seconds
18
+ @route_binding = route_binding
19
+ end
20
+
21
+ def verify(credential, request)
22
+ payload = credential.payload
23
+ unless payload.is_a?(Hash) && payload["type"] == "authorization"
24
+ raise Mpp::VerificationError, "EVM authorization credentials are not supported for this challenge"
25
+ end
26
+
27
+ if request.dig("methodDetails", "splits")
28
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization credentials do not support splits")
29
+ end
30
+
31
+ unless addresses_equal?(payload["to"], request["recipient"])
32
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization recipient mismatch")
33
+ end
34
+ unless payload["value"].to_s == request["amount"].to_s
35
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization amount mismatch")
36
+ end
37
+
38
+ unless x402_credential?(credential)
39
+ expected_nonce = Authorization.challenge_hash(credential.challenge)
40
+ unless payload["nonce"].to_s.downcase == expected_nonce.downcase
41
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization challenge hash mismatch")
42
+ end
43
+ end
44
+
45
+ now = Time.now.to_i
46
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization is not valid yet") if Integer(payload["validAfter"]) > now
47
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization has expired") if Integer(payload["validBefore"]) <= now
48
+
49
+ chain_id = request.dig("methodDetails", "chainId")
50
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization requires chainId") if chain_id.nil?
51
+
52
+ signer = Authorization.recover(
53
+ authorization: @authorization,
54
+ chain_id: chain_id,
55
+ currency: request["currency"],
56
+ payload: payload
57
+ )
58
+ unless signer && addresses_equal?(signer, payload["from"])
59
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization signature mismatch")
60
+ end
61
+
62
+ checksummed_from = Authorization.checksum_address(payload["from"])
63
+ source = "did:pkh:eip155:#{chain_id}:#{checksummed_from}"
64
+ if credential.source && credential.source != source
65
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization source mismatch")
66
+ end
67
+
68
+ settled = settle(payload, request)
69
+ Mpp::Receipt.success(settled.fetch("transaction"), method: "evm")
70
+ end
71
+
72
+ def payment_requirements(request)
73
+ Mpp::X402::Server.to_payment_requirements(
74
+ request,
75
+ authorization: @authorization,
76
+ max_timeout_seconds: @max_timeout_seconds
77
+ )
78
+ end
79
+
80
+ private
81
+
82
+ def settle(payload, request)
83
+ requirements = payment_requirements(request)
84
+ payment_payload = {
85
+ "accepted" => requirements,
86
+ "payload" => {
87
+ "authorization" => {
88
+ "from" => payload["from"],
89
+ "nonce" => payload["nonce"],
90
+ "to" => payload["to"],
91
+ "validAfter" => payload["validAfter"].to_s,
92
+ "validBefore" => payload["validBefore"].to_s,
93
+ "value" => payload["value"].to_s
94
+ },
95
+ "signature" => payload["signature"]
96
+ },
97
+ "x402Version" => Mpp::X402::VERSION
98
+ }
99
+
100
+ verified = @facilitator.verify(payment_payload, requirements)
101
+ unless verified["isValid"]
102
+ raise Mpp::VerificationFailedError.new(
103
+ reason: verified["invalidMessage"] || verified["invalidReason"] || "EVM facilitator verify failed"
104
+ )
105
+ end
106
+
107
+ settled = @facilitator.settle(payment_payload, requirements)
108
+ unless settled["success"]
109
+ raise Mpp::VerificationFailedError.new(
110
+ reason: settled["errorMessage"] || settled["errorReason"] || "EVM facilitator settlement failed"
111
+ )
112
+ end
113
+
114
+ settled
115
+ end
116
+
117
+ def x402_credential?(credential)
118
+ credential.payload.is_a?(Hash) && credential.payload["_x402"] == true
119
+ end
120
+
121
+ def addresses_equal?(left, right)
122
+ return false if left.nil? || right.nil?
123
+
124
+ left.to_s.delete_prefix("0x").downcase == right.to_s.delete_prefix("0x").downcase
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end