mpp-rb 0.1.5 → 0.1.7

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +150 -0
  3. data/lib/mpp/challenge.rb +31 -8
  4. data/lib/mpp/challenge_echo.rb +14 -3
  5. data/lib/mpp/challenge_id.rb +30 -6
  6. data/lib/mpp/client/transport.rb +1 -1
  7. data/lib/mpp/extensions/mcp/verify.rb +1 -1
  8. data/lib/mpp/methods/evm/charge_intent.rb +38 -17
  9. data/lib/mpp/methods/evm/evm_method.rb +18 -2
  10. data/lib/mpp/methods/evm.rb +5 -2
  11. data/lib/mpp/methods/stripe/analytics_metadata.rb +24 -0
  12. data/lib/mpp/methods/stripe/charge_intent.rb +34 -11
  13. data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +92 -0
  14. data/lib/mpp/methods/stripe/defaults.rb +1 -0
  15. data/lib/mpp/methods/stripe/machine_payments.rb +128 -0
  16. data/lib/mpp/methods/stripe/payment_intent_method.rb +103 -0
  17. data/lib/mpp/methods/stripe/payment_intent_options.rb +81 -0
  18. data/lib/mpp/methods/stripe/stripe_method.rb +30 -3
  19. data/lib/mpp/methods/stripe.rb +26 -0
  20. data/lib/mpp/methods/tempo/client_method.rb +58 -12
  21. data/lib/mpp/methods/tempo/defaults.rb +51 -0
  22. data/lib/mpp/methods/tempo/intents.rb +195 -125
  23. data/lib/mpp/methods/tempo/relay.rb +33 -31
  24. data/lib/mpp/methods/tempo/schemas.rb +3 -4
  25. data/lib/mpp/parsing.rb +29 -4
  26. data/lib/mpp/server/compose.rb +32 -15
  27. data/lib/mpp/server/decorator.rb +3 -1
  28. data/lib/mpp/server/intent.rb +6 -1
  29. data/lib/mpp/server/intent_lifecycle.rb +41 -0
  30. data/lib/mpp/server/method.rb +41 -0
  31. data/lib/mpp/server/middleware.rb +16 -2
  32. data/lib/mpp/server/mpp_handler.rb +87 -21
  33. data/lib/mpp/server/verify.rb +25 -15
  34. data/lib/mpp/server.rb +1 -0
  35. data/lib/mpp/validation.rb +27 -0
  36. data/lib/mpp/version.rb +1 -1
  37. data/lib/mpp.rb +14 -3
  38. metadata +22 -1
@@ -10,6 +10,8 @@ module Mpp
10
10
  RPC_URL = "https://rpc.tempo.xyz"
11
11
  PATH_USD = "0x20c0000000000000000000000000000000000000"
12
12
  USDC = "0x20C000000000000000000000b9537d11c60E8b50"
13
+ # Same address on mainnet and Moderato.
14
+ OUSD = "0x20c0000000000000000000006a37DA5C996874BE"
13
15
  PATH_USD_DECIMALS = 6
14
16
 
15
17
  # Testnet (Moderato)
@@ -25,6 +27,14 @@ module Mpp
25
27
  TESTNET_CHAIN_ID => PATH_USD
26
28
  }.freeze, T::Hash[T.untyped, T.untyped])
27
29
 
30
+ # Chain ID -> ordered currencies a server accepts by default
31
+ DEFAULT_ACCEPTED_CURRENCIES = T.let({
32
+ CHAIN_ID => [OUSD, USDC].freeze,
33
+ TESTNET_CHAIN_ID => [OUSD, PATH_USD].freeze
34
+ }.freeze, T::Hash[Integer, T::Array[String]])
35
+
36
+ ADDRESS_PATTERN = T.let(/\A0x[0-9a-fA-F]{40}\z/, Regexp)
37
+
28
38
  # Chain ID -> default RPC URL mapping
