mpp-rb 0.1.1 → 0.1.3

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.
@@ -14,24 +14,31 @@ module Mpp
14
14
  TRANSFER_WITH_MEMO_SELECTOR = "95777d59"
15
15
  TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
16
16
  TRANSFER_WITH_MEMO_TOPIC = "0x57bc7354aa85aed339e000bccffabbc529466af35f0772c8f8ee1145927de7f0"
17
+ TRANSACTION_PENDING = "transaction:pending"
18
+ TRANSACTION_VERIFIED = "transaction:verified"
17
19
 
18
20
  # Tempo charge intent for server-side verification.
19
21
  class ChargeIntent
20
22
  attr_reader :name
21
23
  attr_accessor :rpc_url
22
24
 
23
- def initialize(chain_id: nil, rpc_url: nil, timeout: 30, store: nil)
25
+ def initialize(chain_id: nil, rpc_url: nil, timeout: 30, store: nil, validate_sender: nil)
24
26
  @name = "charge"
25
27
  @rpc_url = rpc_url || (chain_id ? Defaults.rpc_url_for_chain(chain_id) : nil)
26
28
  @_method = nil
27
29
  @timeout = timeout
28
30
  @store = store
31
+ @validate_sender = validate_sender
29
32
  end
30
33
 
31
34
  def fee_payer
32
35
  @_method&.fee_payer
33
36
  end
34
37
 
38
+ def fee_payer_allowed_fee_tokens
39
+ @_method&.fee_payer_allowed_fee_tokens
40
+ end
41
+
35
42
  def verify(credential, request)
36
43
  req = Schemas::ChargeRequest.from_hash(request)
37
44
 
@@ -51,12 +58,12 @@ module Mpp
51
58
  case payload_data["type"]
52
59
  when "hash"
53
60
  payload = Schemas::HashCredentialPayload.new(type: "hash", hash: payload_data["hash"])
54
- verify_hash(payload, req)
61
+ verify_hash(payload, req, credential: credential)
55
62
  when "transaction"
56
63
  payload = Schemas::TransactionCredentialPayload.new(
57
64
  type: "transaction", signature: payload_data["signature"]
58
65
  )
59
- verify_transaction(payload, req)
66
+ verify_transaction(payload, req, credential: credential)
60
67
  when "proof"
