mpp-rb 0.1.6 → 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.
@@ -18,15 +18,15 @@ module Mpp
18
18
  # Handles client-side credential creation for Tempo payments.
19
19
  class TempoMethod
20
20
  attr_reader :name, :account, :fee_payer, :fee_payer_allowed_fee_tokens,
21
- :root_account, :rpc_url, :chain_id, :currency, :recipient, :decimals, :client_id,
22
- :expected_recipients, :relay, :on_payment_success
21
+ :root_account, :rpc_url, :chain_id, :currency, :currencies, :recipient, :decimals, :client_id,
22
+ :expected_recipients, :relay, :on_payment_success, :fee_token
23
23
  attr_accessor :intents
24
24
 
25
25
  def initialize(account: nil, fee_payer: nil, root_account: nil,
26
26
  rpc_url: Defaults::RPC_URL, chain_id: nil, currency: nil,
27
27
  recipient: nil, decimals: 6, client_id: nil,
28
28
  expected_recipients: nil, fee_payer_allowed_fee_tokens: nil,
29
- relay: nil, on_payment_success: nil, can_offer: nil)
29
+ relay: nil, on_payment_success: nil, can_offer: nil, currencies: nil, fee_token: nil)
30
30
  unless on_payment_success.nil? || on_payment_success.respond_to?(:call)
31
31
  raise ArgumentError, "on_payment_success must be callable"
32
32
  end
@@ -38,12 +38,15 @@ module Mpp
38
38
  @account = account
39
39
  @fee_payer = fee_payer
40
40
  @relay = relay
41
+ @fee_token = fee_token
41
42
  @fee_payer_allowed_fee_tokens =
42
43
  fee_payer_allowed_fee_tokens&.map { |token| token.to_s.downcase }
43
44
  @root_account = root_account
44
45
  @rpc_url = rpc_url
45
46
  @chain_id = chain_id
46
- @currency = currency
47
+ # Ordered currencies offered by a server; the first is the primary.
48
+ @currencies = (currencies || [currency].compact).dup.freeze
49
+ @currency = currency || @currencies.first
47
50
  @recipient = recipient
48
51
  @decimals = decimals
49
52
  @client_id = client_id
@@ -82,8 +85,7 @@ module Mpp
82
85
  nonce_key = nonce_key.start_with?("0x") ? nonce_key.to_i(16) : nonce_key.to_i
83
86
  end
84
87
 
85
- memo = method_details["memo"]
86
- memo ||= Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
88
+ memo = Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
87
89
 
88
90
  # Resolve RPC URL from challenge's chainId. Normalize the configured pin
89
91
  # once (it may be a String from ENV/config) so it compares equal to
@@ -264,26 +266,51 @@ module Mpp
264
266
  end
265
267
 
266
268
  # Factory function to create a configured TempoMethod.
267
- def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: nil, rpc_url: nil,
269
+ #
270
+ # A server offers one charge per accepted currency, in order. Without
271
+ # `currencies:` it offers OUSD then USDC.e on mainnet and OUSD then
272
+ # pathUSD on Moderato; other chains keep their single default.
273
+ # `currencies:` replaces the defaults. The deprecated `currency:` accepts
274
+ # exactly that token. `currency` keeps its previous default for clients.
275
+ #
276
+ # A local fee payer pays gas in `fee_token:` when set, else in the first
277
+ # allowed fee token it holds, independent of the charge currency.
278
+ def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: Defaults::CHAIN_ID, rpc_url: nil,
268
279
  root_account: nil, currency: nil, recipient: nil, decimals: 6, client_id: nil,
269
280
  expected_recipients: nil, fee_payer_allowed_fee_tokens: nil, relay: nil,
270
- on_payment_success: nil, can_offer: nil)
281
+ on_payment_success: nil, can_offer: nil, currencies: nil, fee_token: nil)
271
282
  rpc_url ||= chain_id ? Defaults.rpc_url_for_chain(chain_id) : Defaults::RPC_URL
272
- currency ||= Defaults.default_currency_for_chain(chain_id)
273
283
 
274
284
  if fee_payer == true
275
285
  raise ArgumentError, "fee_payer: true requires account:" unless account
