mpp-rb 0.1.5 → 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.
- checksums.yaml +4 -4
- data/README.md +150 -0
- data/lib/mpp/challenge.rb +31 -8
- data/lib/mpp/challenge_echo.rb +14 -3
- data/lib/mpp/challenge_id.rb +30 -6
- data/lib/mpp/client/transport.rb +1 -1
- data/lib/mpp/extensions/mcp/verify.rb +1 -1
- data/lib/mpp/methods/evm/charge_intent.rb +38 -17
- data/lib/mpp/methods/evm/evm_method.rb +18 -2
- data/lib/mpp/methods/evm.rb +5 -2
- data/lib/mpp/methods/stripe/analytics_metadata.rb +24 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +34 -11
- data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +92 -0
- data/lib/mpp/methods/stripe/defaults.rb +1 -0
- data/lib/mpp/methods/stripe/machine_payments.rb +128 -0
- data/lib/mpp/methods/stripe/payment_intent_method.rb +103 -0
- data/lib/mpp/methods/stripe/payment_intent_options.rb +81 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +30 -3
- data/lib/mpp/methods/stripe.rb +26 -0
- data/lib/mpp/methods/tempo/client_method.rb +58 -12
- data/lib/mpp/methods/tempo/defaults.rb +51 -0
- data/lib/mpp/methods/tempo/intents.rb +195 -125
- data/lib/mpp/methods/tempo/relay.rb +33 -31
- data/lib/mpp/methods/tempo/schemas.rb +3 -4
- data/lib/mpp/parsing.rb +29 -4
- data/lib/mpp/server/compose.rb +32 -15
- data/lib/mpp/server/decorator.rb +3 -1
- data/lib/mpp/server/intent.rb +6 -1
- data/lib/mpp/server/intent_lifecycle.rb +41 -0
- data/lib/mpp/server/method.rb +41 -0
- data/lib/mpp/server/middleware.rb +16 -2
- data/lib/mpp/server/mpp_handler.rb +87 -21
- data/lib/mpp/server/verify.rb +25 -15
- data/lib/mpp/server.rb +1 -0
- data/lib/mpp/validation.rb +27 -0
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp.rb +14 -3
- metadata +22 -1
|
@@ -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)
|
|
@@ -22,7 +22,9 @@ module Mpp
|
|
|
22
22
|
attr_reader :name
|
|
23
23
|
attr_accessor :rpc_url
|
|
24
24
|
|
|
25
|
-
def initialize(chain_id: nil, rpc_url: nil, timeout: 30, store:
|
|
25
|
+
def initialize(chain_id: nil, rpc_url: nil, timeout: 30, store: Mpp::MemoryStore.new, validate_sender: nil)
|
|
26
|
+
raise ArgumentError, "store is required" if store.nil?
|
|
27
|
+
|
|
26
28
|
@name = "charge"
|
|
27
29
|
@rpc_url = rpc_url || (chain_id ? Defaults.rpc_url_for_chain(chain_id) : nil)
|
|
28
30
|
@_method = nil
|
|
@@ -39,11 +41,75 @@ module Mpp
|
|
|
39
41
|
@_method&.fee_payer_allowed_fee_tokens
|
|
40
42
|
end
|
|
41
43
|
|
|
44
|
+
def configured_fee_token
|
|
45
|
+
@_method&.fee_token
|
|
46
|
+
end
|
|
47
|
+
|
|
42
48
|
def relay
|
|
43
49
|
@_method&.relay
|
|
44
50
|
end
|
|
45
51
|
|
|
52
|
+
# @deprecated Use #validate followed by #broadcast.
|
|
46
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)
|
|
47
113
|
req = Schemas::ChargeRequest.from_hash(request)
|
|
48
114
|
|
|
49
115
|
# Check challenge expiry
|
|
@@ -53,69 +119,61 @@ module Mpp
|
|
|
53
119
|
raise Mpp::VerificationError, "Request has expired" if expires < Time.now.utc
|
|
54
120
|
end
|
|
55
121
|
|
|
56
|
-
|
|
122
|
+
req
|
|
123
|
+
end
|
|
57
124
|
|
|
125
|
+
def validate_credential(credential, req)
|
|
58
126
|
payload_data = credential.payload
|
|
59
127
|
unless payload_data.is_a?(Hash) && payload_data.key?("type")
|
|
60
|
-
raise Mpp::VerificationError,
|
|
61
|
-
"Invalid credential payload"
|
|
128
|
+
raise Mpp::VerificationError, "Invalid credential payload"
|
|
62
129
|
end
|
|
63
130
|
|
|
64
131
|
case payload_data["type"]
|
|
65
132
|
when "hash"
|
|
66
133
|
payload = Schemas::HashCredentialPayload.new(type: "hash", hash: payload_data["hash"])
|
|
67
|
-
|
|
134
|
+
validate_hash(payload, req, credential: credential)
|
|
68
135
|
when "transaction"
|
|
69
136
|
payload = Schemas::TransactionCredentialPayload.new(
|
|
70
137
|
type: "transaction", signature: payload_data["signature"]
|
|
71
138
|
)
|
|
72
|
-
|
|
139
|
+
validate_transaction(payload, req, credential: credential)
|
|
73
140
|
when "proof"
|
|
74
141
|
payload = Schemas::ProofCredentialPayload.new(
|
|
75
142
|
type: "proof", signature: payload_data["signature"]
|
|
76
143
|
)
|
|
77
|
-
|
|
144
|
+
validate_proof(payload, req, credential: credential)
|
|
78
145
|
else
|
|
79
146
|
raise Mpp::VerificationError, "Invalid credential type: #{payload_data["type"]}"
|
|
80
147
|
end
|
|
81
148
|
end
|
|
82
149
|
|
|
83
|
-
private
|
|
84
|
-
|
|
85
150
|
def get_rpc_url
|
|
86
151
|
raise Mpp::VerificationError, "No rpc_url configured on ChargeIntent" unless @rpc_url
|
|
87
152
|
|
|
88
153
|
@rpc_url
|
|
89
154
|
end
|
|
90
155
|
|
|
91
|
-
# Parse a
|
|
156
|
+
# Parse a credential source: nil if absent, the address for a
|
|
92
157
|
# did:pkh:eip155 DID matching expected_chain_id, else raises.
|
|
93
|
-
def
|
|
158
|
+
def parse_credential_source(source, expected_chain_id)
|
|
94
159
|
return nil unless source
|
|
95
160
|
|
|
96
161
|
expected_chain_id = begin
|
|
97
162
|
Integer(expected_chain_id)
|
|
98
163
|
rescue ArgumentError, TypeError
|
|
99
|
-
raise Mpp::VerificationError, "
|
|
164
|
+
raise Mpp::VerificationError, "Credential source is invalid"
|
|
100
165
|
end
|
|
101
166
|
|
|
102
167
|
parsed = Proof.parse_source(source)
|
|
103
168
|
unless parsed && parsed[:chain_id] == expected_chain_id
|
|
104
|
-
raise Mpp::VerificationError, "
|
|
169
|
+
raise Mpp::VerificationError, "Credential source is invalid"
|
|
105
170
|
end
|
|
106
171
|
|
|
107
172
|
parsed[:address]
|
|
108
173
|
end
|
|
109
174
|
|
|
110
|
-
def
|
|
111
|
-
|
|
112
|
-
source_address = parse_hash_credential_source(credential.source, request.method_details.chain_id)
|
|
113
|
-
|
|
114
|
-
if @store
|
|
115
|
-
store_key = "mpp:charge:#{payload.hash.downcase}"
|
|
116
|
-
raise Mpp::VerificationError, "Transaction hash already used" unless @store.put_if_absent(store_key,
|
|
117
|
-
payload.hash)
|
|
118
|
-
end
|
|
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)
|
|
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
|
|
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,26 +207,22 @@ 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,
|
|
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
|
|
158
214
|
end
|
|
159
215
|
|
|
160
216
|
rpc_url = get_rpc_url
|
|
161
|
-
reserved_tx_hash =
|
|
162
|
-
store_key =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
store_key = "mpp:charge:#{reserved_tx_hash.downcase}"
|
|
166
|
-
unless @store.put_if_absent(store_key, TRANSACTION_PENDING)
|
|
167
|
-
raise Mpp::VerificationError, "Transaction hash already used" unless @store.get(store_key) == TRANSACTION_PENDING
|
|
217
|
+
reserved_tx_hash = raw_transaction_hash(raw_tx)
|
|
218
|
+
store_key = "mpp:charge:#{reserved_tx_hash.downcase}"
|
|
219
|
+
unless @store.put_if_absent(store_key, TRANSACTION_PENDING)
|
|
220
|
+
raise Mpp::VerificationError, "Transaction hash already used" unless @store.get(store_key) == TRANSACTION_PENDING
|
|
168
221
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
end
|
|
222
|
+
receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
|
|
223
|
+
verify_transaction_receipt!(receipt_data, request, credential: credential)
|
|
224
|
+
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
225
|
+
return Mpp::Receipt.success(reserved_tx_hash)
|
|
174
226
|
end
|
|
175
227
|
|
|
176
228
|
# We pay the gas, so simulate the co-signed tx first and bail if it
|
|
@@ -178,7 +230,7 @@ module Mpp
|
|
|
178
230
|
begin
|
|
179
231
|
simulate_before_broadcast(simulate_payload, rpc_url) if simulate_payload
|
|
180
232
|
rescue
|
|
181
|
-
@store.delete(store_key)
|
|
233
|
+
@store.delete(store_key)
|
|
182
234
|
raise
|
|
183
235
|
end
|
|
184
236
|
|
|
@@ -186,33 +238,36 @@ module Mpp
|
|
|
186
238
|
begin
|
|
187
239
|
tx_hash = Rpc.call(rpc_url, "eth_sendRawTransaction", [raw_tx])
|
|
188
240
|
rescue => e
|
|
189
|
-
if
|
|
241
|
+
if transaction_submission_may_have_succeeded?(e)
|
|
190
242
|
receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
|
|
191
243
|
verify_transaction_receipt!(receipt_data, request, credential: credential)
|
|
192
|
-
@store
|
|
244
|
+
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
193
245
|
return Mpp::Receipt.success(reserved_tx_hash)
|
|
194
246
|
end
|
|
195
247
|
|
|
196
|
-
@store.delete(store_key)
|
|
248
|
+
@store.delete(store_key)
|
|
197
249
|
raise Mpp::VerificationError, "Transaction submission failed: #{e.message}"
|
|
198
250
|
end
|
|
199
251
|
|
|
200
252
|
unless tx_hash
|
|
201
|
-
@store.delete(store_key)
|
|
253
|
+
@store.delete(store_key)
|
|
202
254
|
raise Mpp::VerificationError, "No transaction hash returned"
|
|
203
255
|
end
|
|
204
256
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
257
|
+
# Tempo nodes hash canonical RLP, which can differ from keccak(raw
|
|
258
|
+
# bytes). Replay keys must follow the chain hash so a later hash
|
|
259
|
+
# credential for the same payment is rejected. Keep the raw-hash
|
|
260
|
+
# claim until verification finishes so a concurrent retry cannot
|
|
261
|
+
# rebroadcast the same payload.
|
|
262
|
+
raw_store_key = store_key
|
|
263
|
+
store_key, reserved_tx_hash = rebase_reservation_to_chain_hash(store_key, reserved_tx_hash, tx_hash)
|
|
209
264
|
|
|
210
|
-
|
|
211
|
-
receipt_data = fetch_transaction_receipt(rpc_url, tx_hash)
|
|
265
|
+
receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
|
|
212
266
|
verify_transaction_receipt!(receipt_data, request, credential: credential)
|
|
213
|
-
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
267
|
+
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
268
|
+
@store.put(raw_store_key, TRANSACTION_VERIFIED) if raw_store_key != store_key
|
|
214
269
|
|
|
215
|
-
Mpp::Receipt.success(
|
|
270
|
+
Mpp::Receipt.success(reserved_tx_hash)
|
|
216
271
|
end
|
|
217
272
|
|
|
218
273
|
def transaction_submission_may_have_succeeded?(error)
|
|
@@ -245,12 +300,13 @@ module Mpp
|
|
|
245
300
|
|
|
246
301
|
def verify_transaction_receipt!(receipt_data, request, credential:)
|
|
247
302
|
raise Mpp::VerificationError, "Transaction reverted" unless receipt_data["status"] == "0x1"
|
|
248
|
-
|
|
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"])
|
|
249
305
|
unless matched_logs.any?
|
|
250
306
|
raise Mpp::VerificationError,
|
|
251
307
|
"Transaction must contain a Transfer log matching request parameters"
|
|
252
308
|
end
|
|
253
|
-
assert_challenge_bound_memo(matched_logs, credential.challenge)
|
|
309
|
+
assert_challenge_bound_memo(matched_logs, credential.challenge)
|
|
254
310
|
end
|
|
255
311
|
|
|
256
312
|
def verify_transfer_logs(receipt, request, expected_sender: nil)
|
|
@@ -258,7 +314,6 @@ module Mpp
|
|
|
258
314
|
end
|
|
259
315
|
|
|
260
316
|
def match_transfer_logs(receipt, request, expected_sender: nil, source: nil, validate_sender: nil)
|
|
261
|
-
expected_memo = request.method_details.memo
|
|
262
317
|
matched_logs = []
|
|
263
318
|
|
|
264
319
|
(receipt["logs"] || []).each do |log|
|
|
@@ -273,41 +328,25 @@ module Mpp
|
|
|
273
328
|
next unless to_address.downcase == request.recipient.downcase
|
|
274
329
|
|
|
275
330
|
matched =
|
|
276
|
-
|
|
277
|
-
|
|
331
|
+
case topics[0]
|
|
332
|
+
when TRANSFER_WITH_MEMO_TOPIC
|
|
278
333
|
next if topics.length < 4
|
|
279
334
|
|
|
280
335
|
data = log.fetch("data", "0x")
|
|
281
336
|
next if data.length < 66
|
|
282
337
|
|
|
283
338
|
amount = data[2, 64].to_i(16)
|
|
284
|
-
|
|
285
|
-
memo_clean = expected_memo.downcase
|
|
286
|
-
memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
|
|
287
|
-
next unless amount == Integer(request.amount) && memo.downcase == memo_clean
|
|
288
|
-
|
|
289
|
-
{kind: :memo, memo: memo}
|
|
290
|
-
else
|
|
291
|
-
case topics[0]
|
|
292
|
-
when TRANSFER_WITH_MEMO_TOPIC
|
|
293
|
-
next if topics.length < 4
|
|
294
|
-
|
|
295
|
-
data = log.fetch("data", "0x")
|
|
296
|
-
next if data.length < 66
|
|
339
|
+
next unless amount == Integer(request.amount)
|
|
297
340
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
when TRANSFER_TOPIC
|
|
303
|
-
data = log.fetch("data", "0x")
|
|
304
|
-
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
|
|
305
345
|
|
|
306
|
-
|
|
307
|
-
|
|
346
|
+
amount = data.delete_prefix("0x").to_i(16)
|
|
347
|
+
next unless amount == Integer(request.amount)
|
|
308
348
|
|
|
309
|
-
|
|
310
|
-
end
|
|
349
|
+
{kind: :transfer}
|
|
311
350
|
end
|
|
312
351
|
|
|
313
352
|
next unless matched
|
|
@@ -339,6 +378,22 @@ module Mpp
|
|
|
339
378
|
raise Mpp::VerificationError, "Payment verification failed: memo is not bound to this challenge"
|
|
340
379
|
end
|
|
341
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
|
+
|
|
342
397
|
def validate_transaction_payload(signature, request, challenge: nil)
|
|
343
398
|
# Best-effort pre-broadcast check
|
|
344
399
|
begin
|
|
@@ -389,30 +444,40 @@ module Mpp
|
|
|
389
444
|
end
|
|
390
445
|
|
|
391
446
|
def raw_transaction_hash(raw_tx)
|
|
392
|
-
Kernel.require "eth"
|
|
393
|
-
|
|
394
447
|
hex = raw_tx.delete_prefix("0x")
|
|
395
448
|
unless hex.match?(/\A[0-9a-fA-F]+\z/) && hex.length.even?
|
|
396
449
|
raise Mpp::VerificationError, "Invalid transaction signature"
|
|
397
450
|
end
|
|
398
451
|
|
|
399
|
-
"0x#{
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
452
|
+
"0x#{Attribution.keccak256([hex].pack("H*")).unpack1("H*")}"
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def normalize_tx_hash(tx_hash)
|
|
456
|
+
hex = tx_hash.to_s.downcase
|
|
457
|
+
hex.start_with?("0x") ? hex : "0x#{hex}"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def rebase_reservation_to_chain_hash(store_key, reserved_tx_hash, returned_tx_hash)
|
|
461
|
+
canonical = normalize_tx_hash(returned_tx_hash)
|
|
462
|
+
return [store_key, reserved_tx_hash] if canonical == reserved_tx_hash.downcase
|
|
463
|
+
|
|
464
|
+
canonical_key = "mpp:charge:#{canonical}"
|
|
465
|
+
unless @store.put_if_absent(canonical_key, TRANSACTION_PENDING)
|
|
466
|
+
unless @store.get(canonical_key) == TRANSACTION_PENDING
|
|
467
|
+
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
468
|
+
raise Mpp::VerificationError, "Transaction hash already used"
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
[canonical_key, canonical]
|
|
403
473
|
end
|
|
404
474
|
|
|
405
475
|
def match_transfer_calldata(call_data_hex, request, challenge: nil)
|
|
406
476
|
return false if call_data_hex.length < 136
|
|
407
477
|
|
|
408
478
|
selector = call_data_hex[0, 8].downcase
|
|
409
|
-
expected_memo = request.method_details.memo
|
|
410
479
|
|
|
411
|
-
|
|
412
|
-
return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
|
|
413
|
-
elsif selector != TRANSFER_WITH_MEMO_SELECTOR
|
|
414
|
-
return false
|
|
415
|
-
end
|
|
480
|
+
return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
|
|
416
481
|
|
|
417
482
|
decoded_to = "0x#{call_data_hex[32, 40]}"
|
|
418
483
|
decoded_amount = call_data_hex[72, 64].to_i(16)
|
|
@@ -421,23 +486,16 @@ module Mpp
|
|
|
421
486
|
return false unless decoded_amount == Integer(request.amount)
|
|
422
487
|
return false if call_data_hex.length < 200
|
|
423
488
|
|
|
424
|
-
|
|
425
|
-
decoded_memo = "0x#{call_data_hex[136, 64]}"
|
|
426
|
-
memo_clean = expected_memo.downcase
|
|
427
|
-
memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
|
|
428
|
-
return false unless decoded_memo.downcase == memo_clean
|
|
429
|
-
else
|
|
430
|
-
return false unless challenge
|
|
489
|
+
return false unless challenge
|
|
431
490
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
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)
|
|
436
494
|
|
|
437
495
|
true
|
|
438
496
|
end
|
|
439
497
|
|
|
440
|
-
def
|
|
498
|
+
def validate_proof(payload, request, credential:)
|
|
441
499
|
raise Mpp::VerificationError, "Proof credentials are only valid for zero-amount challenges" unless Integer(request.amount).zero?
|
|
442
500
|
raise Mpp::VerificationError, "Proof credential must include a source" unless credential.source
|
|
443
501
|
|
|
@@ -455,11 +513,6 @@ module Mpp
|
|
|
455
513
|
)
|
|
456
514
|
raise Mpp::VerificationError, "Proof signature does not match source" unless valid
|
|
457
515
|
|
|
458
|
-
if @store
|
|
459
|
-
store_key = "mpp:proof:#{credential.challenge.id}"
|
|
460
|
-
raise Mpp::VerificationError, "Proof credential has already been used" unless @store.put_if_absent(store_key, true)
|
|
461
|
-
end
|
|
462
|
-
|
|
463
516
|
Mpp::Receipt.success(credential.challenge.id)
|
|
464
517
|
end
|
|
465
518
|
|
|
@@ -467,12 +520,27 @@ module Mpp
|
|
|
467
520
|
!FeePayerClient.hosted_config?(payer)
|
|
468
521
|
end
|
|
469
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.
|
|
470
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)
|
|
471
541
|
require "eth"
|
|
472
542
|
require "rlp"
|
|
473
543
|
|
|
474
|
-
raise Mpp::VerificationError, "No fee payer account configured" unless fee_payer
|
|
475
|
-
|
|
476
544
|
# Decode the 0x78 fee payer envelope
|
|
477
545
|
begin
|
|
478
546
|
all_bytes = [raw_tx.delete_prefix("0x")].pack("H*")
|
|
@@ -591,26 +659,17 @@ module Mpp
|
|
|
591
659
|
end
|
|
592
660
|
|
|
593
661
|
# Build the final transaction with fee_token set
|
|
594
|
-
|
|
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
|
|
595
665
|
raise Mpp::VerificationError, "No fee token available" unless resolved_fee_token
|
|
596
666
|
|
|
597
|
-
allowed_fee_tokens = fee_payer_allowed_fee_tokens ||
|
|
598
|
-
[Defaults.default_currency_for_chain(chain_id).downcase]
|
|
599
667
|
unless allowed_fee_tokens.map(&:downcase).include?(resolved_fee_token.downcase)
|
|
600
668
|
raise Mpp::VerificationError,
|
|
601
669
|
"Fee token #{resolved_fee_token} is not allowed by fee payer policy"
|
|
602
670
|
end
|
|
603
671
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
# Fee payer signs the 0x78 payload, which identifies the recovered sender.
|
|
607
|
-
fee_payer_hash = tx_to_sign.fee_payer_signature_hash
|
|
608
|
-
fee_payer_sig = fee_payer.sign_hash(fee_payer_hash)
|
|
609
|
-
|
|
610
|
-
signed = tx_to_sign.with(fee_payer_signature: fee_payer_sig)
|
|
611
|
-
raw_tx = "0x#{signed.encoded_2718.unpack1("H*")}"
|
|
612
|
-
|
|
613
|
-
[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]
|
|
614
673
|
end
|
|
615
674
|
|
|
616
675
|
# Build a `tempo_simulateV1` payload for the co-signed `0x76` tx.
|
|
@@ -661,6 +720,17 @@ module Mpp
|
|
|
661
720
|
}
|
|
662
721
|
end
|
|
663
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
|
+
|
|
664
734
|
# Simulate the co-signed tx and raise if it would revert. Fails closed:
|
|
665
735
|
# an RPC error is treated as a failed check, not a pass.
|
|
666
736
|
def simulate_before_broadcast(simulate_payload, rpc_url)
|