61
68
  payload = Schemas::ProofCredentialPayload.new(
62
69
  type: "proof", signature: payload_data["signature"]
@@ -75,7 +82,29 @@ module Mpp
75
82
  @rpc_url
76
83
  end
77
84
 
78
- def verify_hash(payload, request)
85
+ # Parse a hash credential source: nil if absent, the address for a
86
+ # did:pkh:eip155 DID matching expected_chain_id, else raises.
87
+ def parse_hash_credential_source(source, expected_chain_id)
88
+ return nil unless source
89
+
90
+ expected_chain_id = begin
91
+ Integer(expected_chain_id)
92
+ rescue ArgumentError, TypeError
93
+ raise Mpp::VerificationError, "Hash credential source is invalid"
94
+ end
95
+
96
+ parsed = Proof.parse_source(source)
97
+ unless parsed && parsed[:chain_id] == expected_chain_id
98
+ raise Mpp::VerificationError, "Hash credential source is invalid"
99
+ end
100
+
101
+ parsed[:address]
102
+ end
103
+
104
+ def verify_hash(payload, request, credential:)
105
+ # Validate the source before reserving the hash.
106
+ source_address = parse_hash_credential_source(credential.source, request.method_details.chain_id)
107
+
79
108
  if @store
80
109
  store_key = "mpp:charge:#{payload.hash.downcase}"
81
110
  raise Mpp::VerificationError, "Transaction hash already used" unless @store.put_if_absent(store_key,
@@ -87,24 +116,33 @@ module Mpp
87
116
 
88
117
  raise Mpp::VerificationError, "Transaction not found" unless result
89
118
  raise Mpp::VerificationError, "Transaction reverted" unless result["status"] == "0x1"
90
- unless verify_transfer_logs(
91
- result, request
92
- )
119
+
120
+ # Use the source address if present, otherwise the receipt sender.
121
+ # The sender override only applies when a source was declared; without
122
+ # one, the legacy receipt["from"] match must hold unconditionally.
123
+ expected_sender = source_address || result["from"]
124
+ matched_logs = match_transfer_logs(result, request, expected_sender: expected_sender,
125
+ source: credential.source, validate_sender: source_address ? @validate_sender : nil)
126
+ unless matched_logs.any?
93
127
  raise Mpp::VerificationError,
94
128
  "Transaction must contain a Transfer log matching request parameters"
95
129
  end
130
+ assert_challenge_bound_memo(matched_logs, credential.challenge) unless request.method_details.memo
96
131
 
97
132
  Mpp::Receipt.success(payload.hash)
98
133
  end
99
134
 
100
- def verify_transaction(payload, request)
101
- validate_transaction_payload(payload.signature, request)
135
+ def verify_transaction(payload, request, credential:)
136
+ validate_transaction_payload(payload.signature, request, challenge: credential.challenge)
102
137
 
103
138
  raw_tx = payload.signature
104
139
 
140
+ # Simulation payload for the locally co-signed tx, if we sponsor it.
141
+ simulate_payload = nil
142
+
105
143
  if request.method_details.fee_payer
106
144
  if fee_payer
107
- raw_tx = cosign_as_fee_payer(raw_tx, request.currency, request: request)
145
+ raw_tx, simulate_payload = cosign_as_fee_payer(raw_tx, request.currency, request: request, challenge: credential.challenge)
108
146
  else
109
147
  fee_payer_url = request.method_details.fee_payer_url || Defaults::DEFAULT_FEE_PAYER_URL
110
148
  result = Rpc.call(fee_payer_url, "eth_signRawTransaction", [raw_tx])
@@ -115,9 +153,75 @@ module Mpp
115
153
  end
116
154
 
117
155
  rpc_url = get_rpc_url
118
- tx_hash = Rpc.call(rpc_url, "eth_sendRawTransaction", [raw_tx])
119
- raise Mpp::VerificationError, "No transaction hash returned" unless tx_hash
156
+ reserved_tx_hash = T.let(nil, T.nilable(String))
157
+ store_key = T.let(nil, T.nilable(String))
158
+ if @store
159
+ reserved_tx_hash = raw_transaction_hash(raw_tx)
160
+ store_key = "mpp:charge:#{reserved_tx_hash.downcase}"
161
+ unless @store.put_if_absent(store_key, TRANSACTION_PENDING)
162
+ raise Mpp::VerificationError, "Transaction hash already used" unless @store.get(store_key) == TRANSACTION_PENDING
163
+
164
+ receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
165
+ verify_transaction_receipt!(receipt_data, request, credential: credential)
166
+ @store.put(store_key, TRANSACTION_VERIFIED)
167
+ return Mpp::Receipt.success(reserved_tx_hash)
168
+ end
169
+ end
170
+
171
+ # We pay the gas, so simulate the co-signed tx first and bail if it
172
+ # would revert. Fails closed: no simulation, no broadcast.
173
+ begin
174
+ simulate_before_broadcast(simulate_payload, rpc_url) if simulate_payload
175
+ rescue
176
+ @store.delete(store_key) if @store && store_key
177
+ raise
178
+ end
179
+
180
+ tx_hash = T.let(nil, T.nilable(String))
181
+ begin
182
+ tx_hash = Rpc.call(rpc_url, "eth_sendRawTransaction", [raw_tx])
183
+ rescue => e
184
+ if reserved_tx_hash && store_key && transaction_submission_may_have_succeeded?(e)
185
+ receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
186
+ verify_transaction_receipt!(receipt_data, request, credential: credential)
187
+ @store&.put(store_key, TRANSACTION_VERIFIED)
188
+ return Mpp::Receipt.success(reserved_tx_hash)
189
+ end
190
+
191
+ @store.delete(store_key) if @store && store_key
192
+ raise Mpp::VerificationError, "Transaction submission failed: #{e.message}"
193
+ end
194
+
195
+ unless tx_hash
196
+ @store.delete(store_key) if @store && store_key
197
+ raise Mpp::VerificationError, "No transaction hash returned"
198
+ end
199
+
200
+ if reserved_tx_hash && tx_hash.downcase != reserved_tx_hash.downcase
201
+ @store.delete(store_key) if @store && store_key
202
+ raise Mpp::VerificationError, "Returned transaction hash does not match submitted transaction"
203
+ end
204
+
205
+ tx_hash = reserved_tx_hash || tx_hash
206
+ receipt_data = fetch_transaction_receipt(rpc_url, tx_hash)
207
+ verify_transaction_receipt!(receipt_data, request, credential: credential)
208
+ @store.put(store_key, TRANSACTION_VERIFIED) if @store && store_key
120
209
 
210
+ Mpp::Receipt.success(tx_hash)
211
+ end
212
+
213
+ def transaction_submission_may_have_succeeded?(error)
214
+ message = "#{error.class}: #{error.message}".downcase
215
+
216
+ message.include?("timeout") ||
217
+ message.include?("timed out") ||
218
+ message.include?("already known") ||
219
+ message.include?("already imported") ||
220
+ message.include?("known transaction") ||
221
+ message.include?("transaction already exists")
222
+ end
223
+
224
+ def fetch_transaction_receipt(rpc_url, tx_hash)
121
225
  receipt_data = T.let(nil, T.untyped)
122
226
  MAX_RECEIPT_RETRY_ATTEMPTS.times do |attempt|
123
227
  receipt_data = Rpc.call(rpc_url, "eth_getTransactionReceipt", [tx_hash])
@@ -126,20 +230,31 @@ module Mpp
126
230
  sleep(RECEIPT_RETRY_DELAY_SECONDS) if attempt < MAX_RECEIPT_RETRY_ATTEMPTS - 1
127
231
  end
128
232
 
129
- raise Mpp::VerificationError, "Transaction receipt not found after retries" unless receipt_data
233
+ unless receipt_data
234
+ raise Mpp::TransactionPendingError,
235
+ "Transaction receipt pending; retry verification later"
236
+ end
237
+
238
+ receipt_data
239
+ end
240
+
241
+ def verify_transaction_receipt!(receipt_data, request, credential:)
130
242
  raise Mpp::VerificationError, "Transaction reverted" unless receipt_data["status"] == "0x1"
131
- unless verify_transfer_logs(
132
- receipt_data, request
133
- )
243
+ matched_logs = match_transfer_logs(receipt_data, request, expected_sender: receipt_data["from"])
244
+ unless matched_logs.any?
134
245
  raise Mpp::VerificationError,
135
246
  "Transaction must contain a Transfer log matching request parameters"
136
247
  end
137
-
138
- Mpp::Receipt.success(tx_hash)
248
+ assert_challenge_bound_memo(matched_logs, credential.challenge) unless request.method_details.memo
139
249
  end
140
250
 
141
251
  def verify_transfer_logs(receipt, request, expected_sender: nil)
252
+ match_transfer_logs(receipt, request, expected_sender: expected_sender).any?
253
+ end
254
+
255
+ def match_transfer_logs(receipt, request, expected_sender: nil, source: nil, validate_sender: nil)
142
256
  expected_memo = request.method_details.memo
257
+ matched_logs = []
143
258
 
144
259
  (receipt["logs"] || []).each do |log|
145
260
  next unless log["address"]&.downcase == request.currency.downcase
@@ -151,35 +266,75 @@ module Mpp
151
266
  to_address = "0x#{topics[2][-40..]}"
152
267
 
153
268
  next unless to_address.downcase == request.recipient.downcase
154
- next if expected_sender && from_address.downcase != expected_sender.downcase
155
-
156
- if expected_memo
157
- next unless topics[0] == TRANSFER_WITH_MEMO_TOPIC
158
- next if topics.length < 4
159
269
 
160
- data = log.fetch("data", "0x")
161
- next if data.length < 66
270
+ matched =
271
+ if expected_memo
272
+ next unless topics[0] == TRANSFER_WITH_MEMO_TOPIC
273
+ next if topics.length < 4
274
+
275
+ data = log.fetch("data", "0x")
276
+ next if data.length < 66
277
+
278
+ amount = data[2, 64].to_i(16)
279
+ memo = topics[3]
280
+ memo_clean = expected_memo.downcase
281
+ memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
282
+ next unless amount == Integer(request.amount) && memo.downcase == memo_clean
283
+
284
+ {kind: :memo, memo: memo}
285
+ else
286
+ case topics[0]
287
+ when TRANSFER_WITH_MEMO_TOPIC
288
+ next if topics.length < 4
289
+
290
+ data = log.fetch("data", "0x")
291
+ next if data.length < 66
292
+
293
+ amount = data[2, 64].to_i(16)
294
+ next unless amount == Integer(request.amount)
295
+
296
+ {kind: :memo, memo: topics[3]}
297
+ when TRANSFER_TOPIC
298
+ data = log.fetch("data", "0x")
299
+ next if data.length < 66
300
+
301
+ amount = data.delete_prefix("0x").to_i(16)
302
+ next unless amount == Integer(request.amount)
303
+
304
+ {kind: :transfer}
305
+ end
306
+ end
307
+
308
+ next unless matched
309
+
310
+ # On a sender mismatch, validate_sender may authorize the log.
311
+ if expected_sender && from_address.downcase != expected_sender.downcase
312
+ next unless validate_sender&.call(
313
+ expected_sender: expected_sender,
314
+ sender: from_address,
315
+ source: source
316
+ )
317
+ end
162
318
 
163
- amount = data[2, 64].to_i(16)
164
- memo = topics[3]
165
- memo_clean = expected_memo.downcase
166
- memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
167
- return true if amount == Integer(request.amount) && memo.downcase == memo_clean
168
- else
169
- next unless topics[0] == TRANSFER_TOPIC
319
+ matched_logs << matched
320
+ end
170
321
 
171
- data = log.fetch("data", "0x")
172
- next if data.length < 66
322
+ matched_logs.sort_by { |log| (log[:kind] == :memo) ? 0 : 1 }
323
+ end
173
324
 
174
- amount = data.delete_prefix("0x").to_i(16)
175
- return true if amount == Integer(request.amount)
176
- end
325
+ def assert_challenge_bound_memo(matched_logs, challenge)
326
+ bound = matched_logs.any? do |log|
327
+ log[:kind] == :memo &&
328
+ Attribution.verify_server(log[:memo], challenge.realm) &&
329
+ Attribution.verify_challenge_binding(log[:memo], challenge.id)
177
330
  end
178
331
 
179
- false
332
+ return if bound
333
+
334
+ raise Mpp::VerificationError, "Payment verification failed: memo is not bound to this challenge"
180
335
  end
181
336
 
182
- def validate_transaction_payload(signature, request)
337
+ def validate_transaction_payload(signature, request, challenge: nil)
183
338
  # Best-effort pre-broadcast check
184
339
  begin
185
340
  require "rlp"
@@ -203,6 +358,11 @@ module Mpp
203
358
 
204
359
  return unless decoded.is_a?(Array) && decoded.length >= 5
205
360
 
361
+ chain_id = int_value(decoded[0])
362
+ unless chain_id == Integer(request.method_details.chain_id)
363
+ raise Mpp::VerificationError, "Invalid transaction: chain ID does not match request"
364
+ end
365
+
206
366
  calls_data = decoded[4] || []
207
367
  raise Mpp::VerificationError, "Transaction contains no calls" if calls_data.empty?
208
368
 
@@ -217,13 +377,27 @@ module Mpp
217
377
  next unless "0x#{to_hex}".downcase == request.currency.downcase
218
378
 
219
379
  data_hex = call_data_bytes.is_a?(String) ? call_data_bytes.unpack1("H*") : call_data_bytes.to_s
220
- match_transfer_calldata(data_hex, request)
380
+ match_transfer_calldata(data_hex, request, challenge: challenge)
221
381
  end
222
382
 
223
383
  raise Mpp::VerificationError, "Invalid transaction: no matching payment call found" unless found
224
384
  end
225
385
 
226
- def match_transfer_calldata(call_data_hex, request)
386
+ def raw_transaction_hash(raw_tx)
387
+ Kernel.require "eth"
388
+
389
+ hex = raw_tx.delete_prefix("0x")
390
+ unless hex.match?(/\A[0-9a-fA-F]+\z/) && hex.length.even?
391
+ raise Mpp::VerificationError, "Invalid transaction signature"
392
+ end
393
+
394
+ "0x#{Eth::Util.keccak256([hex].pack("H*")).unpack1("H*")}"
395
+ rescue LoadError
396
+ raise Mpp::VerificationError,
397
+ "eth gem is required to compute transaction hash for transaction replay protection"
398
+ end
399
+
400
+ def match_transfer_calldata(call_data_hex, request, challenge: nil)
227
401
  return false if call_data_hex.length < 136
228
402
 
229
403
  selector = call_data_hex[0, 8].downcase
@@ -231,7 +405,7 @@ module Mpp
231
405
 
232
406
  if expected_memo
233
407
  return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
234
- elsif ![TRANSFER_SELECTOR, TRANSFER_WITH_MEMO_SELECTOR].include?(selector)
408
+ elsif selector != TRANSFER_WITH_MEMO_SELECTOR
235
409
  return false
236
410
  end
237
411
 
@@ -240,14 +414,19 @@ module Mpp
240
414
 
241
415
  return false unless decoded_to.downcase == request.recipient.downcase
242
416
  return false unless decoded_amount == Integer(request.amount)
417
+ return false if call_data_hex.length < 200
243
418
 
244
419
  if expected_memo
245
- return false if call_data_hex.length < 200
246
-
247
420
  decoded_memo = "0x#{call_data_hex[136, 64]}"
248
421
  memo_clean = expected_memo.downcase
249
422
  memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
250
423
  return false unless decoded_memo.downcase == memo_clean
424
+ else
425
+ return false unless challenge
426
+
427
+ decoded_memo = "0x#{call_data_hex[136, 64]}"
428
+ return false unless Attribution.verify_server(decoded_memo, challenge.realm)
429
+ return false unless Attribution.verify_challenge_binding(decoded_memo, challenge.id)
251
430
  end
252
431
 
253
432
  true
@@ -266,14 +445,20 @@ module Mpp
266
445
  address: source[:address],
267
446
  chain_id: resolved_chain_id,
268
447
  challenge_id: credential.challenge.id,
448
+ realm: credential.challenge.realm,
269
449
  signature: payload.signature
270
450
  )
271
451
  raise Mpp::VerificationError, "Proof signature does not match source" unless valid
272
452
 
453
+ if @store
454
+ store_key = "mpp:proof:#{credential.challenge.id}"
455
+ raise Mpp::VerificationError, "Proof credential has already been used" unless @store.put_if_absent(store_key, true)
456
+ end
457
+
273
458
  Mpp::Receipt.success(credential.challenge.id)
274
459
  end
275
460
 
276
- def cosign_as_fee_payer(raw_tx, fee_token, request: nil)
461
+ def cosign_as_fee_payer(raw_tx, fee_token, request: nil, challenge: nil)
277
462
  require "eth"
278
463
  require "rlp"
279
464
 
@@ -287,23 +472,25 @@ module Mpp
287
472
  raise Mpp::VerificationError, "Failed to deserialize client transaction: #{e.message}"
288
473
  end
289
474
 
290
- int = ->(b) {
291
- if b.is_a?(String) && !b.empty?
292
- b.unpack1("H*").to_i(16)
293
- elsif b.is_a?(Integer)
294
- b
295
- else
296
- 0
297
- end
298
- }
299
-
300
475
  # Validate fee-payer invariants
301
476
  fee_token_field = decoded[10]
302
477
  if fee_token_field.is_a?(String) && !fee_token_field.empty?
303
478
  raise Mpp::VerificationError, "Fee payer transaction must not include fee_token (server sets it)"
304
479
  end
305
480
 
306
- nonce_key = int.call(decoded[6])
481
+ # Reject authorizations we can't replay in tempo_simulateV1, which
482
+ # would let preflight validate a different tx than we broadcast.
483
+ tempo_authorization_list = decoded[12]
484
+ if tempo_authorization_list.is_a?(Array) && !tempo_authorization_list.empty?
485
+ raise Mpp::VerificationError,
486
+ "Fee payer envelope must not include tempo_authorization_list (cannot be safely pre-simulated)"
487
+ end
488
+ unless key_auth.nil?
489
+ raise Mpp::VerificationError,
490
+ "Fee payer envelope must not include key_authorization (cannot be safely pre-simulated)"
491
+ end
492
+
493
+ nonce_key = int_value(decoded[6])
307
494
  unless nonce_key == (1 << 256) - 1
308
495
  raise Mpp::VerificationError, "Fee payer envelope must use expiring nonce key (U256::MAX)"
309
496
  end
@@ -312,34 +499,70 @@ module Mpp
312
499
  if !valid_before_raw.is_a?(String) || valid_before_raw.empty?
313
500
  raise Mpp::VerificationError, "Fee payer envelope must include valid_before"
314
501
  end
315
- valid_before = int.call(valid_before_raw)
502
+ valid_before = int_value(valid_before_raw)
316
503
  if valid_before <= Time.now.to_i
317
504
  raise Mpp::VerificationError,
318
505
  "Fee payer envelope expired: valid_before (#{valid_before}) is not in the future"
319
506
  end
320
507
 
508
+ chain_id = int_value(decoded[0])
509
+ if request && chain_id != Integer(request.method_details.chain_id)
510
+ raise Mpp::VerificationError, "Invalid transaction: chain ID does not match request"
511
+ end
512
+ max_priority_fee_per_gas = int_value(decoded[1])
513
+ max_fee_per_gas = int_value(decoded[2])
514
+ gas_limit = int_value(decoded[3])
515
+ access_list = decoded[5] || Transaction::EMPTY_LIST
516
+ policy = FeePayerPolicy.for_chain_id(chain_id)
517
+
518
+ if gas_limit > policy.max_gas
519
+ raise Mpp::VerificationError, "Invalid transaction: gas limit exceeds sponsor policy"
520
+ end
521
+ if max_fee_per_gas > policy.max_fee_per_gas
522
+ raise Mpp::VerificationError, "Invalid transaction: max fee per gas exceeds sponsor policy"
523
+ end
524
+ if max_priority_fee_per_gas > max_fee_per_gas
525
+ raise Mpp::VerificationError,
526
+ "Invalid transaction: max priority fee per gas exceeds max fee per gas"
527
+ end
528
+ if max_priority_fee_per_gas > policy.max_priority_fee_per_gas
529
+ raise Mpp::VerificationError,
530
+ "Invalid transaction: max priority fee per gas exceeds sponsor policy"
531
+ end
532
+ if gas_limit * max_fee_per_gas > policy.max_total_fee
533
+ raise Mpp::VerificationError, "Invalid transaction: total fee budget exceeds sponsor policy"
534
+ end
535
+ if valid_before > Time.now.to_i + policy.max_validity_window_seconds
536
+ raise Mpp::VerificationError, "Invalid transaction: validity window exceeds sponsor policy"
537
+ end
538
+ unless access_list.empty?
539
+ raise Mpp::VerificationError, "Invalid transaction: access list is not allowed"
540
+ end
541
+
321
542
  # Build calls from decoded RLP
322
543
  calls_data = decoded[4] || []
323
544
  calls = calls_data.map do |c|
324
545
  Transaction::Call.new(
325
546
  to: "0x#{c[0].unpack1("H*")}",
326
- value: int.call(c[1]),
547
+ value: int_value(c[1]),
327
548
  data: "0x#{c[2].unpack1("H*")}"
328
549
  )
329
550
  end
330
551
 
552
+ validate_fee_payer_calls(calls, request, challenge: challenge) if request
553
+
331
554
  # Reconstruct transaction for sender signature recovery
332
555
  tx_for_recovery = Transaction::SignedTransaction.new(
333
- chain_id: int.call(decoded[0]),
334
- max_priority_fee_per_gas: int.call(decoded[1]),
335
- max_fee_per_gas: int.call(decoded[2]),
336
- gas_limit: int.call(decoded[3]),
556
+ chain_id: chain_id,
557
+ max_priority_fee_per_gas: max_priority_fee_per_gas,
558
+ max_fee_per_gas: max_fee_per_gas,
559
+ gas_limit: gas_limit,
337
560
  calls: calls,
338
561
  access_list: Transaction::EMPTY_LIST,
339
- nonce_key: int.call(decoded[6]),
340
- nonce: int.call(decoded[7]),
341
- valid_before: int.call(decoded[8]),
342
- valid_after: (decoded[9].is_a?(String) && !decoded[9].empty?) ? int.call(decoded[9]) : nil,
562
+ nonce_key: int_value(decoded[6]),
563
+ nonce: int_value(decoded[7]),
564
+ valid_before: int_value(decoded[8]),
565
+ valid_after: (decoded[9].is_a?(String) && !decoded[9].empty?) ? int_value(decoded[9]) : nil,
343
566
  fee_token: nil,
344
567
  sender_signature: sender_sig,
345
568
  fee_payer_signature: Transaction::EMPTY_SIGNATURE,
@@ -350,7 +573,7 @@ module Mpp
350
573
 
351
574
  # Verify sender signature
352
575
  sender_hash = tx_for_recovery.signature_hash
353
- recovered = Eth::Key.personal_recover(sender_hash, "0x#{sender_sig.unpack1("H*")}")
576
+ recovered = Eth::Signature.recover(sender_hash, "0x#{sender_sig.unpack1("H*")}")
354
577
  recovered_addr = Eth::Util.public_key_to_address(recovered).to_s
355
578
  envelope_addr = "0x#{sender_addr_bytes.unpack1("H*")}"
356
579
 
@@ -362,14 +585,149 @@ module Mpp
362
585
  resolved_fee_token = fee_token || request&.currency
363
586
  raise Mpp::VerificationError, "No fee token available" unless resolved_fee_token
364
587
 
588
+ allowed_fee_tokens = fee_payer_allowed_fee_tokens ||
589
+ [Defaults.default_currency_for_chain(chain_id).downcase]
590
+ unless allowed_fee_tokens.map(&:downcase).include?(resolved_fee_token.downcase)
591
+ raise Mpp::VerificationError,
592
+ "Fee token #{resolved_fee_token} is not allowed by fee payer policy"
593
+ end
594
+
365
595
  tx_to_sign = tx_for_recovery.with(fee_token: resolved_fee_token)
366
596
 
367
- # Fee payer signs over fields including sender_signature
597
+ # Fee payer signs the 0x78 payload, which identifies the recovered sender.
368
598
  fee_payer_hash = tx_to_sign.fee_payer_signature_hash
369
599
  fee_payer_sig = fee_payer.sign_hash(fee_payer_hash)
370
600
 
371
601
  signed = tx_to_sign.with(fee_payer_signature: fee_payer_sig)
372
- "0x#{signed.encoded_2718.unpack1("H*")}"
602
+ raw_tx = "0x#{signed.encoded_2718.unpack1("H*")}"
603
+
604
+ [raw_tx, build_simulate_payload(tx_to_sign, recovered_addr, fee_payer_sig)]
605
+ end
606
+
607
+ # Build a `tempo_simulateV1` payload for the co-signed `0x76` tx.
608
+ #
609
+ # Carries the recovered sender as `from` (the node needs it to model the
610
+ # sender) plus the sponsor fields (`feeToken`, `feePayerSignature`) so the
611
+ # node simulates the same tx we are about to broadcast.
612
+ def build_simulate_payload(tx, sender, fee_payer_sig)
613
+ tx_request = {
614
+ "from" => sender,
615
+ "type" => "0x76",
616
+ "chainId" => to_hex(tx.chain_id),
617
+ "nonce" => to_hex(tx.nonce),
618
+ "nonceKey" => to_hex(tx.nonce_key),
619
+ "gas" => to_hex(tx.gas_limit),
620
+ "maxFeePerGas" => to_hex(tx.max_fee_per_gas),
621
+ "maxPriorityFeePerGas" => to_hex(tx.max_priority_fee_per_gas),
622
+ "feeToken" => tx.fee_token,
623
+ "feePayerSignature" => signature_object(fee_payer_sig)
624
+ }
625
+
626
+ # The node forces `to = CREATE` when a request has no top-level `to`,
627
+ # appending a phantom CREATE call that trips Tempo's batch rules. Carry
628
+ # the final call via the top-level `to`/`value`/`input` shorthand (the
629
+ # builder appends it last, preserving order); keep earlier calls in `calls`.
630
+ raise Mpp::VerificationError, "Cannot simulate transaction with no calls" if tx.calls.empty?
631
+ *head_calls, last_call = tx.calls
632
+ unless head_calls.empty?
633
+ tx_request["calls"] = head_calls.map do |c|
634
+ {"to" => c.to, "value" => to_hex(c.value), "input" => c.data}
635
+ end
636
+ end
637
+ tx_request["to"] = last_call.to
638
+ tx_request["value"] = to_hex(last_call.value)
639
+ tx_request["input"] = last_call.data
640
+
641
+ tx_request["validBefore"] = to_hex(tx.valid_before) if tx.valid_before
642
+ tx_request["validAfter"] = to_hex(tx.valid_after) if tx.valid_after
643
+ access_list = encode_access_list(tx.access_list)
644
+ tx_request["accessList"] = access_list unless access_list.empty?
645
+
646
+ {
647
+ "blockStateCalls" => [{"calls" => [tx_request]}],
648
+ # We only care about execution outcome, not mempool admission.
649
+ "validation" => false,
650
+ "traceTransfers" => false,
651
+ "returnFullTransactions" => false
652
+ }
653
+ end
654
+
655
+ # Simulate the co-signed tx and raise if it would revert. Fails closed:
656
+ # an RPC error is treated as a failed check, not a pass.
657
+ def simulate_before_broadcast(simulate_payload, rpc_url)
658
+ response =
659
+ begin
660
+ Rpc.call(rpc_url, "tempo_simulateV1", [simulate_payload])
661
+ rescue => e
662
+ raise Mpp::VerificationError, "Pre-broadcast simulation failed: #{e.message}"
663
+ end
664
+
665
+ call = response&.dig("blocks", 0, "calls", 0)
666
+ raise Mpp::VerificationError, "Pre-broadcast simulation returned no call results" unless call
667
+
668
+ status = call["status"]
669
+ succeeded = status == "0x1" || status == 1 || status == true
670
+ return if succeeded
671
+
672
+ detail = call.dig("error", "message") || "no revert reason returned"
673
+ raise Mpp::VerificationError,
674
+ "Sponsored transaction would revert in pre-broadcast simulation: #{detail}"
675
+ end
676
+
677
+ # Encode an integer as a 0x-prefixed hex quantity.
678
+ def to_hex(value)
679
+ "0x#{Integer(value).to_s(16)}"
680
+ end
681
+
682
+ # Convert the RLP-decoded access list ([addr_bytes, [key_bytes, ...]])
683
+ # into the JSON shape the node expects.
684
+ def encode_access_list(access_list)
685
+ (access_list || []).map do |address, keys|
686
+ {
687
+ "address" => "0x#{address.unpack1("H*")}",
688
+ "storageKeys" => (keys || []).map { |k| "0x#{k.unpack1("H*")}" }
689
+ }
690
+ end
691
+ end
692
+
693
+ # Split a 65-byte (r||s||v) signature into the {r, s, yParity} object the
694
+ # node expects for `feePayerSignature`.
695
+ def signature_object(sig)
696
+ bytes = sig.b
697
+ v = bytes.getbyte(64)
698
+ parity = (v >= 27) ? v - 27 : v
699
+ {
700
+ "r" => "0x#{bytes[0, 32].unpack1("H*")}",
701
+ "s" => "0x#{bytes[32, 32].unpack1("H*")}",
702
+ "yParity" => to_hex(parity)
703
+ }
704
+ end
705
+
706
+ def validate_fee_payer_calls(calls, request, challenge: nil)
707
+ if calls.length != 1
708
+ raise Mpp::VerificationError, "Invalid transaction: contains unauthorized extra calls"
709
+ end
710
+
711
+ call = calls.first
712
+ if call.value && Integer(call.value) != 0
713
+ raise Mpp::VerificationError, "Invalid transaction: no matching payment call found"
714
+ end
715
+ unless call.to.downcase == request.currency.downcase
716
+ raise Mpp::VerificationError, "Invalid transaction: no matching payment call found"
717
+ end
718
+ unless match_transfer_calldata(call.data.delete_prefix("0x"), request, challenge: challenge)
719
+ raise Mpp::VerificationError, "Invalid transaction: no matching payment call found"
720
+ end
721
+ end
722
+
723
+ def int_value(value)
724
+ if value.is_a?(String) && !value.empty?
725
+ value.unpack1("H*").to_i(16)
726
+ elsif value.is_a?(Integer)
727
+ value
728
+ else
729
+ 0
730
+ end
373
731
  end
374
732
  end
375
733
  end