276
286
 
277
287
  fee_payer = account
278
288
  end
289
+ fee_payer = FeePayerClient.resolve_optional(fee_payer)
290
+ if fee_token && (fee_payer.nil? || FeePayerClient.hosted_config?(fee_payer))
291
+ raise ArgumentError, "fee_token can only be configured for a local fee payer"
292
+ end
293
+
294
+ if fee_token && !Defaults::ADDRESS_PATTERN.match?(fee_token)
295
+ raise ArgumentError, "Invalid Tempo fee token address: #{fee_token.inspect}"
296
+ end
297
+ if fee_payer_allowed_fee_tokens&.empty?
298
+ raise ArgumentError, "fee_payer_allowed_fee_tokens must contain at least one token"
299
+ end
300
+
301
+ legacy_currency = Defaults.default_currency_for_chain(chain_id)
302
+ primary_currency = currency || currencies&.first || legacy_currency
303
+ # `currency:` is deprecated in favor of `currencies: [currency]`.
304
+ currencies = Defaults.accepted_currencies(chain_id: chain_id, currency: currency, currencies: currencies)
279
305
 
280
306
  method = TempoMethod.new(
281
307
  account: account,
282
- fee_payer: FeePayerClient.resolve_optional(fee_payer),
308
+ fee_payer: fee_payer,
283
309
  rpc_url: rpc_url,
284
310
  chain_id: chain_id,
285
311
  root_account: root_account,
286
- currency: currency,
312
+ currency: primary_currency,
313
+ currencies: currencies,
287
314
  recipient: recipient,
288
315
  decimals: decimals,
289
316
  client_id: client_id,
@@ -291,7 +318,8 @@ module Mpp
291
318
  fee_payer_allowed_fee_tokens: fee_payer_allowed_fee_tokens,
292
319
  relay: Relay.resolve_optional(relay),
293
320
  on_payment_success: on_payment_success,
294
- can_offer: can_offer
321
+ can_offer: can_offer,
322
+ fee_token: fee_token
295
323
  )
296
324
 
297
325
  intents.each_value do |intent|
@@ -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)
@@ -41,11 +41,75 @@ module Mpp
41
41
  @_method&.fee_payer_allowed_fee_tokens
42
42
  end
43
43
 
44
+ def configured_fee_token
45
+ @_method&.fee_token
46
+ end
47
+
44
48
  def relay
45
49
  @_method&.relay
46
50
  end
47
51
 
52
+ # @deprecated Use #validate followed by #broadcast.
48
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)
49
113
  req = Schemas::ChargeRequest.from_hash(request)
50
114
 
51
115
  # Check challenge expiry
@@ -55,67 +119,61 @@ module Mpp
55
119
  raise Mpp::VerificationError, "Request has expired" if expires < Time.now.utc
56
120
  end
57
121
 
58
- return relay.verify(credential, request) if relay
122
+ req
123
+ end
59
124
 
125
+ def validate_credential(credential, req)
60
126
  payload_data = credential.payload
61
127
  unless payload_data.is_a?(Hash) && payload_data.key?("type")
62
- raise Mpp::VerificationError,
63
- "Invalid credential payload"
128
+ raise Mpp::VerificationError, "Invalid credential payload"
64
129
  end
65
130
 
66
131
  case payload_data["type"]
67
132
  when "hash"
68
133
  payload = Schemas::HashCredentialPayload.new(type: "hash", hash: payload_data["hash"])
69
- verify_hash(payload, req, credential: credential)
134
+ validate_hash(payload, req, credential: credential)
70
135
  when "transaction"
71
136
  payload = Schemas::TransactionCredentialPayload.new(
72
137
  type: "transaction", signature: payload_data["signature"]
73
138
  )
74
- verify_transaction(payload, req, credential: credential)
139
+ validate_transaction(payload, req, credential: credential)
75
140
  when "proof"
76
141
  payload = Schemas::ProofCredentialPayload.new(
77
142
  type: "proof", signature: payload_data["signature"]
78
143
  )
79
- verify_proof(payload, req, credential: credential)
144
+ validate_proof(payload, req, credential: credential)
80
145
  else
