mpp-rb 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +85 -0
- data/lib/mpp/extensions/mcp/verify.rb +1 -1
- data/lib/mpp/methods/evm/charge_intent.rb +38 -17
- data/lib/mpp/methods/stripe/analytics_metadata.rb +24 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +29 -5
- data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +46 -9
- data/lib/mpp/methods/stripe/machine_payments.rb +6 -8
- 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 +8 -0
- data/lib/mpp/methods/stripe.rb +3 -0
- data/lib/mpp/methods/tempo/client_method.rb +40 -12
- data/lib/mpp/methods/tempo/defaults.rb +51 -0
- data/lib/mpp/methods/tempo/intents.rb +147 -89
- data/lib/mpp/methods/tempo/relay.rb +33 -31
- data/lib/mpp/methods/tempo/schemas.rb +3 -4
- data/lib/mpp/parsing.rb +9 -2
- data/lib/mpp/server/compose.rb +10 -5
- data/lib/mpp/server/intent.rb +6 -1
- data/lib/mpp/server/intent_lifecycle.rb +41 -0
- data/lib/mpp/server/method.rb +26 -0
- data/lib/mpp/server/mpp_handler.rb +21 -9
- data/lib/mpp/server/verify.rb +1 -1
- 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 +1 -0
- metadata +6 -1
|
@@ -18,15 +18,15 @@ module Mpp
|
|
|
18
18
|
# Handles client-side credential creation for Tempo payments.
|
|
19
19
|
class TempoMethod
|
|
20
20
|
attr_reader :name, :account, :fee_payer, :fee_payer_allowed_fee_tokens,
|
|
21
|
-
:root_account, :rpc_url, :chain_id, :currency, :recipient, :decimals, :client_id,
|
|
22
|
-
:expected_recipients, :relay, :on_payment_success
|
|
21
|
+
:root_account, :rpc_url, :chain_id, :currency, :currencies, :recipient, :decimals, :client_id,
|
|
22
|
+
:expected_recipients, :relay, :on_payment_success, :fee_token
|
|
23
23
|
attr_accessor :intents
|
|
24
24
|
|
|
25
25
|
def initialize(account: nil, fee_payer: nil, root_account: nil,
|
|
26
26
|
rpc_url: Defaults::RPC_URL, chain_id: nil, currency: nil,
|
|
27
27
|
recipient: nil, decimals: 6, client_id: nil,
|
|
28
28
|
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil,
|
|
29
|
-
relay: nil, on_payment_success: nil, can_offer: nil)
|
|
29
|
+
relay: nil, on_payment_success: nil, can_offer: nil, currencies: nil, fee_token: nil)
|
|
30
30
|
unless on_payment_success.nil? || on_payment_success.respond_to?(:call)
|
|
31
31
|
raise ArgumentError, "on_payment_success must be callable"
|
|
32
32
|
end
|
|
@@ -38,12 +38,15 @@ module Mpp
|
|
|
38
38
|
@account = account
|
|
39
39
|
@fee_payer = fee_payer
|
|
40
40
|
@relay = relay
|
|
41
|
+
@fee_token = fee_token
|
|
41
42
|
@fee_payer_allowed_fee_tokens =
|
|
42
43
|
fee_payer_allowed_fee_tokens&.map { |token| token.to_s.downcase }
|
|
43
44
|
@root_account = root_account
|
|
44
45
|
@rpc_url = rpc_url
|
|
45
46
|
@chain_id = chain_id
|
|
46
|
-
|
|
47
|
+
# Ordered currencies offered by a server; the first is the primary.
|
|
48
|
+
@currencies = (currencies || [currency].compact).dup.freeze
|
|
49
|
+
@currency = currency || @currencies.first
|
|
47
50
|
@recipient = recipient
|
|
48
51
|
@decimals = decimals
|
|
49
52
|
@client_id = client_id
|
|
@@ -82,8 +85,7 @@ module Mpp
|
|
|
82
85
|
nonce_key = nonce_key.start_with?("0x") ? nonce_key.to_i(16) : nonce_key.to_i
|
|
83
86
|
end
|
|
84
87
|
|
|
85
|
-
memo =
|
|
86
|
-
memo ||= Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
|
|
88
|
+
memo = Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
|
|
87
89
|
|
|
88
90
|
# Resolve RPC URL from challenge's chainId. Normalize the configured pin
|
|
89
91
|
# once (it may be a String from ENV/config) so it compares equal to
|
|
@@ -264,26 +266,51 @@ module Mpp
|
|
|
264
266
|
end
|
|
265
267
|
|
|
266
268
|
# Factory function to create a configured TempoMethod.
|
|
267
|
-
|
|
269
|
+
#
|
|
270
|
+
# A server offers one charge per accepted currency, in order. Without
|
|
271
|
+
# `currencies:` it offers OUSD then USDC.e on mainnet and OUSD then
|
|
272
|
+
# pathUSD on Moderato; other chains keep their single default.
|
|
273
|
+
# `currencies:` replaces the defaults. The deprecated `currency:` accepts
|
|
274
|
+
# exactly that token. `currency` keeps its previous default for clients.
|
|
275
|
+
#
|
|
276
|
+
# A local fee payer pays gas in `fee_token:` when set, else in the first
|
|
277
|
+
# allowed fee token it holds, independent of the charge currency.
|
|
278
|
+
def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: Defaults::CHAIN_ID, rpc_url: nil,
|
|
268
279
|
root_account: nil, currency: nil, recipient: nil, decimals: 6, client_id: nil,
|
|
269
280
|
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil, relay: nil,
|
|
270
|
-
on_payment_success: nil, can_offer: nil)
|
|
281
|
+
on_payment_success: nil, can_offer: nil, currencies: nil, fee_token: nil)
|
|
271
282
|
rpc_url ||= chain_id ? Defaults.rpc_url_for_chain(chain_id) : Defaults::RPC_URL
|
|
272
|
-
currency ||= Defaults.default_currency_for_chain(chain_id)
|
|
273
283
|
|
|
274
284
|
if fee_payer == true
|
|
275
285
|
raise ArgumentError, "fee_payer: true requires account:" unless account
|
|
276
286
|
|
|
277
287
|
fee_payer = account
|
|
278
288
|
end
|
|
289
|
+
fee_payer = FeePayerClient.resolve_optional(fee_payer)
|
|
290
|
+
if fee_token && (fee_payer.nil? || FeePayerClient.hosted_config?(fee_payer))
|
|
291
|
+
raise ArgumentError, "fee_token can only be configured for a local fee payer"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
if fee_token && !Defaults::ADDRESS_PATTERN.match?(fee_token)
|
|
295
|
+
raise ArgumentError, "Invalid Tempo fee token address: #{fee_token.inspect}"
|
|
296
|
+
end
|
|
297
|
+
if fee_payer_allowed_fee_tokens&.empty?
|
|
298
|
+
raise ArgumentError, "fee_payer_allowed_fee_tokens must contain at least one token"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
legacy_currency = Defaults.default_currency_for_chain(chain_id)
|
|
302
|
+
primary_currency = currency || currencies&.first || legacy_currency
|
|
303
|
+
# `currency:` is deprecated in favor of `currencies: [currency]`.
|
|
304
|
+
currencies = Defaults.accepted_currencies(chain_id: chain_id, currency: currency, currencies: currencies)
|
|
279
305
|
|
|
280
306
|
method = TempoMethod.new(
|
|
281
307
|
account: account,
|
|
282
|
-
fee_payer:
|
|
308
|
+
fee_payer: fee_payer,
|
|
283
309
|
rpc_url: rpc_url,
|
|
284
310
|
chain_id: chain_id,
|
|
285
311
|
root_account: root_account,
|
|
286
|
-
currency:
|
|
312
|
+
currency: primary_currency,
|
|
313
|
+
currencies: currencies,
|
|
287
314
|
recipient: recipient,
|
|
288
315
|
decimals: decimals,
|
|
289
316
|
client_id: client_id,
|
|
@@ -291,7 +318,8 @@ module Mpp
|
|
|
291
318
|
fee_payer_allowed_fee_tokens: fee_payer_allowed_fee_tokens,
|
|
292
319
|
relay: Relay.resolve_optional(relay),
|
|
293
320
|
on_payment_success: on_payment_success,
|
|
294
|
-
can_offer: can_offer
|
|
321
|
+
can_offer: can_offer,
|
|
322
|
+
fee_token: fee_token
|
|
295
323
|
)
|
|
296
324
|
|
|
297
325
|
intents.each_value do |intent|
|
|
@@ -10,6 +10,8 @@ module Mpp
|
|
|
10
10
|
RPC_URL = "https://rpc.tempo.xyz"
|
|
11
11
|
PATH_USD = "0x20c0000000000000000000000000000000000000"
|
|
12
12
|
USDC = "0x20C000000000000000000000b9537d11c60E8b50"
|
|
13
|
+
# Same address on mainnet and Moderato.
|
|
14
|
+
OUSD = "0x20c0000000000000000000006a37DA5C996874BE"
|
|
13
15
|
PATH_USD_DECIMALS = 6
|
|
14
16
|
|
|
15
17
|
# Testnet (Moderato)
|
|
@@ -25,6 +27,14 @@ module Mpp
|
|
|
25
27
|
TESTNET_CHAIN_ID => PATH_USD
|
|
26
28
|
}.freeze, T::Hash[T.untyped, T.untyped])
|
|
27
29
|
|
|
30
|
+
# Chain ID -> ordered currencies a server accepts by default
|
|
31
|
+
DEFAULT_ACCEPTED_CURRENCIES = T.let({
|
|
32
|
+
CHAIN_ID => [OUSD, USDC].freeze,
|
|
33
|
+
TESTNET_CHAIN_ID => [OUSD, PATH_USD].freeze
|
|
34
|
+
}.freeze, T::Hash[Integer, T::Array[String]])
|
|
35
|
+
|
|
36
|
+
ADDRESS_PATTERN = T.let(/\A0x[0-9a-fA-F]{40}\z/, Regexp)
|
|
37
|
+
|
|
28
38
|
# Chain ID -> default RPC URL mapping
|
|
29
39
|
CHAIN_RPC_URLS = T.let({
|
|
30
40
|
CHAIN_ID => RPC_URL,
|
|
@@ -57,6 +67,47 @@ module Mpp
|
|
|
57
67
|
DEFAULT_CURRENCIES.fetch(chain_id, PATH_USD)
|
|
58
68
|
end
|
|
59
69
|
|
|
70
|
+
# Ordered currencies a server offers when none are configured. Unknown
|
|
71
|
+
# chains keep the single default from default_currency_for_chain.
|
|
72
|
+
sig { params(chain_id: T.nilable(Integer)).returns(T::Array[String]) }
|
|
73
|
+
def default_currencies_for_chain(chain_id)
|
|
74
|
+
defaults = chain_id && DEFAULT_ACCEPTED_CURRENCIES[chain_id]
|
|
75
|
+
defaults || [default_currency_for_chain(chain_id)]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Resolve the ordered currencies a server method accepts. An explicit
|
|
79
|
+
# currencies list replaces the chain defaults, and the legacy singular
|
|
80
|
+
# currency restricts acceptance to exactly that token.
|
|
81
|
+
sig do
|
|
82
|
+
params(
|
|
83
|
+
chain_id: T.nilable(Integer),
|
|
84
|
+
currency: T.nilable(String),
|
|
85
|
+
currencies: T.nilable(T::Array[String])
|
|
86
|
+
).returns(T::Array[String])
|
|
87
|
+
end
|
|
88
|
+
def accepted_currencies(chain_id: nil, currency: nil, currencies: nil)
|
|
89
|
+
unless currency.nil? || currencies.nil?
|
|
90
|
+
Kernel.raise ArgumentError, "pass currency: or currencies:, not both"
|
|
91
|
+
end
|
|
92
|
+
return [currency] unless currency.nil?
|
|
93
|
+
return default_currencies_for_chain(chain_id) if currencies.nil?
|
|
94
|
+
|
|
95
|
+
Kernel.raise ArgumentError, "currencies must not be empty" if currencies.empty?
|
|
96
|
+
currencies.each do |address|
|
|
97
|
+
unless ADDRESS_PATTERN.match?(address.to_s)
|
|
98
|
+
Kernel.raise ArgumentError, "Invalid Tempo currency address: #{address.inspect}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
currencies.uniq(&:downcase)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Fee tokens a local fee payer accepts by default: pathUSD, then the
|
|
105
|
+
# chain's default currency.
|
|
106
|
+
sig { params(chain_id: T.nilable(Integer)).returns(T::Array[String]) }
|
|
107
|
+
def default_fee_tokens(chain_id)
|
|
108
|
+
[PATH_USD, default_currency_for_chain(chain_id)].uniq(&:downcase)
|
|
109
|
+
end
|
|
110
|
+
|
|
60
111
|
sig { params(chain_id: T.nilable(Integer), testnet: T::Boolean).returns(String) }
|
|
61
112
|
def resolve_currency(chain_id: nil, testnet: false)
|
|
62
113
|
id = chain_id || (testnet ? TESTNET_CHAIN_ID : CHAIN_ID)
|
|
@@ -41,11 +41,75 @@ module Mpp
|
|
|
41
41
|
@_method&.fee_payer_allowed_fee_tokens
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
def configured_fee_token
|
|
45
|
+
@_method&.fee_token
|
|
46
|
+
end
|
|
47
|
+
|
|
44
48
|
def relay
|
|
45
49
|
@_method&.relay
|
|
46
50
|
end
|
|
47
51
|
|
|
52
|
+
# @deprecated Use #validate followed by #broadcast.
|
|
48
53
|
def verify(credential, request)
|
|
54
|
+
validate(credential, request)
|
|
55
|
+
broadcast(credential, request)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate(credential, request)
|
|
59
|
+
req = resolve_request(credential, request)
|
|
60
|
+
if relay
|
|
61
|
+
relay.validate(Relay.to_relay_input(credential))
|
|
62
|
+
else
|
|
63
|
+
validate_credential(credential, req)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Mpp::Validation.new(
|
|
67
|
+
challenge: credential.challenge,
|
|
68
|
+
credential: credential,
|
|
69
|
+
details: {mode: validation_mode(credential)},
|
|
70
|
+
intent: name,
|
|
71
|
+
method: "tempo",
|
|
72
|
+
request: request,
|
|
73
|
+
source: credential.source
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def broadcast(credential, request)
|
|
78
|
+
req = resolve_request(credential, request)
|
|
79
|
+
return relay.broadcast(Relay.to_relay_input(credential)) if relay
|
|
80
|
+
|
|
81
|
+
# mppx revalidates Tempo credentials before claiming replay state or
|
|
82
|
+
# submitting a transaction. No per-credential state lives on the intent.
|
|
83
|
+
validated = validate_credential(credential, req)
|
|
84
|
+
case credential.payload["type"]
|
|
85
|
+
when "hash"
|
|
86
|
+
hash = credential.payload.fetch("hash")
|
|
87
|
+
unless @store.put_if_absent("mpp:charge:#{hash.downcase}", hash)
|
|
88
|
+
raise Mpp::VerificationError, "Transaction hash already used"
|
|
89
|
+
end
|
|
90
|
+
validated
|
|
91
|
+
when "proof"
|
|
92
|
+
unless @store.put_if_absent("mpp:proof:#{credential.challenge.id}", true)
|
|
93
|
+
raise Mpp::VerificationError, "Proof credential has already been used"
|
|
94
|
+
end
|
|
95
|
+
validated
|
|
96
|
+
when "transaction"
|
|
97
|
+
payload = Schemas::TransactionCredentialPayload.new(type: "transaction", signature: credential.payload["signature"])
|
|
98
|
+
broadcast_transaction(payload, req, credential: credential)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def validation_mode(credential)
|
|
105
|
+
case credential.payload["type"]
|
|
106
|
+
when "hash" then "push"
|
|
107
|
+
when "proof" then "proof"
|
|
108
|
+
when "transaction" then "pull"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def resolve_request(credential, request)
|
|
49
113
|
req = Schemas::ChargeRequest.from_hash(request)
|
|
50
114
|
|
|
51
115
|
# Check challenge expiry
|
|
@@ -55,67 +119,61 @@ module Mpp
|
|
|
55
119
|
raise Mpp::VerificationError, "Request has expired" if expires < Time.now.utc
|
|
56
120
|
end
|
|
57
121
|
|
|
58
|
-
|
|
122
|
+
req
|
|
123
|
+
end
|
|
59
124
|
|
|
125
|
+
def validate_credential(credential, req)
|
|
60
126
|
payload_data = credential.payload
|
|
61
127
|
unless payload_data.is_a?(Hash) && payload_data.key?("type")
|
|
62
|
-
raise Mpp::VerificationError,
|
|
63
|
-
"Invalid credential payload"
|
|
128
|
+
raise Mpp::VerificationError, "Invalid credential payload"
|
|
64
129
|
end
|
|
65
130
|
|
|
66
131
|
case payload_data["type"]
|
|
67
132
|
when "hash"
|
|
68
133
|
payload = Schemas::HashCredentialPayload.new(type: "hash", hash: payload_data["hash"])
|
|
69
|
-
|
|
134
|
+
validate_hash(payload, req, credential: credential)
|
|
70
135
|
when "transaction"
|
|
71
136
|
payload = Schemas::TransactionCredentialPayload.new(
|
|
72
137
|
type: "transaction", signature: payload_data["signature"]
|
|
73
138
|
)
|
|
74
|
-
|
|
139
|
+
validate_transaction(payload, req, credential: credential)
|
|
75
140
|
when "proof"
|
|
76
141
|
payload = Schemas::ProofCredentialPayload.new(
|
|
77
142
|
type: "proof", signature: payload_data["signature"]
|
|
78
143
|
)
|
|
79
|
-
|
|
144
|
+
validate_proof(payload, req, credential: credential)
|
|
80
145
|
else
|
|
81
146
|
raise Mpp::VerificationError, "Invalid credential type: #{payload_data["type"]}"
|
|
82
147
|
end
|
|
83
148
|
end
|
|
84
149
|
|
|
85
|
-
private
|
|
86
|
-
|
|
87
150
|
def get_rpc_url
|
|
88
151
|
raise Mpp::VerificationError, "No rpc_url configured on ChargeIntent" unless @rpc_url
|
|
89
152
|
|
|
90
153
|
@rpc_url
|
|
91
154
|
end
|
|
92
155
|
|
|
93
|
-
# Parse a
|
|
156
|
+
# Parse a credential source: nil if absent, the address for a
|
|
94
157
|
# did:pkh:eip155 DID matching expected_chain_id, else raises.
|
|
95
|
-
def
|
|
158
|
+
def parse_credential_source(source, expected_chain_id)
|
|
96
159
|
return nil unless source
|
|
97
160
|
|
|
98
161
|
expected_chain_id = begin
|
|
99
162
|
Integer(expected_chain_id)
|
|
100
163
|
rescue ArgumentError, TypeError
|
|
101
|
-
raise Mpp::VerificationError, "
|
|
164
|
+
raise Mpp::VerificationError, "Credential source is invalid"
|
|
102
165
|
end
|
|
103
166
|
|
|
104
167
|
parsed = Proof.parse_source(source)
|
|
105
168
|
unless parsed && parsed[:chain_id] == expected_chain_id
|
|
106
|
-
raise Mpp::VerificationError, "
|
|
169
|
+
raise Mpp::VerificationError, "Credential source is invalid"
|
|
107
170
|
end
|
|
108
171
|
|
|
109
172
|
parsed[:address]
|
|
110
173
|
end
|
|
111
174
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
source_address = parse_hash_credential_source(credential.source, request.method_details.chain_id)
|
|
115
|
-
|
|
116
|
-
store_key = "mpp:charge:#{payload.hash.downcase}"
|
|
117
|
-
raise Mpp::VerificationError, "Transaction hash already used" unless @store.put_if_absent(store_key,
|
|
118
|
-
payload.hash)
|
|
175
|
+
def validate_hash(payload, request, credential:)
|
|
176
|
+
source_address = parse_credential_source(credential.source, request.method_details.chain_id)
|
|
119
177
|
|
|
120
178
|
rpc_url = get_rpc_url
|
|
121
179
|
result = Rpc.call(rpc_url, "eth_getTransactionReceipt", [payload.hash])
|
|
@@ -133,14 +191,12 @@ module Mpp
|
|
|
133
191
|
raise Mpp::VerificationError,
|
|
134
192
|
"Transaction must contain a Transfer log matching request parameters"
|
|
135
193
|
end
|
|
136
|
-
assert_challenge_bound_memo(matched_logs, credential.challenge)
|
|
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,7 +207,7 @@ module Mpp
|
|
|
151
207
|
raise Mpp::VerificationError, "No fee payer configured" unless payer
|
|
152
208
|
|
|
153
209
|
if local_fee_payer?(payer)
|
|
154
|
-
raw_tx, simulate_payload = cosign_as_fee_payer(raw_tx,
|
|
210
|
+
raw_tx, simulate_payload = cosign_as_fee_payer(raw_tx, nil, request: request, challenge: credential.challenge)
|
|
155
211
|
else
|
|
156
212
|
raw_tx = payer.cosign(raw_tx)
|
|
157
213
|
end
|
|
@@ -244,12 +300,13 @@ module Mpp
|
|
|
244
300
|
|
|
245
301
|
def verify_transaction_receipt!(receipt_data, request, credential:)
|
|
246
302
|
raise Mpp::VerificationError, "Transaction reverted" unless receipt_data["status"] == "0x1"
|
|
247
|
-
|
|
303
|
+
source_address = parse_credential_source(credential.source, request.method_details.chain_id)
|
|
304
|
+
matched_logs = match_transfer_logs(receipt_data, request, expected_sender: source_address || receipt_data["from"])
|
|
248
305
|
unless matched_logs.any?
|
|
249
306
|
raise Mpp::VerificationError,
|
|
250
307
|
"Transaction must contain a Transfer log matching request parameters"
|
|
251
308
|
end
|
|
252
|
-
assert_challenge_bound_memo(matched_logs, credential.challenge)
|
|
309
|
+
assert_challenge_bound_memo(matched_logs, credential.challenge)
|
|
253
310
|
end
|
|
254
311
|
|
|
255
312
|
def verify_transfer_logs(receipt, request, expected_sender: nil)
|
|
@@ -257,7 +314,6 @@ module Mpp
|
|
|
257
314
|
end
|
|
258
315
|
|
|
259
316
|
def match_transfer_logs(receipt, request, expected_sender: nil, source: nil, validate_sender: nil)
|
|
260
|
-
expected_memo = request.method_details.memo
|
|
261
317
|
matched_logs = []
|
|
262
318
|
|
|
263
319
|
(receipt["logs"] || []).each do |log|
|
|
@@ -272,41 +328,25 @@ module Mpp
|
|
|
272
328
|
next unless to_address.downcase == request.recipient.downcase
|
|
273
329
|
|
|
274
330
|
matched =
|
|
275
|
-
|
|
276
|
-
|
|
331
|
+
case topics[0]
|
|
332
|
+
when TRANSFER_WITH_MEMO_TOPIC
|
|
277
333
|
next if topics.length < 4
|
|
278
334
|
|
|
279
335
|
data = log.fetch("data", "0x")
|
|
280
336
|
next if data.length < 66
|
|
281
337
|
|
|
282
338
|
amount = data[2, 64].to_i(16)
|
|
283
|
-
|
|
284
|
-
memo_clean = expected_memo.downcase
|
|
285
|
-
memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
|
|
286
|
-
next unless amount == Integer(request.amount) && memo.downcase == memo_clean
|
|
287
|
-
|
|
288
|
-
{kind: :memo, memo: memo}
|
|
289
|
-
else
|
|
290
|
-
case topics[0]
|
|
291
|
-
when TRANSFER_WITH_MEMO_TOPIC
|
|
292
|
-
next if topics.length < 4
|
|
339
|
+
next unless amount == Integer(request.amount)
|
|
293
340
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
next unless amount == Integer(request.amount)
|
|
299
|
-
|
|
300
|
-
{kind: :memo, memo: topics[3]}
|
|
301
|
-
when TRANSFER_TOPIC
|
|
302
|
-
data = log.fetch("data", "0x")
|
|
303
|
-
next if data.length < 66
|
|
341
|
+
{kind: :memo, memo: topics[3]}
|
|
342
|
+
when TRANSFER_TOPIC
|
|
343
|
+
data = log.fetch("data", "0x")
|
|
344
|
+
next if data.length < 66
|
|
304
345
|
|
|
305
|
-
|
|
306
|
-
|
|
346
|
+
amount = data.delete_prefix("0x").to_i(16)
|
|
347
|
+
next unless amount == Integer(request.amount)
|
|
307
348
|
|
|
308
|
-
|
|
309
|
-
end
|
|
349
|
+
{kind: :transfer}
|
|
310
350
|
end
|
|
311
351
|
|
|
312
352
|
next unless matched
|
|
@@ -338,6 +378,22 @@ module Mpp
|
|
|
338
378
|
raise Mpp::VerificationError, "Payment verification failed: memo is not bound to this challenge"
|
|
339
379
|
end
|
|
340
380
|
|
|
381
|
+
def validate_transaction(payload, request, credential:)
|
|
382
|
+
parse_credential_source(credential.source, request.method_details.chain_id)
|
|
383
|
+
validate_transaction_payload(payload.signature, request, challenge: credential.challenge)
|
|
384
|
+
if request.method_details.fee_payer
|
|
385
|
+
payer = fee_payer
|
|
386
|
+
raise Mpp::VerificationError, "No fee payer configured" unless payer
|
|
387
|
+
|
|
388
|
+
# Move existing local sponsorship checks before signing. Hosted
|
|
389
|
+
# payers retain their own policy and terminal credential validation.
|
|
390
|
+
if local_fee_payer?(payer)
|
|
391
|
+
prepare_fee_payer_transaction(payload.signature, nil, request: request, challenge: credential.challenge)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
true
|
|
395
|
+
end
|
|
396
|
+
|
|
341
397
|
def validate_transaction_payload(signature, request, challenge: nil)
|
|
342
398
|
# Best-effort pre-broadcast check
|
|
343
399
|
begin
|
|
@@ -420,13 +476,8 @@ module Mpp
|
|
|
420
476
|
return false if call_data_hex.length < 136
|
|
421
477
|
|
|
422
478
|
selector = call_data_hex[0, 8].downcase
|
|
423
|
-
expected_memo = request.method_details.memo
|
|
424
479
|
|
|
425
|
-
|
|
426
|
-
return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
|
|
427
|
-
elsif selector != TRANSFER_WITH_MEMO_SELECTOR
|
|
428
|
-
return false
|
|
429
|
-
end
|
|
480
|
+
return false unless selector == TRANSFER_WITH_MEMO_SELECTOR
|
|
430
481
|
|
|
431
482
|
decoded_to = "0x#{call_data_hex[32, 40]}"
|
|
432
483
|
decoded_amount = call_data_hex[72, 64].to_i(16)
|
|
@@ -435,23 +486,16 @@ module Mpp
|
|
|
435
486
|
return false unless decoded_amount == Integer(request.amount)
|
|
436
487
|
return false if call_data_hex.length < 200
|
|
437
488
|
|
|
438
|
-
|
|
439
|
-
decoded_memo = "0x#{call_data_hex[136, 64]}"
|
|
440
|
-
memo_clean = expected_memo.downcase
|
|
441
|
-
memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
|
|
442
|
-
return false unless decoded_memo.downcase == memo_clean
|
|
443
|
-
else
|
|
444
|
-
return false unless challenge
|
|
489
|
+
return false unless challenge
|
|
445
490
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
end
|
|
491
|
+
decoded_memo = "0x#{call_data_hex[136, 64]}"
|
|
492
|
+
return false unless Attribution.verify_server(decoded_memo, challenge.realm)
|
|
493
|
+
return false unless Attribution.verify_challenge_binding(decoded_memo, challenge.id)
|
|
450
494
|
|
|
451
495
|
true
|
|
452
496
|
end
|
|
453
497
|
|
|
454
|
-
def
|
|
498
|
+
def validate_proof(payload, request, credential:)
|
|
455
499
|
raise Mpp::VerificationError, "Proof credentials are only valid for zero-amount challenges" unless Integer(request.amount).zero?
|
|
456
500
|
raise Mpp::VerificationError, "Proof credential must include a source" unless credential.source
|
|
457
501
|
|
|
@@ -469,9 +513,6 @@ module Mpp
|
|
|
469
513
|
)
|
|
470
514
|
raise Mpp::VerificationError, "Proof signature does not match source" unless valid
|
|
471
515
|
|
|
472
|
-
store_key = "mpp:proof:#{credential.challenge.id}"
|
|
473
|
-
raise Mpp::VerificationError, "Proof credential has already been used" unless @store.put_if_absent(store_key, true)
|
|
474
|
-
|
|
475
516
|
Mpp::Receipt.success(credential.challenge.id)
|
|
476
517
|
end
|
|
477
518
|
|
|
@@ -479,12 +520,27 @@ module Mpp
|
|
|
479
520
|
!FeePayerClient.hosted_config?(payer)
|
|
480
521
|
end
|
|
481
522
|
|
|
523
|
+
# A nil fee_token selects the configured fee token, else the first
|
|
524
|
+
# allowed fee token the fee payer holds, else the first allowed.
|
|
482
525
|
def cosign_as_fee_payer(raw_tx, fee_token, request: nil, challenge: nil)
|
|
526
|
+
raise Mpp::VerificationError, "No fee payer account configured" unless fee_payer
|
|
527
|
+
|
|
528
|
+
tx_to_sign, recovered_addr = prepare_fee_payer_transaction(
|
|
529
|
+
raw_tx, fee_token, request: request, challenge: challenge, select_funded: true
|
|
530
|
+
)
|
|
531
|
+
fee_payer_sig = fee_payer.sign_hash(tx_to_sign.fee_payer_signature_hash)
|
|
532
|
+
signed = tx_to_sign.with(fee_payer_signature: fee_payer_sig)
|
|
533
|
+
raw_tx = "0x#{signed.encoded_2718.unpack1("H*")}"
|
|
534
|
+
|
|
535
|
+
[raw_tx, build_simulate_payload(tx_to_sign, recovered_addr, fee_payer_sig)]
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Sponsorship checks, shared by validation and terminal signing. Only
|
|
539
|
+
# select_funded (terminal signing) reads fee payer balances over RPC.
|
|
540
|
+
def prepare_fee_payer_transaction(raw_tx, fee_token, request: nil, challenge: nil, select_funded: false)
|
|
483
541
|
require "eth"
|
|
484
542
|
require "rlp"
|
|
485
543
|
|
|
486
|
-
raise Mpp::VerificationError, "No fee payer account configured" unless fee_payer
|
|
487
|
-
|
|
488
544
|
# Decode the 0x78 fee payer envelope
|
|
489
545
|
begin
|
|
490
546
|
all_bytes = [raw_tx.delete_prefix("0x")].pack("H*")
|
|
@@ -603,26 +659,17 @@ module Mpp
|
|
|
603
659
|
end
|
|
604
660
|
|
|
605
661
|
# Build the final transaction with fee_token set
|
|
606
|
-
|
|
662
|
+
allowed_fee_tokens = fee_payer_allowed_fee_tokens || Defaults.default_fee_tokens(chain_id)
|
|
663
|
+
resolved_fee_token = fee_token || configured_fee_token ||
|
|
664
|
+
(select_funded && funded_fee_token(allowed_fee_tokens)) || allowed_fee_tokens.first
|
|
607
665
|
raise Mpp::VerificationError, "No fee token available" unless resolved_fee_token
|
|
608
666
|
|
|
609
|
-
allowed_fee_tokens = fee_payer_allowed_fee_tokens ||
|
|
610
|
-
[Defaults.default_currency_for_chain(chain_id).downcase]
|
|
611
667
|
unless allowed_fee_tokens.map(&:downcase).include?(resolved_fee_token.downcase)
|
|
612
668
|
raise Mpp::VerificationError,
|
|
613
669
|
"Fee token #{resolved_fee_token} is not allowed by fee payer policy"
|
|
614
670
|
end
|
|
615
671
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
# Fee payer signs the 0x78 payload, which identifies the recovered sender.
|
|
619
|
-
fee_payer_hash = tx_to_sign.fee_payer_signature_hash
|
|
620
|
-
fee_payer_sig = fee_payer.sign_hash(fee_payer_hash)
|
|
621
|
-
|
|
622
|
-
signed = tx_to_sign.with(fee_payer_signature: fee_payer_sig)
|
|
623
|
-
raw_tx = "0x#{signed.encoded_2718.unpack1("H*")}"
|
|
624
|
-
|
|
625
|
-
[raw_tx, build_simulate_payload(tx_to_sign, recovered_addr, fee_payer_sig)]
|
|
672
|
+
[tx_for_recovery.with(fee_token: resolved_fee_token), recovered_addr]
|
|
626
673
|
end
|
|
627
674
|
|
|
628
675
|
# Build a `tempo_simulateV1` payload for the co-signed `0x76` tx.
|
|
@@ -673,6 +720,17 @@ module Mpp
|
|
|
673
720
|
}
|
|
674
721
|
end
|
|
675
722
|
|
|
723
|
+
# First allowed fee token with a nonzero fee payer balance.
|
|
724
|
+
def funded_fee_token(tokens)
|
|
725
|
+
owner = fee_payer.address.delete_prefix("0x").downcase.rjust(64, "0")
|
|
726
|
+
tokens.find do |token|
|
|
727
|
+
balance = Rpc.call(get_rpc_url, "eth_call", [{"to" => token, "data" => "0x70a08231#{owner}"}, "latest"])
|
|
728
|
+
balance.is_a?(String) && balance.delete_prefix("0x").to_i(16) > 0
|
|
729
|
+
rescue
|
|
730
|
+
false
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
|
|
676
734
|
# Simulate the co-signed tx and raise if it would revert. Fails closed:
|
|
677
735
|
# an RPC error is treated as a failed check, not a pass.
|
|
678
736
|
def simulate_before_broadcast(simulate_payload, rpc_url)
|
|
@@ -96,8 +96,40 @@ module Mpp
|
|
|
96
96
|
end
|
|
97
97
|
private_class_method :merge_api_key_headers
|
|
98
98
|
|
|
99
|
+
def self.to_relay_input(credential)
|
|
100
|
+
echo = credential.challenge
|
|
101
|
+
challenge = {
|
|
102
|
+
"id" => echo.id,
|
|
103
|
+
"realm" => echo.realm,
|
|
104
|
+
"method" => echo.method,
|
|
105
|
+
"intent" => echo.intent,
|
|
106
|
+
"request" => decode_request(echo.request)
|
|
107
|
+
}
|
|
108
|
+
challenge["expires"] = echo.expires if echo.expires
|
|
109
|
+
challenge["digest"] = echo.digest if echo.digest
|
|
110
|
+
challenge["opaque"] = echo.opaque if echo.opaque
|
|
111
|
+
|
|
112
|
+
input = {
|
|
113
|
+
"challenge" => challenge,
|
|
114
|
+
"payload" => credential.payload
|
|
115
|
+
}
|
|
116
|
+
input["source"] = credential.source if credential.source
|
|
117
|
+
input
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.decode_request(request)
|
|
121
|
+
return request if request.is_a?(Hash)
|
|
122
|
+
return request unless request.is_a?(String) && !request.empty?
|
|
123
|
+
|
|
124
|
+
Mpp::Parsing.b64_decode(request)
|
|
125
|
+
rescue Mpp::ParseError
|
|
126
|
+
request
|
|
127
|
+
end
|
|
128
|
+
private_class_method :decode_request
|
|
129
|
+
|
|
130
|
+
# @deprecated Use #validate followed by #broadcast.
|
|
99
131
|
def verify(credential, _request = nil)
|
|
100
|
-
input = to_relay_input(credential)
|
|
132
|
+
input = self.class.to_relay_input(credential)
|
|
101
133
|
validate(input)
|
|
102
134
|
broadcast(input)
|
|
103
135
|
end
|
|
@@ -155,36 +187,6 @@ module Mpp
|
|
|
155
187
|
raise Mpp::VerificationFailedError.new(reason: "relay request failed: #{e.message}")
|
|
156
188
|
end
|
|
157
189
|
|
|
158
|
-
def to_relay_input(credential)
|
|
159
|
-
echo = credential.challenge
|
|
160
|
-
challenge = {
|
|
161
|
-
"id" => echo.id,
|
|
162
|
-
"realm" => echo.realm,
|
|
163
|
-
"method" => echo.method,
|
|
164
|
-
"intent" => echo.intent,
|
|
165
|
-
"request" => decode_request(echo.request)
|
|
166
|
-
}
|
|
167
|
-
challenge["expires"] = echo.expires if echo.expires
|
|
168
|
-
challenge["digest"] = echo.digest if echo.digest
|
|
169
|
-
challenge["opaque"] = echo.opaque if echo.opaque
|
|
170
|
-
|
|
171
|
-
input = {
|
|
172
|
-
"challenge" => challenge,
|
|
173
|
-
"payload" => credential.payload
|
|
174
|
-
}
|
|
175
|
-
input["source"] = credential.source if credential.source
|
|
176
|
-
input
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def decode_request(request)
|
|
180
|
-
return request if request.is_a?(Hash)
|
|
181
|
-
return request unless request.is_a?(String) && !request.empty?
|
|
182
|
-
|
|
183
|
-
Mpp::Parsing.b64_decode(request)
|
|
184
|
-
rescue Mpp::ParseError
|
|
185
|
-
request
|
|
186
|
-
end
|
|
187
|
-
|
|
188
190
|
def idempotency_key(input)
|
|
189
191
|
payload = input["payload"]
|
|
190
192
|
if payload.is_a?(Hash) && payload["type"] == "transaction" && payload["signature"].is_a?(String)
|