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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -1
  3. data/lib/mpp/challenge.rb +31 -8
  4. data/lib/mpp/challenge_echo.rb +14 -3
  5. data/lib/mpp/challenge_id.rb +30 -6
  6. data/lib/mpp/client/transport.rb +1 -1
  7. data/lib/mpp/errors.rb +22 -0
  8. data/lib/mpp/http/headers.rb +61 -0
  9. data/lib/mpp/http.rb +8 -0
  10. data/lib/mpp/methods/evm/assets.rb +103 -0
  11. data/lib/mpp/methods/evm/authorization.rb +140 -0
  12. data/lib/mpp/methods/evm/charge_intent.rb +129 -0
  13. data/lib/mpp/methods/evm/evm_method.rb +133 -0
  14. data/lib/mpp/methods/evm.rb +67 -0
  15. data/lib/mpp/methods/stripe/charge_intent.rb +10 -8
  16. data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +55 -0
  17. data/lib/mpp/methods/stripe/defaults.rb +1 -0
  18. data/lib/mpp/methods/stripe/machine_payments.rb +130 -0
  19. data/lib/mpp/methods/stripe/stripe_method.rb +22 -3
  20. data/lib/mpp/methods/stripe.rb +23 -0
  21. data/lib/mpp/methods/tempo/attribution.rb +3 -9
  22. data/lib/mpp/methods/tempo/client_method.rb +35 -6
  23. data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
  24. data/lib/mpp/methods/tempo/intents.rb +80 -48
  25. data/lib/mpp/methods/tempo/proof.rb +39 -12
  26. data/lib/mpp/methods/tempo/relay.rb +257 -0
  27. data/lib/mpp/methods/tempo.rb +2 -0
  28. data/lib/mpp/parsing.rb +22 -2
  29. data/lib/mpp/receipt.rb +4 -3
  30. data/lib/mpp/server/accept_payment.rb +194 -0
  31. data/lib/mpp/server/compose.rb +399 -0
  32. data/lib/mpp/server/decorator.rb +30 -7
  33. data/lib/mpp/server/method.rb +15 -0
  34. data/lib/mpp/server/middleware.rb +160 -18
  35. data/lib/mpp/server/mpp_handler.rb +322 -34
  36. data/lib/mpp/server/result.rb +100 -0
  37. data/lib/mpp/server/verify.rb +24 -14
  38. data/lib/mpp/server.rb +4 -0
  39. data/lib/mpp/version.rb +1 -1
  40. data/lib/mpp/x402/facilitator.rb +127 -0
  41. data/lib/mpp/x402/header.rb +131 -0
  42. data/lib/mpp/x402/server.rb +247 -0
  43. data/lib/mpp/x402/types.rb +33 -0
  44. data/lib/mpp/x402.rb +12 -0
  45. data/lib/mpp.rb +16 -3
  46. 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: nil, validate_sender: nil)
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
- if @store
109
- store_key = "mpp:charge:#{payload.hash.downcase}"
110
- raise Mpp::VerificationError, "Transaction hash already used" unless @store.put_if_absent(store_key,
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
- if fee_payer
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
- fee_payer_url = request.method_details.fee_payer_url || Defaults::DEFAULT_FEE_PAYER_URL
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 = 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
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
- 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
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) if @store && 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 reserved_tx_hash && store_key && transaction_submission_may_have_succeeded?(e)
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&.put(store_key, TRANSACTION_VERIFIED)
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) if @store && 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) if @store && store_key
197
+ @store.delete(store_key)
197
198
  raise Mpp::VerificationError, "No transaction hash returned"
198
199
  end
199
200
 
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
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
- tx_hash = reserved_tx_hash || tx_hash
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) if @store && store_key
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(tx_hash)
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#{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"
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
- 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
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
- unless match_transfer_calldata(call.data.delete_prefix("0x"), request, challenge: challenge)
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: "2", chainId }
10
- # Types: { Proof: [{ name: "challengeId", type: "string" }, { name: "realm", type: "string" }] }
11
- # Message: { challengeId: <challenge.id>, realm: <challenge.realm> }
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 = "2"
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(chain_id: chain_id, challenge_id: challenge_id, realm: realm)
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(chain_id: chain_id, challenge_id: challenge_id, realm: realm)
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
@@ -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)