81
146
  raise Mpp::VerificationError, "Invalid credential type: #{payload_data["type"]}"
82
147
  end
83
148
  end
84
149
 
85
- private
86
-
87
150
  def get_rpc_url
88
151
  raise Mpp::VerificationError, "No rpc_url configured on ChargeIntent" unless @rpc_url
89
152
 
90
153
  @rpc_url
91
154
  end
92
155
 
93
- # Parse a hash credential source: nil if absent, the address for a
156
+ # Parse a credential source: nil if absent, the address for a
94
157
  # did:pkh:eip155 DID matching expected_chain_id, else raises.
95
- def parse_hash_credential_source(source, expected_chain_id)
158
+ def parse_credential_source(source, expected_chain_id)
96
159
  return nil unless source
97
160
 
98
161
  expected_chain_id = begin
99
162
  Integer(expected_chain_id)
100
163
  rescue ArgumentError, TypeError
101
- raise Mpp::VerificationError, "Hash credential source is invalid"
164
+ raise Mpp::VerificationError, "Credential source is invalid"
102
165
  end
103
166
 
104
167
  parsed = Proof.parse_source(source)
105
168
  unless parsed && parsed[:chain_id] == expected_chain_id
106
- raise Mpp::VerificationError, "Hash credential source is invalid"
169
+ raise Mpp::VerificationError, "Credential source is invalid"
107
170
  end
108
171
 
109
172
  parsed[:address]
110
173
  end
111
174
 
112
- def verify_hash(payload, request, credential:)
113
- # Validate the source before reserving the hash.
114
- source_address = parse_hash_credential_source(credential.source, request.method_details.chain_id)
115
-
116
- store_key = "mpp:charge:#{payload.hash.downcase}"
117
- raise Mpp::VerificationError, "Transaction hash already used" unless @store.put_if_absent(store_key,
118
- payload.hash)
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,7 +207,7 @@ 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
@@ -244,12 +300,13 @@ module Mpp
244
300
 
245
301
  def verify_transaction_receipt!(receipt_data, request, credential:)
246
302
  raise Mpp::VerificationError, "Transaction reverted" unless receipt_data["status"] == "0x1"
247
- 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"])
248
305
  unless matched_logs.any?
249
306
  raise Mpp::VerificationError,
250
307
  "Transaction must contain a Transfer log matching request parameters"
251
308
  end
252
- assert_challenge_bound_memo(matched_logs, credential.challenge) unless request.method_details.memo
309
+ assert_challenge_bound_memo(matched_logs, credential.challenge)
253
310
  end
254
311
 
255
312
  def verify_transfer_logs(receipt, request, expected_sender: nil)
@@ -257,7 +314,6 @@ module Mpp
257
314
  end
258
315
 
259
316
  def match_transfer_logs(receipt, request, expected_sender: nil, source: nil, validate_sender: nil)
260
- expected_memo = request.method_details.memo
261
317
  matched_logs = []
262
318
 
263
319
  (receipt["logs"] || []).each do |log|
@@ -272,41 +328,25 @@ module Mpp
272
328
  next unless to_address.downcase == request.recipient.downcase
273
329
 
274
330
  matched =
275
- if expected_memo
276
- next unless topics[0] == TRANSFER_WITH_MEMO_TOPIC
331
+ case topics[0]
332
+ when TRANSFER_WITH_MEMO_TOPIC
277
333
  next if topics.length < 4
278
334
 
279
335
  data = log.fetch("data", "0x")
280
336
  next if data.length < 66
281
337
 
282
338
  amount = data[2, 64].to_i(16)
283
- memo = topics[3]
284
- memo_clean = expected_memo.downcase
285
- memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
286
- next unless amount == Integer(request.amount) && memo.downcase == memo_clean
287
-
288
- {kind: :memo, memo: memo}
289
- else
290
- case topics[0]
291
- when TRANSFER_WITH_MEMO_TOPIC
292
- next if topics.length < 4
339
+ next unless amount == Integer(request.amount)
293
340
 
