mpp-rb 0.1.2 → 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
 
@@ -75,7 +82,29 @@ module Mpp
75
82
  @rpc_url
76
83
  end
77
84
 
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
+
78
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,7 +116,13 @@ 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
- matched_logs = match_transfer_logs(result, request, expected_sender: result["from"])
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)
91
126
  unless matched_logs.any?
92
127
  raise Mpp::VerificationError,
93
128
  "Transaction must contain a Transfer log matching request parameters"
@@ -98,13 +133,16 @@ module Mpp
98
133
  end
99
134
 
100
135
  def verify_transaction(payload, request, credential:)
101
- validate_transaction_payload(payload.signature, request)
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
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
120
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,7 +230,15 @@ 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
243
  matched_logs = match_transfer_logs(receipt_data, request, expected_sender: receipt_data["from"])
132
244
  unless matched_logs.any?
@@ -134,15 +246,13 @@ module Mpp
134
246
  "Transaction must contain a Transfer log matching request parameters"
135
247
  end
136
248
  assert_challenge_bound_memo(matched_logs, credential.challenge) unless request.method_details.memo
137
-
138
- Mpp::Receipt.success(tx_hash)
139
249
  end
140
250
 
141
251
  def verify_transfer_logs(receipt, request, expected_sender: nil)
142
252
  match_transfer_logs(receipt, request, expected_sender: expected_sender).any?
143
253
  end
144
254
 
145
- def match_transfer_logs(receipt, request, expected_sender: nil)
255
+ def match_transfer_logs(receipt, request, expected_sender: nil, source: nil, validate_sender: nil)
146
256
  expected_memo = request.method_details.memo
147
257
  matched_logs = []
148
258
 
@@ -156,40 +266,57 @@ module Mpp
156
266
  to_address = "0x#{topics[2][-40..]}"
157
267
 
158
268
  next unless to_address.downcase == request.recipient.downcase
159
- next if expected_sender && from_address.downcase != expected_sender.downcase
160
-
161
- if expected_memo
162
- next unless topics[0] == TRANSFER_WITH_MEMO_TOPIC
163
- next if topics.length < 4
164
-
165
- data = log.fetch("data", "0x")
166
- next if data.length < 66
167
269
 
168
- amount = data[2, 64].to_i(16)
169
- memo = topics[3]
170
- memo_clean = expected_memo.downcase
171
- memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
172
- if amount == Integer(request.amount) && memo.downcase == memo_clean
173
- matched_logs << {kind: :memo, memo: memo}
174
- end
175
- else
176
- case topics[0]
177
- when TRANSFER_WITH_MEMO_TOPIC
270
+ matched =
271
+ if expected_memo
272
+ next unless topics[0] == TRANSFER_WITH_MEMO_TOPIC
178
273
  next if topics.length < 4
179
274
 
180
275
  data = log.fetch("data", "0x")
181
276
  next if data.length < 66
182
277
 
183
278
  amount = data[2, 64].to_i(16)
184
- matched_logs << {kind: :memo, memo: topics[3]} if amount == Integer(request.amount)
185
- when TRANSFER_TOPIC
186
- data = log.fetch("data", "0x")
187
- next if data.length < 66
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
188
283
 
189
- amount = data.delete_prefix("0x").to_i(16)
190
- matched_logs << {kind: :transfer} if amount == Integer(request.amount)
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
191
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
+ )
192
317
  end
318
+
319
+ matched_logs << matched
193
320
  end
194
321
 
195
322
  matched_logs.sort_by { |log| (log[:kind] == :memo) ? 0 : 1 }
@@ -207,7 +334,7 @@ module Mpp
207
334
  raise Mpp::VerificationError, "Payment verification failed: memo is not bound to this challenge"
208
335
  end
209
336
 
210
- def validate_transaction_payload(signature, request)
337
+ def validate_transaction_payload(signature, request, challenge: nil)
211
338
  # Best-effort pre-broadcast check
212
339
  begin
213
340
  require "rlp"
@@ -231,6 +358,11 @@ module Mpp
231
358
 
232
359
  return unless decoded.is_a?(Array) && decoded.length >= 5
233
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
+
234
366
  calls_data = decoded[4] || []
235
367
  raise Mpp::VerificationError, "Transaction contains no calls" if calls_data.empty?
236
368
 
@@ -245,13 +377,27 @@ module Mpp
245
377
  next unless "0x#{to_hex}".downcase == request.currency.downcase
246
378
 
247
379
  data_hex = call_data_bytes.is_a?(String) ? call_data_bytes.unpack1("H*") : call_data_bytes.to_s
248
- match_transfer_calldata(data_hex, request)
380
+ match_transfer_calldata(data_hex, request, challenge: challenge)
249
381
  end
250
382
 
251
383
  raise Mpp::VerificationError, "Invalid transaction: no matching payment call found" unless found
252
384
  end
253
385
 
254
- 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)
255
401
  return false if call_data_hex.length < 136
256
402
 
257
403
  selector = call_data_hex[0, 8].downcase
@@ -259,7 +405,7 @@ module Mpp
259
405
 
260
406
  if expected_memo
261
407
  return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