29
39
  CHAIN_RPC_URLS = T.let({
30
40
  CHAIN_ID => RPC_URL,
@@ -57,6 +67,47 @@ module Mpp
57
67
  DEFAULT_CURRENCIES.fetch(chain_id, PATH_USD)
58
68
  end
59
69
 
70
+ # Ordered currencies a server offers when none are configured. Unknown
71
+ # chains keep the single default from default_currency_for_chain.
72
+ sig { params(chain_id: T.nilable(Integer)).returns(T::Array[String]) }
73
+ def default_currencies_for_chain(chain_id)
74
+ defaults = chain_id && DEFAULT_ACCEPTED_CURRENCIES[chain_id]
75
+ defaults || [default_currency_for_chain(chain_id)]
76
+ end
77
+
78
+ # Resolve the ordered currencies a server method accepts. An explicit
79
+ # currencies list replaces the chain defaults, and the legacy singular
80
+ # currency restricts acceptance to exactly that token.
81
+ sig do
82
+ params(
83
+ chain_id: T.nilable(Integer),
84
+ currency: T.nilable(String),
85
+ currencies: T.nilable(T::Array[String])
86
+ ).returns(T::Array[String])
87
+ end
88
+ def accepted_currencies(chain_id: nil, currency: nil, currencies: nil)
89
+ unless currency.nil? || currencies.nil?
90
+ Kernel.raise ArgumentError, "pass currency: or currencies:, not both"
91
+ end
92
+ return [currency] unless currency.nil?
93
+ return default_currencies_for_chain(chain_id) if currencies.nil?
94
+
95
+ Kernel.raise ArgumentError, "currencies must not be empty" if currencies.empty?
96
+ currencies.each do |address|
97
+ unless ADDRESS_PATTERN.match?(address.to_s)
98
+ Kernel.raise ArgumentError, "Invalid Tempo currency address: #{address.inspect}"
99
+ end
100
+ end
101
+ currencies.uniq(&:downcase)
102
+ end
103
+
104
+ # Fee tokens a local fee payer accepts by default: pathUSD, then the
105
+ # chain's default currency.
106
+ sig { params(chain_id: T.nilable(Integer)).returns(T::Array[String]) }
107
+ def default_fee_tokens(chain_id)
108
+ [PATH_USD, default_currency_for_chain(chain_id)].uniq(&:downcase)
109
+ end
110
+
60
111
  sig { params(chain_id: T.nilable(Integer), testnet: T::Boolean).returns(String) }
61
112
  def resolve_currency(chain_id: nil, testnet: false)
62
113
  id = chain_id || (testnet ? TESTNET_CHAIN_ID : CHAIN_ID)
@@ -22,7 +22,9 @@ module Mpp
22
22
  attr_reader :name
23
23
  attr_accessor :rpc_url
24
24
 
25
- def initialize(chain_id: nil, rpc_url: nil, timeout: 30, store: nil, validate_sender: nil)
25
+ def initialize(chain_id: nil, rpc_url: nil, timeout: 30, store: Mpp::MemoryStore.new, validate_sender: nil)
26
+ raise ArgumentError, "store is required" if store.nil?
27
+
26
28
  @name = "charge"
27
29
  @rpc_url = rpc_url || (chain_id ? Defaults.rpc_url_for_chain(chain_id) : nil)
28
30
  @_method = nil
@@ -39,11 +41,75 @@ module Mpp
39
41
  @_method&.fee_payer_allowed_fee_tokens
40
42
  end
41
43
 
44
+ def configured_fee_token
45
+ @_method&.fee_token
46
+ end
47
+
42
48
  def relay
43
49
  @_method&.relay
44
50
  end
45
51
 
52
+ # @deprecated Use #validate followed by #broadcast.
46
53
  def verify(credential, request)
54
+ validate(credential, request)
55
+ broadcast(credential, request)
56
+ end
57
+
58
+ def validate(credential, request)
59
+ req = resolve_request(credential, request)
60
+ if relay
61
+ relay.validate(Relay.to_relay_input(credential))
62
+ else
63
+ validate_credential(credential, req)
64
+ end
65
+
66
+ Mpp::Validation.new(
67
+ challenge: credential.challenge,
68
+ credential: credential,
69
+ details: {mode: validation_mode(credential)},
70
+ intent: name,
71
+ method: "tempo",
72
+ request: request,
73
+ source: credential.source
74
+ )
75
+ end
76
+
77
+ def broadcast(credential, request)
78
+ req = resolve_request(credential, request)
79
+ return relay.broadcast(Relay.to_relay_input(credential)) if relay
80
+
81
+ # mppx revalidates Tempo credentials before claiming replay state or
82
+ # submitting a transaction. No per-credential state lives on the intent.
83
+ validated = validate_credential(credential, req)
84
+ case credential.payload["type"]
85
+ when "hash"
86
+ hash = credential.payload.fetch("hash")
87
+ unless @store.put_if_absent("mpp:charge:#{hash.downcase}", hash)
88
+ raise Mpp::VerificationError, "Transaction hash already used"
89
+ end
90
+ validated
91
+ when "proof"
92
+ unless @store.put_if_absent("mpp:proof:#{credential.challenge.id}", true)
93
+ raise Mpp::VerificationError, "Proof credential has already been used"
94
+ end
95
+ validated
96
+ when "transaction"
97
+ payload = Schemas::TransactionCredentialPayload.new(type: "transaction", signature: credential.payload["signature"])
98
+ broadcast_transaction(payload, req, credential: credential)
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def validation_mode(credential)
105
+ case credential.payload["type"]
106
+ when "hash" then "push"
107
+ when "proof" then "proof"
108
+ when "transaction" then "pull"
109
+ end
110
+ end
111
+
112
+ def resolve_request(credential, request)
47
113
  req = Schemas::ChargeRequest.from_hash(request)
48
114
 
49
115
  # Check challenge expiry
@@ -53,69 +119,61 @@ module Mpp
53
119
  raise Mpp::VerificationError, "Request has expired" if expires < Time.now.utc
54
120
  end
55
121
 
56
- return relay.verify(credential, request) if relay
122
+ req
123
+ end
57
124
 
125
+ def validate_credential(credential, req)
58
126
  payload_data = credential.payload
59
127
  unless payload_data.is_a?(Hash) && payload_data.key?("type")
60
- raise Mpp::VerificationError,
61
- "Invalid credential payload"
128
+ raise Mpp::VerificationError, "Invalid credential payload"
62
129
  end
63
130
 
64
131
  case payload_data["type"]
65
132
  when "hash"
66
133
  payload = Schemas::HashCredentialPayload.new(type: "hash", hash: payload_data["hash"])
67
- verify_hash(payload, req, credential: credential)
134
+ validate_hash(payload, req, credential: credential)
68
135
  when "transaction"
69
136
  payload = Schemas::TransactionCredentialPayload.new(
70
137
  type: "transaction", signature: payload_data["signature"]
71
138
  )
72
- verify_transaction(payload, req, credential: credential)
139
+ validate_transaction(payload, req, credential: credential)
73
140
  when "proof"
74
141
  payload = Schemas::ProofCredentialPayload.new(
75
142
  type: "proof", signature: payload_data["signature"]
76
143
  )
77
- verify_proof(payload, req, credential: credential)
144
+ validate_proof(payload, req, credential: credential)
78
145
  else
79
146
  raise Mpp::VerificationError, "Invalid credential type: #{payload_data["type"]}"
80
147
  end
81
148
  end
82
149
 
83
- private
84
-
85
150
  def get_rpc_url
86
151
  raise Mpp::VerificationError, "No rpc_url configured on ChargeIntent" unless @rpc_url
87
152
 
88
153
  @rpc_url
89
154
  end
90
155
 
91
- # Parse a hash credential source: nil if absent, the address for a
156
+ # Parse a credential source: nil if absent, the address for a
92
157
  # did:pkh:eip155 DID matching expected_chain_id, else raises.
93
- def parse_hash_credential_source(source, expected_chain_id)
158
+ def parse_credential_source(source, expected_chain_id)
94
159
  return nil unless source
95
160
 
96
161
  expected_chain_id = begin
97
162
  Integer(expected_chain_id)
98
163
  rescue ArgumentError, TypeError
99
- raise Mpp::VerificationError, "Hash credential source is invalid"
164
+ raise Mpp::VerificationError, "Credential source is invalid"
100
165
  end
101
166
 
102
167
  parsed = Proof.parse_source(source)
103
168
  unless parsed && parsed[:chain_id] == expected_chain_id
104
- raise Mpp::VerificationError, "Hash credential source is invalid"
169
+ raise Mpp::VerificationError, "Credential source is invalid"
105
170
  end
106
171
 
107
172
  parsed[:address]
108
173
  end
109
174
 
110
- def verify_hash(payload, request, credential:)
111
- # Validate the source before reserving the hash.
112
- source_address = parse_hash_credential_source(credential.source, request.method_details.chain_id)
113
-
114
- if @store
115
- store_key = "mpp:charge:#{payload.hash.downcase}"
116
- raise Mpp::VerificationError, "Transaction hash already used" unless @store.put_if_absent(store_key,
117
- payload.hash)
118
- end
175
+ def validate_hash(payload, request, credential:)
176
+ source_address = parse_credential_source(credential.source, request.method_details.chain_id)
119
177
 
120
178
  rpc_url = get_rpc_url
121
179
  result = Rpc.call(rpc_url, "eth_getTransactionReceipt", [payload.hash])
@@ -133,14 +191,12 @@ module Mpp
133
191
  raise Mpp::VerificationError,
134
192
  "Transaction must contain a Transfer log matching request parameters"
135
193
  end
136
- assert_challenge_bound_memo(matched_logs, credential.challenge) unless request.method_details.memo
194
+ assert_challenge_bound_memo(matched_logs, credential.challenge)
137
195
 
138
196
  Mpp::Receipt.success(payload.hash)
139
197
  end
140
198
 
141
- def verify_transaction(payload, request, credential:)
142
- validate_transaction_payload(payload.signature, request, challenge: credential.challenge)
143
-
199
+ def broadcast_transaction(payload, request, credential:)
144
200
  raw_tx = payload.signature
145
201
 
146
202
  # Simulation payload for the locally co-signed tx, if we sponsor it.
@@ -151,26 +207,22 @@ module Mpp
151
207
  raise Mpp::VerificationError, "No fee payer configured" unless payer
152
208
 
153
209
  if local_fee_payer?(payer)
154
- raw_tx, simulate_payload = cosign_as_fee_payer(raw_tx, request.currency, request: request, challenge: credential.challenge)
210
+ raw_tx, simulate_payload = cosign_as_fee_payer(raw_tx, nil, request: request, challenge: credential.challenge)
155
211
  else
156
212
  raw_tx = payer.cosign(raw_tx)
157
213
  end
158
214
  end
159
215
 
160
216
  rpc_url = get_rpc_url
161
- reserved_tx_hash = T.let(nil, T.nilable(String))
162
- store_key = T.let(nil, T.nilable(String))
163
- if @store
164
- reserved_tx_hash = raw_transaction_hash(raw_tx)
165
- store_key = "mpp:charge:#{reserved_tx_hash.downcase}"
166
- unless @store.put_if_absent(store_key, TRANSACTION_PENDING)
167
- raise Mpp::VerificationError, "Transaction hash already used" unless @store.get(store_key) == TRANSACTION_PENDING
217
+ reserved_tx_hash = raw_transaction_hash(raw_tx)
218
+ store_key = "mpp:charge:#{reserved_tx_hash.downcase}"
219
+ unless @store.put_if_absent(store_key, TRANSACTION_PENDING)
220
+ raise Mpp::VerificationError, "Transaction hash already used" unless @store.get(store_key) == TRANSACTION_PENDING
168
221
 
169
- receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
170
- verify_transaction_receipt!(receipt_data, request, credential: credential)
171
- @store.put(store_key, TRANSACTION_VERIFIED)
172
- return Mpp::Receipt.success(reserved_tx_hash)
173
- end
222
+ receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
223
+ verify_transaction_receipt!(receipt_data, request, credential: credential)
224
+ @store.put(store_key, TRANSACTION_VERIFIED)
225
+ return Mpp::Receipt.success(reserved_tx_hash)
174
226
  end
175
227
 
176
228
  # We pay the gas, so simulate the co-signed tx first and bail if it
@@ -178,7 +230,7 @@ module Mpp
178
230
  begin
179
231
  simulate_before_broadcast(simulate_payload, rpc_url) if simulate_payload
180
232
  rescue
181
- @store.delete(store_key) if @store && store_key
233
+ @store.delete(store_key)
182
234
  raise
183
235
  end
184
236
 
@@ -186,33 +238,36 @@ module Mpp
186
238
  begin
187
239
  tx_hash = Rpc.call(rpc_url, "eth_sendRawTransaction", [raw_tx])
188
240
  rescue => e
189
- if reserved_tx_hash && store_key && transaction_submission_may_have_succeeded?(e)
241
+ if transaction_submission_may_have_succeeded?(e)
190
242
  receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
191
243
  verify_transaction_receipt!(receipt_data, request, credential: credential)
192
- @store&.put(store_key, TRANSACTION_VERIFIED)
244
+ @store.put(store_key, TRANSACTION_VERIFIED)
193
245
  return Mpp::Receipt.success(reserved_tx_hash)
194
246
  end
195
247
 
196
- @store.delete(store_key) if @store && store_key
248
+ @store.delete(store_key)
197
249
  raise Mpp::VerificationError, "Transaction submission failed: #{e.message}"
198
250
  end
199
251
 
200
252
  unless tx_hash
201
- @store.delete(store_key) if @store && store_key
253
+ @store.delete(store_key)
202
254
  raise Mpp::VerificationError, "No transaction hash returned"
203
255
  end
204
256
 
205
- if reserved_tx_hash && tx_hash.downcase != reserved_tx_hash.downcase
206
- @store.delete(store_key) if @store && store_key
207
- raise Mpp::VerificationError, "Returned transaction hash does not match submitted transaction"
208
- end
257
+ # Tempo nodes hash canonical RLP, which can differ from keccak(raw
258
+ # bytes). Replay keys must follow the chain hash so a later hash
259
+ # credential for the same payment is rejected. Keep the raw-hash
260
+ # claim until verification finishes so a concurrent retry cannot
261
+ # rebroadcast the same payload.
262
+ raw_store_key = store_key
263
+ store_key, reserved_tx_hash = rebase_reservation_to_chain_hash(store_key, reserved_tx_hash, tx_hash)
209
264
 
210
- tx_hash = reserved_tx_hash || tx_hash
211
- receipt_data = fetch_transaction_receipt(rpc_url, tx_hash)
265
+ receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
212
266
  verify_transaction_receipt!(receipt_data, request, credential: credential)
213
- @store.put(store_key, TRANSACTION_VERIFIED) if @store && store_key
267
+ @store.put(store_key, TRANSACTION_VERIFIED)
268
+ @store.put(raw_store_key, TRANSACTION_VERIFIED) if raw_store_key != store_key
214
269
 
215
- Mpp::Receipt.success(tx_hash)
270
+ Mpp::Receipt.success(reserved_tx_hash)
216
271
  end
217
272
 
218
273
  def transaction_submission_may_have_succeeded?(error)
@@ -245,12 +300,13 @@ module Mpp
245
300
 
246
301
  def verify_transaction_receipt!(receipt_data, request, credential:)
247
302
  raise Mpp::VerificationError, "Transaction reverted" unless receipt_data["status"] == "0x1"
248
- matched_logs = match_transfer_logs(receipt_data, request, expected_sender: receipt_data["from"])
303
+ source_address = parse_credential_source(credential.source, request.method_details.chain_id)
304
+ matched_logs = match_transfer_logs(receipt_data, request, expected_sender: source_address || receipt_data["from"])
249
305
  unless matched_logs.any?
250
306
  raise Mpp::VerificationError,
251
307
  "Transaction must contain a Transfer log matching request parameters"
252
308
  end
253
- assert_challenge_bound_memo(matched_logs, credential.challenge) unless request.method_details.memo
309
+ assert_challenge_bound_memo(matched_logs, credential.challenge)
254
310
  end
255
311
 
256
312
  def verify_transfer_logs(receipt, request, expected_sender: nil)
@@ -258,7 +314,6 @@ module Mpp
258
314
  end
259
315
 
260
316
  def match_transfer_logs(receipt, request, expected_sender: nil, source: nil, validate_sender: nil)
261
- expected_memo = request.method_details.memo
262
317
  matched_logs = []
263
318
 
264
319
  (receipt["logs"] || []).each do |log|
@@ -273,41 +328,25 @@ module Mpp
273
328
  next unless to_address.downcase == request.recipient.downcase
274
329
 
275
330
  matched =
276
- if expected_memo
277
- next unless topics[0] == TRANSFER_WITH_MEMO_TOPIC
331
+ case topics[0]
332
+ when TRANSFER_WITH_MEMO_TOPIC
278
333
  next if topics.length < 4
279
334
 
280
335
  data = log.fetch("data", "0x")
281
336
  next if data.length < 66
282
337
 
283
338
  amount = data[2, 64].to_i(16)
284
- memo = topics[3]
285
- memo_clean = expected_memo.downcase
286
- memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
287
- next unless amount == Integer(request.amount) && memo.downcase == memo_clean
288
-
289
- {kind: :memo, memo: memo}
290
- else
291
- case topics[0]
292
- when TRANSFER_WITH_MEMO_TOPIC
293
- next if topics.length < 4
294
-
295
- data = log.fetch("data", "0x")
296
- next if data.length < 66
339
+ next unless amount == Integer(request.amount)
297
340
 
298
- amount = data[2, 64].to_i(16)
299
- next unless amount == Integer(request.amount)
300
-
301
- {kind: :memo, memo: topics[3]}
302
- when TRANSFER_TOPIC
303
- data = log.fetch("data", "0x")
304
- next if data.length < 66
341
+ {kind: :memo, memo: topics[3]}
342
+ when TRANSFER_TOPIC
343
+ data = log.fetch("data", "0x")
344
+ next if data.length < 66
305
345
 
306
- amount = data.delete_prefix("0x").to_i(16)
307
- next unless amount == Integer(request.amount)
346
+ amount = data.delete_prefix("0x").to_i(16)
347
+ next unless amount == Integer(request.amount)
308
348
 
309
- {kind: :transfer}
310
- end
349
+ {kind: :transfer}
311
350
  end
312
351
 
313
352
  next unless matched
@@ -339,6 +378,22 @@ module Mpp
339
378
  raise Mpp::VerificationError, "Payment verification failed: memo is not bound to this challenge"
340
379
  end
341
380
 
381
+ def validate_transaction(payload, request, credential:)
382
+ parse_credential_source(credential.source, request.method_details.chain_id)
383
+ validate_transaction_payload(payload.signature, request, challenge: credential.challenge)
384
+ if request.method_details.fee_payer
385
+ payer = fee_payer
386
+ raise Mpp::VerificationError, "No fee payer configured" unless payer
387
+
388
+ # Move existing local sponsorship checks before signing. Hosted
389
+ # payers retain their own policy and terminal credential validation.
390
+ if local_fee_payer?(payer)
391
+ prepare_fee_payer_transaction(payload.signature, nil, request: request, challenge: credential.challenge)
392
+ end
393
+ end
394
+ true
395
+ end
396
+
342
397
  def validate_transaction_payload(signature, request, challenge: nil)
343
398
  # Best-effort pre-broadcast check
344
399
  begin
@@ -389,30 +444,40 @@ module Mpp
389
444
  end
390
445
 
391
446
  def raw_transaction_hash(raw_tx)
392
- Kernel.require "eth"
393
-
394
447
  hex = raw_tx.delete_prefix("0x")
395
448
  unless hex.match?(/\A[0-9a-fA-F]+\z/) && hex.length.even?
396
449
  raise Mpp::VerificationError, "Invalid transaction signature"
397
450
  end
398
451
 
399
- "0x#{Eth::Util.keccak256([hex].pack("H*")).unpack1("H*")}"
400
- rescue LoadError
401
- raise Mpp::VerificationError,
402
- "eth gem is required to compute transaction hash for transaction replay protection"
452
+ "0x#{Attribution.keccak256([hex].pack("H*")).unpack1("H*")}"
453
+ end
454
+
455
+ def normalize_tx_hash(tx_hash)
456
+ hex = tx_hash.to_s.downcase
457
+ hex.start_with?("0x") ? hex : "0x#{hex}"
458
+ end
459
+
460
+ def rebase_reservation_to_chain_hash(store_key, reserved_tx_hash, returned_tx_hash)
461
+ canonical = normalize_tx_hash(returned_tx_hash)
462
+ return [store_key, reserved_tx_hash] if canonical == reserved_tx_hash.downcase
463
+
464
+ canonical_key = "mpp:charge:#{canonical}"
465
+ unless @store.put_if_absent(canonical_key, TRANSACTION_PENDING)
466
+ unless @store.get(canonical_key) == TRANSACTION_PENDING
467
+ @store.put(store_key, TRANSACTION_VERIFIED)
468
+ raise Mpp::VerificationError, "Transaction hash already used"
469
+ end
470
+ end
471
+
472
+ [canonical_key, canonical]
403
473
  end
404
474
 
405
475
  def match_transfer_calldata(call_data_hex, request, challenge: nil)
406
476
  return false if call_data_hex.length < 136
407
477
 
408
478
  selector = call_data_hex[0, 8].downcase
409
- expected_memo = request.method_details.memo
410
479
 
411
- if expected_memo
412
- return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
413
- elsif selector != TRANSFER_WITH_MEMO_SELECTOR
414
- return false
415
- end
480
+ return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
416
481
 
417
482
  decoded_to = "0x#{call_data_hex[32, 40]}"
418
483
  decoded_amount = call_data_hex[72, 64].to_i(16)
@@ -421,23 +486,16 @@ module Mpp
421
486
  return false unless decoded_amount == Integer(request.amount)
422
487
  return false if call_data_hex.length < 200
423
488
 
424
- if expected_memo
425
- decoded_memo = "0x#{call_data_hex[136, 64]}"
426
- memo_clean = expected_memo.downcase
427
- memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
428
- return false unless decoded_memo.downcase == memo_clean
429
- else
430
- return false unless challenge
489
+ return false unless challenge
431
490
 
432
- decoded_memo = "0x#{call_data_hex[136, 64]}"
433
- return false unless Attribution.verify_server(decoded_memo, challenge.realm)
434
- return false unless Attribution.verify_challenge_binding(decoded_memo, challenge.id)
435
- end
491
+ decoded_memo = "0x#{call_data_hex[136, 64]}"
492
+ return false unless Attribution.verify_server(decoded_memo, challenge.realm)
493
+ return false unless Attribution.verify_challenge_binding(decoded_memo, challenge.id)
436
494
 
437
495
  true
438
496
  end
439
497
 
440
- def verify_proof(payload, request, credential:)
498
+ def validate_proof(payload, request, credential:)
441
499
  raise Mpp::VerificationError, "Proof credentials are only valid for zero-amount challenges" unless Integer(request.amount).zero?
442
500
  raise Mpp::VerificationError, "Proof credential must include a source" unless credential.source
443
501
 
@@ -455,11 +513,6 @@ module Mpp
455
513
  )
456
514
  raise Mpp::VerificationError, "Proof signature does not match source" unless valid
457
515
 
458
- if @store
459
- store_key = "mpp:proof:#{credential.challenge.id}"
460
- raise Mpp::VerificationError, "Proof credential has already been used" unless @store.put_if_absent(store_key, true)
461
- end
462
-
463
516
  Mpp::Receipt.success(credential.challenge.id)
464
517
  end
465
518
 
@@ -467,12 +520,27 @@ module Mpp
467
520
  !FeePayerClient.hosted_config?(payer)
468
521
  end
469
522
 
523
+ # A nil fee_token selects the configured fee token, else the first
524
+ # allowed fee token the fee payer holds, else the first allowed.
470
525
  def cosign_as_fee_payer(raw_tx, fee_token, request: nil, challenge: nil)
526
+ raise Mpp::VerificationError, "No fee payer account configured" unless fee_payer
527
+
528
+ tx_to_sign, recovered_addr = prepare_fee_payer_transaction(
529
+ raw_tx, fee_token, request: request, challenge: challenge, select_funded: true
530
+ )
531
+ fee_payer_sig = fee_payer.sign_hash(tx_to_sign.fee_payer_signature_hash)
532
+ signed = tx_to_sign.with(fee_payer_signature: fee_payer_sig)
533
+ raw_tx = "0x#{signed.encoded_2718.unpack1("H*")}"
534
+
535
+ [raw_tx, build_simulate_payload(tx_to_sign, recovered_addr, fee_payer_sig)]
536
+ end
537
+
538
+ # Sponsorship checks, shared by validation and terminal signing. Only
539
+ # select_funded (terminal signing) reads fee payer balances over RPC.
540
+ def prepare_fee_payer_transaction(raw_tx, fee_token, request: nil, challenge: nil, select_funded: false)
471
541
  require "eth"
472
542
  require "rlp"
473
543
 
474
- raise Mpp::VerificationError, "No fee payer account configured" unless fee_payer
475
-
476
544
  # Decode the 0x78 fee payer envelope
477
545
  begin
478
546
  all_bytes = [raw_tx.delete_prefix("0x")].pack("H*")
@@ -591,26 +659,17 @@ module Mpp
591
659
  end
592
660
 
593
661
  # Build the final transaction with fee_token set
594
- resolved_fee_token = fee_token || request&.currency
662
+ allowed_fee_tokens = fee_payer_allowed_fee_tokens || Defaults.default_fee_tokens(chain_id)
663
+ resolved_fee_token = fee_token || configured_fee_token ||
664
+ (select_funded && funded_fee_token(allowed_fee_tokens)) || allowed_fee_tokens.first
595
665
  raise Mpp::VerificationError, "No fee token available" unless resolved_fee_token
596
666
 
597
- allowed_fee_tokens = fee_payer_allowed_fee_tokens ||
598
- [Defaults.default_currency_for_chain(chain_id).downcase]
599
667
  unless allowed_fee_tokens.map(&:downcase).include?(resolved_fee_token.downcase)
600
668
  raise Mpp::VerificationError,
601
669
  "Fee token #{resolved_fee_token} is not allowed by fee payer policy"
602
670
  end
603
671
 
604
- tx_to_sign = tx_for_recovery.with(fee_token: resolved_fee_token)
605
-
606
- # Fee payer signs the 0x78 payload, which identifies the recovered sender.
607
- fee_payer_hash = tx_to_sign.fee_payer_signature_hash
608
- fee_payer_sig = fee_payer.sign_hash(fee_payer_hash)
609
-
610
- signed = tx_to_sign.with(fee_payer_signature: fee_payer_sig)
611
- raw_tx = "0x#{signed.encoded_2718.unpack1("H*")}"
612
-
613
- [raw_tx, build_simulate_payload(tx_to_sign, recovered_addr, fee_payer_sig)]
672
+ [tx_for_recovery.with(fee_token: resolved_fee_token), recovered_addr]
614
673
  end
615
674
 
616
675
  # Build a `tempo_simulateV1` payload for the co-signed `0x76` tx.
@@ -661,6 +720,17 @@ module Mpp
661
720
  }
662
721
  end
663
722
 
723
+ # First allowed fee token with a nonzero fee payer balance.
724
+ def funded_fee_token(tokens)
725
+ owner = fee_payer.address.delete_prefix("0x").downcase.rjust(64, "0")
726
+ tokens.find do |token|
727
+ balance = Rpc.call(get_rpc_url, "eth_call", [{"to" => token, "data" => "0x70a08231#{owner}"}, "latest"])
728
+ balance.is_a?(String) && balance.delete_prefix("0x").to_i(16) > 0
729
+ rescue
730
+ false
731
+ end
732
+ end
733
+
664
734
  # Simulate the co-signed tx and raise if it would revert. Fails closed:
665
735
  # an RPC error is treated as a failed check, not a pass.
666
736
  def simulate_before_broadcast(simulate_payload, rpc_url)