294
- data = log.fetch("data", "0x")
295
- next if data.length < 66
296
-
297
- amount = data[2, 64].to_i(16)
298
- next unless amount == Integer(request.amount)
299
-
300
- {kind: :memo, memo: topics[3]}
301
- when TRANSFER_TOPIC
302
- data = log.fetch("data", "0x")
303
- 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
304
345
 
305
- amount = data.delete_prefix("0x").to_i(16)
306
- next unless amount == Integer(request.amount)
346
+ amount = data.delete_prefix("0x").to_i(16)
347
+ next unless amount == Integer(request.amount)
307
348
 
308
- {kind: :transfer}
309
- end
349
+ {kind: :transfer}
310
350
  end
311
351
 
312
352
  next unless matched
@@ -338,6 +378,22 @@ module Mpp
338
378
  raise Mpp::VerificationError, "Payment verification failed: memo is not bound to this challenge"
339
379
  end
340
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
+
341
397
  def validate_transaction_payload(signature, request, challenge: nil)
342
398
  # Best-effort pre-broadcast check
343
399
  begin
@@ -420,13 +476,8 @@ module Mpp
420
476
  return false if call_data_hex.length < 136
421
477
 
422
478
  selector = call_data_hex[0, 8].downcase
423
- expected_memo = request.method_details.memo
424
479
 
425
- if expected_memo
426
- return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
427
- elsif selector != TRANSFER_WITH_MEMO_SELECTOR
428
- return false
429
- end
480
+ return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
430
481
 
431
482
  decoded_to = "0x#{call_data_hex[32, 40]}"
432
483
  decoded_amount = call_data_hex[72, 64].to_i(16)
@@ -435,23 +486,16 @@ module Mpp
435
486
  return false unless decoded_amount == Integer(request.amount)
436
487
  return false if call_data_hex.length < 200
437
488
 
438
- if expected_memo
439
- decoded_memo = "0x#{call_data_hex[136, 64]}"
440
- memo_clean = expected_memo.downcase
441
- memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
442
- return false unless decoded_memo.downcase == memo_clean
443
- else
444
- return false unless challenge
489
+ return false unless challenge
445
490
 
446
- decoded_memo = "0x#{call_data_hex[136, 64]}"
447
- return false unless Attribution.verify_server(decoded_memo, challenge.realm)
448
- return false unless Attribution.verify_challenge_binding(decoded_memo, challenge.id)
449
- 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)
450
494
 
451
495
  true
452
496
  end
453
497
 
454
- def verify_proof(payload, request, credential:)
498
+ def validate_proof(payload, request, credential:)
455
499
  raise Mpp::VerificationError, "Proof credentials are only valid for zero-amount challenges" unless Integer(request.amount).zero?
456
500
  raise Mpp::VerificationError, "Proof credential must include a source" unless credential.source
457
501
 
@@ -469,9 +513,6 @@ module Mpp
469
513
  )
470
514
  raise Mpp::VerificationError, "Proof signature does not match source" unless valid
471
515
 
472
- store_key = "mpp:proof:#{credential.challenge.id}"
473
- raise Mpp::VerificationError, "Proof credential has already been used" unless @store.put_if_absent(store_key, true)
474
-
475
516
  Mpp::Receipt.success(credential.challenge.id)
476
517
  end
477
518
 
@@ -479,12 +520,27 @@ module Mpp
479
520
  !FeePayerClient.hosted_config?(payer)
480
521
  end
481
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.
482
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)
483
541
  require "eth"
484
542
  require "rlp"
485
543
 
486
- raise Mpp::VerificationError, "No fee payer account configured" unless fee_payer
487
-
488
544
  # Decode the 0x78 fee payer envelope
489
545
  begin
490
546
  all_bytes = [raw_tx.delete_prefix("0x")].pack("H*")
@@ -603,26 +659,17 @@ module Mpp
603
659
  end
604
660
 
605
661
  # Build the final transaction with fee_token set
606
- 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
607
665
  raise Mpp::VerificationError, "No fee token available" unless resolved_fee_token
608
666
 
609
- allowed_fee_tokens = fee_payer_allowed_fee_tokens ||
610
- [Defaults.default_currency_for_chain(chain_id).downcase]
611
667
  unless allowed_fee_tokens.map(&:downcase).include?(resolved_fee_token.downcase)
