mpp-rb 0.1.4 → 0.1.6
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 +128 -1
- 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/errors.rb +22 -0
- data/lib/mpp/http/headers.rb +61 -0
- data/lib/mpp/http.rb +8 -0
- data/lib/mpp/methods/evm/assets.rb +103 -0
- data/lib/mpp/methods/evm/authorization.rb +140 -0
- data/lib/mpp/methods/evm/charge_intent.rb +129 -0
- data/lib/mpp/methods/evm/evm_method.rb +133 -0
- data/lib/mpp/methods/evm.rb +67 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +10 -8
- data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +55 -0
- data/lib/mpp/methods/stripe/defaults.rb +1 -0
- data/lib/mpp/methods/stripe/machine_payments.rb +130 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +22 -3
- data/lib/mpp/methods/stripe.rb +23 -0
- data/lib/mpp/methods/tempo/attribution.rb +3 -9
- data/lib/mpp/methods/tempo/client_method.rb +35 -6
- data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
- data/lib/mpp/methods/tempo/intents.rb +80 -48
- data/lib/mpp/methods/tempo/proof.rb +39 -12
- data/lib/mpp/methods/tempo/relay.rb +257 -0
- data/lib/mpp/methods/tempo.rb +2 -0
- data/lib/mpp/parsing.rb +22 -2
- data/lib/mpp/receipt.rb +4 -3
- data/lib/mpp/server/accept_payment.rb +194 -0
- data/lib/mpp/server/compose.rb +399 -0
- data/lib/mpp/server/decorator.rb +30 -7
- data/lib/mpp/server/method.rb +15 -0
- data/lib/mpp/server/middleware.rb +160 -18
- data/lib/mpp/server/mpp_handler.rb +322 -34
- data/lib/mpp/server/result.rb +100 -0
- data/lib/mpp/server/verify.rb +24 -14
- data/lib/mpp/server.rb +4 -0
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp/x402/facilitator.rb +127 -0
- data/lib/mpp/x402/header.rb +131 -0
- data/lib/mpp/x402/server.rb +247 -0
- data/lib/mpp/x402/types.rb +33 -0
- data/lib/mpp/x402.rb +12 -0
- data/lib/mpp.rb +16 -3
- metadata +48 -1
|
@@ -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,6 +41,10 @@ module Mpp
|
|
|
39
41
|
@_method&.fee_payer_allowed_fee_tokens
|
|
40
42
|
end
|
|
41
43
|
|
|
44
|
+
def relay
|
|
45
|
+
@_method&.relay
|
|
46
|
+
end
|
|
47
|
+
|
|
42
48
|
def verify(credential, request)
|
|
43
49
|
req = Schemas::ChargeRequest.from_hash(request)
|
|
44
50
|
|
|
@@ -49,6 +55,8 @@ module Mpp
|
|
|
49
55
|
raise Mpp::VerificationError, "Request has expired" if expires < Time.now.utc
|
|
50
56
|
end
|
|
51
57
|
|
|
58
|
+
return relay.verify(credential, request) if relay
|
|
59
|
+
|
|
52
60
|
payload_data = credential.payload
|
|
53
61
|
unless payload_data.is_a?(Hash) && payload_data.key?("type")
|
|
54
62
|
raise Mpp::VerificationError,
|
|
@@ -105,11 +113,9 @@ module Mpp
|
|
|
105
113
|
# Validate the source before reserving the hash.
|
|
106
114
|
source_address = parse_hash_credential_source(credential.source, request.method_details.chain_id)
|
|
107
115
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
payload.hash)
|
|
112
|
-
end
|
|
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)
|
|
113
119
|
|
|
114
120
|
rpc_url = get_rpc_url
|
|
115
121
|
result = Rpc.call(rpc_url, "eth_getTransactionReceipt", [payload.hash])
|
|
@@ -141,31 +147,26 @@ module Mpp
|
|
|
141
147
|
simulate_payload = nil
|
|
142
148
|
|
|
143
149
|
if request.method_details.fee_payer
|
|
144
|
-
|
|
150
|
+
payer = fee_payer
|
|
151
|
+
raise Mpp::VerificationError, "No fee payer configured" unless payer
|
|
152
|
+
|
|
153
|
+
if local_fee_payer?(payer)
|
|
145
154
|
raw_tx, simulate_payload = cosign_as_fee_payer(raw_tx, request.currency, request: request, challenge: credential.challenge)
|
|
146
155
|
else
|
|
147
|
-
|
|
148
|
-
result = Rpc.call(fee_payer_url, "eth_signRawTransaction", [raw_tx])
|
|
149
|
-
raise Mpp::VerificationError, "Fee payer returned no signed transaction" unless result
|
|
150
|
-
|
|
151
|
-
raw_tx = result
|
|
156
|
+
raw_tx = payer.cosign(raw_tx)
|
|
152
157
|
end
|
|
153
158
|
end
|
|
154
159
|
|
|
155
160
|
rpc_url = get_rpc_url
|
|
156
|
-
reserved_tx_hash =
|
|
157
|
-
store_key =
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
161
|
+
reserved_tx_hash = raw_transaction_hash(raw_tx)
|
|
162
|
+
store_key = "mpp:charge:#{reserved_tx_hash.downcase}"
|
|
163
|
+
unless @store.put_if_absent(store_key, TRANSACTION_PENDING)
|
|
164
|
+
raise Mpp::VerificationError, "Transaction hash already used" unless @store.get(store_key) == TRANSACTION_PENDING
|
|
163
165
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
end
|
|
166
|
+
receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
|
|
167
|
+
verify_transaction_receipt!(receipt_data, request, credential: credential)
|
|
168
|
+
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
169
|
+
return Mpp::Receipt.success(reserved_tx_hash)
|
|
169
170
|
end
|
|
170
171
|
|
|
171
172
|
# We pay the gas, so simulate the co-signed tx first and bail if it
|
|
@@ -173,7 +174,7 @@ module Mpp
|
|
|
173
174
|
begin
|
|
174
175
|
simulate_before_broadcast(simulate_payload, rpc_url) if simulate_payload
|
|
175
176
|
rescue
|
|
176
|
-
@store.delete(store_key)
|
|
177
|
+
@store.delete(store_key)
|
|
177
178
|
raise
|
|
178
179
|
end
|
|
179
180
|
|
|
@@ -181,33 +182,36 @@ module Mpp
|
|
|
181
182
|
begin
|
|
182
183
|
tx_hash = Rpc.call(rpc_url, "eth_sendRawTransaction", [raw_tx])
|
|
183
184
|
rescue => e
|
|
184
|
-
if
|
|
185
|
+
if transaction_submission_may_have_succeeded?(e)
|
|
185
186
|
receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
|
|
186
187
|
verify_transaction_receipt!(receipt_data, request, credential: credential)
|
|
187
|
-
@store
|
|
188
|
+
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
188
189
|
return Mpp::Receipt.success(reserved_tx_hash)
|
|
189
190
|
end
|
|
190
191
|
|
|
191
|
-
@store.delete(store_key)
|
|
192
|
+
@store.delete(store_key)
|
|
192
193
|
raise Mpp::VerificationError, "Transaction submission failed: #{e.message}"
|
|
193
194
|
end
|
|
194
195
|
|
|
195
196
|
unless tx_hash
|
|
196
|
-
@store.delete(store_key)
|
|
197
|
+
@store.delete(store_key)
|
|
197
198
|
raise Mpp::VerificationError, "No transaction hash returned"
|
|
198
199
|
end
|
|
199
200
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
# Tempo nodes hash canonical RLP, which can differ from keccak(raw
|
|
202
|
+
# bytes). Replay keys must follow the chain hash so a later hash
|
|
203
|
+
# credential for the same payment is rejected. Keep the raw-hash
|
|
204
|
+
# claim until verification finishes so a concurrent retry cannot
|
|
205
|
+
# rebroadcast the same payload.
|
|
206
|
+
raw_store_key = store_key
|
|
207
|
+
store_key, reserved_tx_hash = rebase_reservation_to_chain_hash(store_key, reserved_tx_hash, tx_hash)
|
|
204
208
|
|
|
205
|
-
|
|
206
|
-
receipt_data = fetch_transaction_receipt(rpc_url, tx_hash)
|
|
209
|
+
receipt_data = fetch_transaction_receipt(rpc_url, reserved_tx_hash)
|
|
207
210
|
verify_transaction_receipt!(receipt_data, request, credential: credential)
|
|
208
|
-
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
211
|
+
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
212
|
+
@store.put(raw_store_key, TRANSACTION_VERIFIED) if raw_store_key != store_key
|
|
209
213
|
|
|
210
|
-
Mpp::Receipt.success(
|
|
214
|
+
Mpp::Receipt.success(reserved_tx_hash)
|
|
211
215
|
end
|
|
212
216
|
|
|
213
217
|
def transaction_submission_may_have_succeeded?(error)
|
|
@@ -384,17 +388,32 @@ module Mpp
|
|
|
384
388
|
end
|
|
385
389
|
|
|
386
390
|
def raw_transaction_hash(raw_tx)
|
|
387
|
-
Kernel.require "eth"
|
|
388
|
-
|
|
389
391
|
hex = raw_tx.delete_prefix("0x")
|
|
390
392
|
unless hex.match?(/\A[0-9a-fA-F]+\z/) && hex.length.even?
|
|
391
393
|
raise Mpp::VerificationError, "Invalid transaction signature"
|
|
392
394
|
end
|
|
393
395
|
|
|
394
|
-
"0x#{
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
396
|
+
"0x#{Attribution.keccak256([hex].pack("H*")).unpack1("H*")}"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def normalize_tx_hash(tx_hash)
|
|
400
|
+
hex = tx_hash.to_s.downcase
|
|
401
|
+
hex.start_with?("0x") ? hex : "0x#{hex}"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def rebase_reservation_to_chain_hash(store_key, reserved_tx_hash, returned_tx_hash)
|
|
405
|
+
canonical = normalize_tx_hash(returned_tx_hash)
|
|
406
|
+
return [store_key, reserved_tx_hash] if canonical == reserved_tx_hash.downcase
|
|
407
|
+
|
|
408
|
+
canonical_key = "mpp:charge:#{canonical}"
|
|
409
|
+
unless @store.put_if_absent(canonical_key, TRANSACTION_PENDING)
|
|
410
|
+
unless @store.get(canonical_key) == TRANSACTION_PENDING
|
|
411
|
+
@store.put(store_key, TRANSACTION_VERIFIED)
|
|
412
|
+
raise Mpp::VerificationError, "Transaction hash already used"
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
[canonical_key, canonical]
|
|
398
417
|
end
|
|
399
418
|
|
|
400
419
|
def match_transfer_calldata(call_data_hex, request, challenge: nil)
|
|
@@ -450,14 +469,16 @@ module Mpp
|
|
|
450
469
|
)
|
|
451
470
|
raise Mpp::VerificationError, "Proof signature does not match source" unless valid
|
|
452
471
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
raise Mpp::VerificationError, "Proof credential has already been used" unless @store.put_if_absent(store_key, true)
|
|
456
|
-
end
|
|
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)
|
|
457
474
|
|
|
458
475
|
Mpp::Receipt.success(credential.challenge.id)
|
|
459
476
|
end
|
|
460
477
|
|
|
478
|
+
def local_fee_payer?(payer)
|
|
479
|
+
!FeePayerClient.hosted_config?(payer)
|
|
480
|
+
end
|
|
481
|
+
|
|
461
482
|
def cosign_as_fee_payer(raw_tx, fee_token, request: nil, challenge: nil)
|
|
462
483
|
require "eth"
|
|
463
484
|
require "rlp"
|
|
@@ -703,6 +724,10 @@ module Mpp
|
|
|
703
724
|
}
|
|
704
725
|
end
|
|
705
726
|
|
|
727
|
+
# Expected calldata length for transferWithMemo(address,uint256,bytes32):
|
|
728
|
+
# 4 (selector) + 32 (address) + 32 (amount) + 32 (memo) = 100 bytes = 200 hex chars
|
|
729
|
+
MAX_TRANSFER_CALLDATA_HEX_LENGTH = 200
|
|
730
|
+
|
|
706
731
|
def validate_fee_payer_calls(calls, request, challenge: nil)
|
|
707
732
|
if calls.length != 1
|
|
708
733
|
raise Mpp::VerificationError, "Invalid transaction: contains unauthorized extra calls"
|
|
@@ -715,7 +740,14 @@ module Mpp
|
|
|
715
740
|
unless call.to.downcase == request.currency.downcase
|
|
716
741
|
raise Mpp::VerificationError, "Invalid transaction: no matching payment call found"
|
|
717
742
|
end
|
|
718
|
-
|
|
743
|
+
|
|
744
|
+
call_data_hex = call.data.delete_prefix("0x")
|
|
745
|
+
if call_data_hex.length > MAX_TRANSFER_CALLDATA_HEX_LENGTH
|
|
746
|
+
raise Mpp::VerificationError,
|
|
747
|
+
"Invalid transaction: calldata contains trailing padding (#{call_data_hex.length / 2} bytes, expected #{MAX_TRANSFER_CALLDATA_HEX_LENGTH / 2})"
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
unless match_transfer_calldata(call_data_hex, request, challenge: challenge)
|
|
719
751
|
raise Mpp::VerificationError, "Invalid transaction: no matching payment call found"
|
|
720
752
|
end
|
|
721
753
|
end
|
|
@@ -6,16 +6,23 @@ module Mpp
|
|
|
6
6
|
module Tempo
|
|
7
7
|
# EIP-712 proof credentials for zero-amount challenges.
|
|
8
8
|
#
|
|
9
|
-
# Domain: { name: "MPP", version: "
|
|
10
|
-
# Types: { Proof: [{ name: "
|
|
11
|
-
#
|
|
9
|
+
# Domain: { name: "MPP", version: "3", chainId }
|
|
10
|
+
# Types: { Proof: [{ name: "account", type: "address" },
|
|
11
|
+
# { name: "challengeId", type: "string" },
|
|
12
|
+
# { name: "realm", type: "string" }] }
|
|
13
|
+
# Message: { account: <payer>, challengeId: <challenge.id>, realm: <challenge.realm> }
|
|
14
|
+
#
|
|
15
|
+
# The account field binds the signature to one payer wallet, so a proof
|
|
16
|
+
# signed for one account cannot be replayed against another. This matches
|
|
17
|
+
# mpp-go, mpp-rs and mppx, which all sign and verify domain version "3"
|
|
18
|
+
# and reject version "2".
|
|
12
19
|
module Proof
|
|
13
20
|
DOMAIN_NAME = "MPP"
|
|
14
|
-
DOMAIN_VERSION = "
|
|
21
|
+
DOMAIN_VERSION = "3"
|
|
15
22
|
|
|
16
23
|
# EIP-712 domain separator type hash
|
|
17
24
|
DOMAIN_TYPE_HASH = "EIP712Domain(string name,string version,uint256 chainId)"
|
|
18
|
-
PROOF_TYPE_HASH = "Proof(string challengeId,string realm)"
|
|
25
|
+
PROOF_TYPE_HASH = "Proof(address account,string challengeId,string realm)"
|
|
19
26
|
|
|
20
27
|
module_function
|
|
21
28
|
|
|
@@ -36,11 +43,12 @@ module Mpp
|
|
|
36
43
|
)
|
|
37
44
|
end
|
|
38
45
|
|
|
39
|
-
# Compute the EIP-712 struct hash for Proof(challengeId, realm).
|
|
40
|
-
def struct_hash(challenge_id, realm)
|
|
46
|
+
# Compute the EIP-712 struct hash for Proof(account, challengeId, realm).
|
|
47
|
+
def struct_hash(account, challenge_id, realm)
|
|
41
48
|
keccak256(
|
|
42
49
|
abi_encode(
|
|
43
50
|
keccak256(PROOF_TYPE_HASH),
|
|
51
|
+
address_word(account),
|
|
44
52
|
keccak256(challenge_id),
|
|
45
53
|
keccak256(realm)
|
|
46
54
|
)
|
|
@@ -48,15 +56,20 @@ module Mpp
|
|
|
48
56
|
end
|
|
49
57
|
|
|
50
58
|
# Compute the full EIP-712 signing hash.
|
|
51
|
-
def signing_hash(chain_id:, challenge_id:, realm:)
|
|
59
|
+
def signing_hash(chain_id:, account:, challenge_id:, realm:)
|
|
52
60
|
keccak256(
|
|
53
|
-
"\x19\x01".b + domain_separator(chain_id) + struct_hash(challenge_id, realm)
|
|
61
|
+
"\x19\x01".b + domain_separator(chain_id) + struct_hash(account, challenge_id, realm)
|
|
54
62
|
)
|
|
55
63
|
end
|
|
56
64
|
|
|
57
|
-
# Sign a proof credential (client-side).
|
|
65
|
+
# Sign a proof credential (client-side). The signer is also the payer.
|
|
58
66
|
def sign(account:, chain_id:, challenge_id:, realm:)
|
|
59
|
-
hash = signing_hash(
|
|
67
|
+
hash = signing_hash(
|
|
68
|
+
chain_id: chain_id,
|
|
69
|
+
account: account.address.to_s,
|
|
70
|
+
challenge_id: challenge_id,
|
|
71
|
+
realm: realm
|
|
72
|
+
)
|
|
60
73
|
sig = account.sign_hash(hash)
|
|
61
74
|
"0x#{sig.unpack1("H*")}"
|
|
62
75
|
end
|
|
@@ -65,7 +78,12 @@ module Mpp
|
|
|
65
78
|
def verify(address:, chain_id:, challenge_id:, realm:, signature:)
|
|
66
79
|
Kernel.require "eth"
|
|
67
80
|
|
|
68
|
-
hash = signing_hash(
|
|
81
|
+
hash = signing_hash(
|
|
82
|
+
chain_id: chain_id,
|
|
83
|
+
account: address,
|
|
84
|
+
challenge_id: challenge_id,
|
|
85
|
+
realm: realm
|
|
86
|
+
)
|
|
69
87
|
sig_bytes = [signature.delete_prefix("0x")].pack("H*")
|
|
70
88
|
|
|
71
89
|
# Recover the signer address from the signature
|
|
@@ -94,6 +112,15 @@ module Mpp
|
|
|
94
112
|
nil
|
|
95
113
|
end
|
|
96
114
|
|
|
115
|
+
# Encode an address as a 32-byte EIP-712 word. Unlike a string field the
|
|
116
|
+
# address is not hashed, it is the raw 20 bytes left-padded to 32.
|
|
117
|
+
def address_word(address)
|
|
118
|
+
hex = address.to_s.delete_prefix("0x")
|
|
119
|
+
raise ArgumentError, "invalid address: #{address}" unless hex.match?(/\A[a-fA-F0-9]{40}\z/)
|
|
120
|
+
|
|
121
|
+
[hex].pack("H*")
|
|
122
|
+
end
|
|
123
|
+
|
|
97
124
|
# ABI-encode values (packed 32-byte words).
|
|
98
125
|
def abi_encode(*values)
|
|
99
126
|
values.map { |v|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "../../http/headers"
|
|
10
|
+
require_relative "attribution"
|
|
11
|
+
|
|
12
|
+
module Mpp
|
|
13
|
+
module Methods
|
|
14
|
+
module Tempo
|
|
15
|
+
# HTTP client for a Tempo API-compatible MPP relay.
|
|
16
|
+
#
|
|
17
|
+
# `resolve` accepts:
|
|
18
|
+
# * a URL string (unauthenticated relay)
|
|
19
|
+
# * a config hash (`url:` plus optional `headers:`, or mppx-style
|
|
20
|
+
# `api_base_url:` / `api_key:`)
|
|
21
|
+
# * any object that responds to `#validate` and `#broadcast`
|
|
22
|
+
class Relay
|
|
23
|
+
DEFAULT_API_BASE_URL = "https://api.tempo.xyz"
|
|
24
|
+
VALIDATE_PATH = "/v1/mpp/validate"
|
|
25
|
+
BROADCAST_PATH = "/v1/mpp/broadcast"
|
|
26
|
+
DEFAULT_TIMEOUT = Rpc::DEFAULT_TIMEOUT
|
|
27
|
+
|
|
28
|
+
ERROR_CODES = %w[
|
|
29
|
+
already_used
|
|
30
|
+
broadcast_failed
|
|
31
|
+
expired
|
|
32
|
+
invalid_payment
|
|
33
|
+
insufficient_funds
|
|
34
|
+
policy_denied
|
|
35
|
+
screen_rejected
|
|
36
|
+
simulation_failed
|
|
37
|
+
temporarily_unavailable
|
|
38
|
+
unsupported
|
|
39
|
+
unknown
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
attr_reader :base_url
|
|
43
|
+
|
|
44
|
+
def initialize(url, headers: nil)
|
|
45
|
+
@base_url = Mpp::Http::Headers.normalize_base_url(url)
|
|
46
|
+
raise ArgumentError, "relay url is required" if @base_url.empty?
|
|
47
|
+
|
|
48
|
+
@headers = headers
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.resolve_optional(relay)
|
|
52
|
+
return if relay.nil? || relay == false
|
|
53
|
+
|
|
54
|
+
resolve(relay)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.resolve(relay)
|
|
58
|
+
return relay if relay.is_a?(Relay)
|
|
59
|
+
return new(relay) if relay.is_a?(String) && !relay.empty?
|
|
60
|
+
return from_config(relay) if relay.is_a?(Hash)
|
|
61
|
+
return relay if duck_type?(relay)
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "relay must be a URL, {url:, headers:}, or an object that implements #validate and #broadcast"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.from_config(config)
|
|
67
|
+
cfg = Mpp::Http::Headers.symbolize(config)
|
|
68
|
+
url = cfg[:url] || cfg[:api_base_url] || cfg[:apiBaseUrl] || cfg[:base_url] || cfg[:baseUrl]
|
|
69
|
+
url = DEFAULT_API_BASE_URL if url.nil? || url.to_s.empty?
|
|
70
|
+
headers = merge_api_key_headers(cfg[:headers], cfg[:api_key] || cfg[:apiKey])
|
|
71
|
+
new(url.to_s, headers: headers)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.duck_type?(relay)
|
|
75
|
+
!relay.nil? && relay.respond_to?(:validate) && relay.respond_to?(:broadcast)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.merge_api_key_headers(headers, api_key)
|
|
79
|
+
return headers if api_key.nil? || api_key.to_s.empty?
|
|
80
|
+
|
|
81
|
+
defaults = {"tempo-api-key" => api_key.to_s}
|
|
82
|
+
return defaults if headers.nil?
|
|
83
|
+
|
|
84
|
+
if headers.respond_to?(:call)
|
|
85
|
+
original = headers
|
|
86
|
+
->(path) {
|
|
87
|
+
extra = (original.arity == 0) ? original.call : original.call(path)
|
|
88
|
+
extra = {} unless extra.is_a?(Hash)
|
|
89
|
+
defaults.merge(Mpp::Http::Headers.stringify(extra))
|
|
90
|
+
}
|
|
91
|
+
elsif headers.is_a?(Hash)
|
|
92
|
+
defaults.merge(Mpp::Http::Headers.stringify(headers))
|
|
93
|
+
else
|
|
94
|
+
defaults
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
private_class_method :merge_api_key_headers
|
|
98
|
+
|
|
99
|
+
def verify(credential, _request = nil)
|
|
100
|
+
input = to_relay_input(credential)
|
|
101
|
+
validate(input)
|
|
102
|
+
broadcast(input)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate(input)
|
|
106
|
+
response = post(VALIDATE_PATH, input)
|
|
107
|
+
raise failure(response) unless success?(response)
|
|
108
|
+
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def broadcast(input)
|
|
113
|
+
response = post(BROADCAST_PATH, input, extra_headers: {
|
|
114
|
+
"Idempotency-Key" => idempotency_key(input)
|
|
115
|
+
})
|
|
116
|
+
raise failure(response) unless broadcast_success?(response)
|
|
117
|
+
|
|
118
|
+
to_receipt(response.fetch("receipt"))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def post(path, input, extra_headers: {})
|
|
124
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
125
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
126
|
+
http.use_ssl = uri.scheme == "https"
|
|
127
|
+
http.read_timeout = DEFAULT_TIMEOUT
|
|
128
|
+
|
|
129
|
+
request = Net::HTTP::Post.new(uri)
|
|
130
|
+
request["Accept"] = "application/json"
|
|
131
|
+
request["Content-Type"] = "application/json"
|
|
132
|
+
Mpp::Http::Headers.resolve(@headers, path).each { |key, value| request[key] = value }
|
|
133
|
+
extra_headers.each { |key, value| request[key] = value }
|
|
134
|
+
request.body = JSON.generate(input)
|
|
135
|
+
|
|
136
|
+
response = http.request(request)
|
|
137
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
138
|
+
raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned HTTP #{response.code}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
body = response.body.to_s
|
|
142
|
+
if body.empty?
|
|
143
|
+
raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned an empty body")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
parsed = JSON.parse(body)
|
|
147
|
+
unless parsed.is_a?(Hash)
|
|
148
|
+
raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned JSON that is not an object")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
parsed
|
|
152
|
+
rescue JSON::ParserError
|
|
153
|
+
raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned invalid JSON")
|
|
154
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
|
|
155
|
+
raise Mpp::VerificationFailedError.new(reason: "relay request failed: #{e.message}")
|
|
156
|
+
end
|
|
157
|
+
|
|
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
|
+
def idempotency_key(input)
|
|
189
|
+
payload = input["payload"]
|
|
190
|
+
if payload.is_a?(Hash) && payload["type"] == "transaction" && payload["signature"].is_a?(String)
|
|
191
|
+
hex = payload["signature"].delete_prefix("0x")
|
|
192
|
+
if hex.match?(/\A[0-9a-fA-F]+\z/) && hex.length.even?
|
|
193
|
+
hash = Attribution.keccak256([hex].pack("H*"))
|
|
194
|
+
return "mpp_0x#{hash.unpack1("H*")}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
digest = Digest::SHA256.hexdigest(Mpp::Json.compact_encode(input))
|
|
199
|
+
"mpp_0x#{digest}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def success?(response)
|
|
203
|
+
response.is_a?(Hash) && response["success"] == true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def broadcast_success?(response)
|
|
207
|
+
success?(response) && relay_receipt?(response["receipt"])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def relay_receipt?(value)
|
|
211
|
+
value.is_a?(Hash) &&
|
|
212
|
+
value["method"].is_a?(String) &&
|
|
213
|
+
value["reference"].is_a?(String) &&
|
|
214
|
+
value["timestamp"].is_a?(String) &&
|
|
215
|
+
(value["externalId"].nil? || value["externalId"].is_a?(String))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def to_receipt(receipt)
|
|
219
|
+
timestamp = begin
|
|
220
|
+
Time.iso8601(receipt["timestamp"].to_s.gsub("Z", "+00:00"))
|
|
221
|
+
rescue ArgumentError
|
|
222
|
+
raise failure
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
Mpp::Receipt.success(
|
|
226
|
+
receipt["reference"],
|
|
227
|
+
timestamp: timestamp,
|
|
228
|
+
method: receipt["method"],
|
|
229
|
+
external_id: receipt["externalId"]
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def failure(response = nil)
|
|
234
|
+
code = relay_error_code(response)
|
|
235
|
+
return Mpp::PaymentExpiredError.new if code == "expired"
|
|
236
|
+
|
|
237
|
+
reason = case code
|
|
238
|
+
when "already_used", "broadcast_failed", "insufficient_funds", "invalid_payment",
|
|
239
|
+
"simulation_failed", "unsupported", "temporarily_unavailable"
|
|
240
|
+
code.tr("_", " ")
|
|
241
|
+
end
|
|
242
|
+
Mpp::VerificationFailedError.new(reason: reason)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def relay_error_code(value)
|
|
246
|
+
return unless value.is_a?(Hash)
|
|
247
|
+
|
|
248
|
+
error = value["error"]
|
|
249
|
+
return unless error.is_a?(Hash)
|
|
250
|
+
|
|
251
|
+
code = error["code"]
|
|
252
|
+
code if ERROR_CODES.include?(code)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
data/lib/mpp/methods/tempo.rb
CHANGED
|
@@ -11,6 +11,8 @@ module Mpp
|
|
|
11
11
|
autoload :Rpc, "mpp/methods/tempo/rpc"
|
|
12
12
|
autoload :Transaction, "mpp/methods/tempo/transaction"
|
|
13
13
|
autoload :FeePayerPolicy, "mpp/methods/tempo/fee_payer_policy"
|
|
14
|
+
autoload :FeePayerClient, "mpp/methods/tempo/fee_payer_client"
|
|
15
|
+
autoload :Relay, "mpp/methods/tempo/relay"
|
|
14
16
|
autoload :Schemas, "mpp/methods/tempo/schemas"
|
|
15
17
|
# Eagerly require client_method so the Tempo.tempo factory method is available
|
|
16
18
|
require_relative "tempo/client_method"
|
data/lib/mpp/parsing.rb
CHANGED
|
@@ -101,6 +101,12 @@ module Mpp
|
|
|
101
101
|
opaque_b64 = params["opaque"]
|
|
102
102
|
opaque = (opaque_b64 && !opaque_b64.empty?) ? b64_decode(opaque_b64) : nil
|
|
103
103
|
|
|
104
|
+
header = begin
|
|
105
|
+
Mpp.advertised_credential_header(params["header"])
|
|
106
|
+
rescue ArgumentError => e
|
|
107
|
+
Kernel.raise Mpp::ParseError, e.message
|
|
108
|
+
end
|
|
109
|
+
|
|
104
110
|
Mpp::Challenge.new(
|
|
105
111
|
id: id,
|
|
106
112
|
method: method,
|
|
@@ -111,7 +117,8 @@ module Mpp
|
|
|
111
117
|
digest: params["digest"],
|
|
112
118
|
expires: params["expires"],
|
|
113
119
|
description: params["description"],
|
|
114
|
-
opaque: opaque
|
|
120
|
+
opaque: opaque,
|
|
121
|
+
header: header
|
|
115
122
|
)
|
|
116
123
|
end
|
|
117
124
|
|
|
@@ -131,6 +138,9 @@ module Mpp
|
|
|
131
138
|
parts << "digest=\"#{escape_quoted(challenge.digest)}\"" if challenge.digest
|
|
132
139
|
parts << "expires=\"#{escape_quoted(challenge.expires)}\"" if challenge.expires
|
|
133
140
|
parts << "description=\"#{escape_quoted(challenge.description)}\"" if challenge.description
|
|
141
|
+
if challenge.header
|
|
142
|
+
parts << "header=\"#{escape_quoted(challenge.header)}\""
|
|
143
|
+
end
|
|
134
144
|
if challenge.opaque
|
|
135
145
|
opaque_b64 = b64_encode(challenge.opaque)
|
|
136
146
|
parts << "opaque=\"#{opaque_b64}\""
|
|
@@ -158,6 +168,12 @@ module Mpp
|
|
|
158
168
|
method = challenge_data["method"]
|
|
159
169
|
validate_payment_method_id(method)
|
|
160
170
|
|
|
171
|
+
header = begin
|
|
172
|
+
Mpp.advertised_credential_header(challenge_data["header"]&.to_s)
|
|
173
|
+
rescue ArgumentError => e
|
|
174
|
+
Kernel.raise Mpp::ParseError, e.message
|
|
175
|
+
end
|
|
176
|
+
|
|
161
177
|
echo = Mpp::ChallengeEcho.new(
|
|
162
178
|
id: challenge_data["id"].to_s,
|
|
163
179
|
realm: (challenge_data["realm"] || "").to_s,
|
|
@@ -166,7 +182,8 @@ module Mpp
|
|
|
166
182
|
request: (challenge_data["request"] || "").to_s,
|
|
167
183
|
expires: challenge_data["expires"]&.to_s,
|
|
168
184
|
digest: challenge_data["digest"]&.to_s,
|
|
169
|
-
opaque: challenge_data["opaque"]&.to_s
|
|
185
|
+
opaque: challenge_data["opaque"]&.to_s,
|
|
186
|
+
header: header
|
|
170
187
|
)
|
|
171
188
|
|
|
172
189
|
Mpp::Credential.new(
|
|
@@ -188,6 +205,7 @@ module Mpp
|
|
|
188
205
|
}
|
|
189
206
|
challenge_dict["expires"] = credential.challenge.expires if credential.challenge.expires
|
|
190
207
|
challenge_dict["digest"] = credential.challenge.digest if credential.challenge.digest
|
|
208
|
+
challenge_dict["header"] = credential.challenge.header if credential.challenge.header
|
|
191
209
|
challenge_dict["opaque"] = credential.challenge.opaque if credential.challenge.opaque
|
|
192
210
|
|
|
193
211
|
payload = {
|
|
@@ -235,6 +253,7 @@ module Mpp
|
|
|
235
253
|
reference: data["reference"].to_s,
|
|
236
254
|
method: method,
|
|
237
255
|
external_id: data["externalId"]&.to_s,
|
|
256
|
+
subscription_id: data["subscriptionId"]&.to_s,
|
|
238
257
|
extra: extra
|
|
239
258
|
)
|
|
240
259
|
end
|
|
@@ -256,6 +275,7 @@ module Mpp
|
|
|
256
275
|
"timestamp" => timestamp_str
|
|
257
276
|
}
|
|
258
277
|
payload["externalId"] = receipt.external_id if receipt.external_id
|
|
278
|
+
payload["subscriptionId"] = receipt.subscription_id if receipt.subscription_id
|
|
259
279
|
payload["extra"] = receipt.extra if receipt.extra
|
|
260
280
|
|
|
261
281
|
b64_encode(payload)
|