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.
- 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/attribution.rb +13 -0
- data/lib/mpp/methods/tempo/client_method.rb +28 -8
- data/lib/mpp/methods/tempo/fee_payer_envelope.rb +21 -6
- data/lib/mpp/methods/tempo/fee_payer_policy.rb +45 -0
- data/lib/mpp/methods/tempo/intents.rb +427 -69
- data/lib/mpp/methods/tempo/proof.rb +19 -15
- data/lib/mpp/methods/tempo/transaction.rb +68 -10
- 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 +5 -7
|
@@ -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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
172
|
-
|
|
322
|
+
matched_logs.sort_by { |log| (log[:kind] == :memo) ? 0 : 1 }
|
|
323
|
+
end
|
|
173
324
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
334
|
-
max_priority_fee_per_gas:
|
|
335
|
-
max_fee_per_gas:
|
|
336
|
-
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,
|
|
337
560
|
calls: calls,
|
|
338
561
|
access_list: Transaction::EMPTY_LIST,
|
|
339
|
-
nonce_key:
|
|
340
|
-
nonce:
|
|
341
|
-
valid_before:
|
|
342
|
-
valid_after: (decoded[9].is_a?(String) && !decoded[9].empty?) ?
|
|
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::
|
|
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
|
|
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
|