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.
- checksums.yaml +4 -4
- data/README.md +37 -5
- data/lib/mpp/body_digest.rb +1 -1
- data/lib/mpp/challenge.rb +71 -6
- data/lib/mpp/client/transport.rb +153 -18
- data/lib/mpp/errors.rb +3 -0
- data/lib/mpp/events.rb +124 -0
- data/lib/mpp/extensions/mcp/types.rb +2 -1
- data/lib/mpp/methods/stripe/charge_intent.rb +41 -16
- data/lib/mpp/methods/stripe/client_method.rb +5 -1
- data/lib/mpp/methods/stripe/stripe_method.rb +15 -4
- data/lib/mpp/methods/tempo/client_method.rb +28 -8
- data/lib/mpp/methods/tempo/fee_payer_policy.rb +45 -0
- data/lib/mpp/methods/tempo/intents.rb +394 -64
- data/lib/mpp/methods/tempo/proof.rb +19 -15
- data/lib/mpp/methods/tempo/transaction.rb +4 -3
- data/lib/mpp/methods/tempo.rb +1 -0
- data/lib/mpp/parsing.rb +14 -2
- data/lib/mpp/receipt.rb +3 -2
- data/lib/mpp/server/decorator.rb +1 -0
- data/lib/mpp/server/middleware.rb +101 -2
- data/lib/mpp/server/mpp_handler.rb +40 -8
- data/lib/mpp/server/verify.rb +173 -27
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp.rb +5 -3
- metadata +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
next
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
362
|
-
max_priority_fee_per_gas:
|
|
363
|
-
max_fee_per_gas:
|
|
364
|
-
gas_limit:
|
|
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:
|
|
367
|
-
nonce_key:
|
|
368
|
-
nonce:
|
|
369
|
-
valid_before:
|
|
370
|
-
valid_after: (decoded[9].is_a?(String) && !decoded[9].empty?) ?
|
|
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
|