262
- elsif ![TRANSFER_SELECTOR, TRANSFER_WITH_MEMO_SELECTOR].include?(selector)
408
+ elsif selector != TRANSFER_WITH_MEMO_SELECTOR
263
409
  return false
264
410
  end
265
411
 
@@ -268,14 +414,19 @@ module Mpp
268
414
 
269
415
  return false unless decoded_to.downcase == request.recipient.downcase
270
416
  return false unless decoded_amount == Integer(request.amount)
417
+ return false if call_data_hex.length < 200
271
418
 
272
419
  if expected_memo
273
- return false if call_data_hex.length < 200
274
-
275
420
  decoded_memo = "0x#{call_data_hex[136, 64]}"
276
421
  memo_clean = expected_memo.downcase
277
422
  memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
278
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)
279
430
  end
280
431
 
281
432
  true
@@ -294,14 +445,20 @@ module Mpp
294
445
  address: source[:address],
295
446
  chain_id: resolved_chain_id,
296
447
  challenge_id: credential.challenge.id,
448
+ realm: credential.challenge.realm,
297
449
  signature: payload.signature
298
450
  )
299
451
  raise Mpp::VerificationError, "Proof signature does not match source" unless valid
300
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
+
301
458
  Mpp::Receipt.success(credential.challenge.id)
302
459
  end
303
460
 
304
- 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)
305
462
  require "eth"
306
463
  require "rlp"
307
464
 
@@ -315,23 +472,25 @@ module Mpp
315
472
  raise Mpp::VerificationError, "Failed to deserialize client transaction: #{e.message}"
316
473
  end
317
474
 
318
- int = ->(b) {
319
- if b.is_a?(String) && !b.empty?
320
- b.unpack1("H*").to_i(16)
321
- elsif b.is_a?(Integer)
322
- b
323
- else
324
- 0
325
- end
326
- }
327
-
328
475
  # Validate fee-payer invariants
329
476
  fee_token_field = decoded[10]
330
477
  if fee_token_field.is_a?(String) && !fee_token_field.empty?
331
478
  raise Mpp::VerificationError, "Fee payer transaction must not include fee_token (server sets it)"
332
479
  end
333
480
 
334
- 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])
335
494
  unless nonce_key == (1 << 256) - 1
336
495
  raise Mpp::VerificationError, "Fee payer envelope must use expiring nonce key (U256::MAX)"
337
496
  end
@@ -340,34 +499,70 @@ module Mpp
340
499
  if !valid_before_raw.is_a?(String) || valid_before_raw.empty?
341
500
  raise Mpp::VerificationError, "Fee payer envelope must include valid_before"
342
501
  end
343
- valid_before = int.call(valid_before_raw)
502
+ valid_before = int_value(valid_before_raw)
344
503
  if valid_before <= Time.now.to_i
345
504
  raise Mpp::VerificationError,
346
505
  "Fee payer envelope expired: valid_before (#{valid_before}) is not in the future"
347
506
  end
348
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
+
349
542
  # Build calls from decoded RLP
350
543
  calls_data = decoded[4] || []
351
544
  calls = calls_data.map do |c|
352
545
  Transaction::Call.new(
353
546
  to: "0x#{c[0].unpack1("H*")}",
354
- value: int.call(c[1]),
547
+ value: int_value(c[1]),
355
548
  data: "0x#{c[2].unpack1("H*")}"
356
549
  )
357
550
  end
358
551
 
552
+ validate_fee_payer_calls(calls, request, challenge: challenge) if request
553
+
359
554
  # Reconstruct transaction for sender signature recovery
360
555
  tx_for_recovery = Transaction::SignedTransaction.new(
361
- chain_id: int.call(decoded[0]),
362
- max_priority_fee_per_gas: int.call(decoded[1]),
363
- max_fee_per_gas: int.call(decoded[2]),
364
- 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,
365
560
  calls: calls,
366
- access_list: decoded[5] || Transaction::EMPTY_LIST,
367
- nonce_key: int.call(decoded[6]),
368
- nonce: int.call(decoded[7]),
369
- valid_before: int.call(decoded[8]),
370
- valid_after: (decoded[9].is_a?(String) && !decoded[9].empty?) ? int.call(decoded[9]) : nil,
561
+ access_list: Transaction::EMPTY_LIST,
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,
371
566
  fee_token: nil,
372
567
  sender_signature: sender_sig,
373
568
  fee_payer_signature: Transaction::EMPTY_SIGNATURE,
@@ -390,6 +585,13 @@ module Mpp
390
585
  resolved_fee_token = fee_token || request&.currency
391
586
  raise Mpp::VerificationError, "No fee token available" unless resolved_fee_token
392
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
+
393
595
  tx_to_sign = tx_for_recovery.with(fee_token: resolved_fee_token)
394
596
 
395
597
  # Fee payer signs the 0x78 payload, which identifies the recovered sender.
@@ -397,7 +599,135 @@ module Mpp
397
599
  fee_payer_sig = fee_payer.sign_hash(fee_payer_hash)
398
600
 
399
601
  signed = tx_to_sign.with(fee_payer_signature: fee_payer_sig)
400
- "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
401
731
  end
402
732
  end
403
733
  end