612
668
  raise Mpp::VerificationError,
613
669
  "Fee token #{resolved_fee_token} is not allowed by fee payer policy"
614
670
  end
615
671
 
616
- tx_to_sign = tx_for_recovery.with(fee_token: resolved_fee_token)
617
-
618
- # Fee payer signs the 0x78 payload, which identifies the recovered sender.
619
- fee_payer_hash = tx_to_sign.fee_payer_signature_hash
620
- fee_payer_sig = fee_payer.sign_hash(fee_payer_hash)
621
-
622
- signed = tx_to_sign.with(fee_payer_signature: fee_payer_sig)
623
- raw_tx = "0x#{signed.encoded_2718.unpack1("H*")}"
624
-
625
- [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]
626
673
  end
627
674
 
628
675
  # Build a `tempo_simulateV1` payload for the co-signed `0x76` tx.
@@ -673,6 +720,17 @@ module Mpp
673
720
  }
674
721
  end
675
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
+
676
734
  # Simulate the co-signed tx and raise if it would revert. Fails closed:
677
735
  # an RPC error is treated as a failed check, not a pass.
678
736
  def simulate_before_broadcast(simulate_payload, rpc_url)
@@ -96,8 +96,40 @@ module Mpp
96
96
  end
97
97
  private_class_method :merge_api_key_headers
98
98
 
99
+ def self.to_relay_input(credential)
100
+ echo = credential.challenge
101
+ challenge = {
102
+ "id" => echo.id,
103
+ "realm" => echo.realm,
104
+ "method" => echo.method,
105
+ "intent" => echo.intent,
106
+ "request" => decode_request(echo.request)
107
+ }
108
+ challenge["expires"] = echo.expires if echo.expires
109
+ challenge["digest"] = echo.digest if echo.digest
110
+ challenge["opaque"] = echo.opaque if echo.opaque
111
+
112
+ input = {
113
+ "challenge" => challenge,
114
+ "payload" => credential.payload
115
+ }
116
+ input["source"] = credential.source if credential.source
117
+ input
118
+ end
119
+
120
+ def self.decode_request(request)
121
+ return request if request.is_a?(Hash)
122
+ return request unless request.is_a?(String) && !request.empty?
123
+
124
+ Mpp::Parsing.b64_decode(request)
125
+ rescue Mpp::ParseError
126
+ request
127
+ end
128
+ private_class_method :decode_request
129
+
130
+ # @deprecated Use #validate followed by #broadcast.
99
131
  def verify(credential, _request = nil)
100
- input = to_relay_input(credential)
132
+ input = self.class.to_relay_input(credential)
101
133
  validate(input)
102
134
  broadcast(input)
103
135
  end
@@ -155,36 +187,6 @@ module Mpp
155
187
  raise Mpp::VerificationFailedError.new(reason: "relay request failed: #{e.message}")
156
188
  end
157
189
 
158
- def to_relay_input(credential)
159
- echo = credential.challenge
160
- challenge = {
161
- "id" => echo.id,
162
- "realm" => echo.realm,
163
- "method" => echo.method,
164
- "intent" => echo.intent,
165
- "request" => decode_request(echo.request)
166
- }
167
- challenge["expires"] = echo.expires if echo.expires
168
- challenge["digest"] = echo.digest if echo.digest
169
- challenge["opaque"] = echo.opaque if echo.opaque
170
-
171
- input = {
172
- "challenge" => challenge,
173
- "payload" => credential.payload
174
- }
175
- input["source"] = credential.source if credential.source
176
- input
177
- end
178
-
179
- def decode_request(request)
180
- return request if request.is_a?(Hash)
181
- return request unless request.is_a?(String) && !request.empty?
182
-
183
- Mpp::Parsing.b64_decode(request)
184
- rescue Mpp::ParseError
185
- request
186
- end
187
-
188
190
  def idempotency_key(input)
189
191
  payload = input["payload"]
190
192
  if payload.is_a?(Hash) && payload["type"] == "transaction" && payload["signature"].is_a?(String)