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
@@ -0,0 +1,140 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Evm
7
+ # EIP-712 TransferWithAuthorization hashing and recovery.
8
+ module Authorization
9
+ DOMAIN_TYPE_HASH = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
10
+ TRANSFER_TYPE_HASH = "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
11
+
12
+ module_function
13
+
14
+ def keccak256(data)
15
+ Kernel.require "eth"
16
+ Eth::Util.keccak256(data)
17
+ rescue Gem::LoadError
18
+ raise LoadError, "eth gem is not available"
19
+ end
20
+
21
+ def challenge_hash(challenge)
22
+ digest = keccak256("#{challenge.id}#{challenge.realm}")
23
+ "0x#{digest.unpack1("H*")}"
24
+ end
25
+
26
+ def checksum_address(address)
27
+ raw = address.to_s
28
+ hex = raw.delete_prefix("0x")
29
+ raise ArgumentError, "invalid address: #{address}" unless hex.match?(/\A[a-fA-F0-9]{40}\z/)
30
+
31
+ begin
32
+ hash = keccak256(hex.downcase).unpack1("H*")
33
+ rescue LoadError
34
+ return raw.start_with?("0x") ? raw : "0x#{hex}"
35
+ end
36
+
37
+ checksummed = hex.downcase.chars.each_with_index.map { |char, index|
38
+ (char.match?(/[a-f]/) && hash[index].to_i(16) >= 8) ? char.upcase : char
39
+ }.join
40
+ "0x#{checksummed}"
41
+ end
42
+
43
+ def signing_hash(authorization:, chain_id:, currency:, from:, to:, value:, valid_after:, valid_before:, nonce:)
44
+ domain = domain_separator(
45
+ name: authorization["name"] || authorization[:name],
46
+ version: authorization["version"] || authorization[:version],
47
+ chain_id: chain_id,
48
+ verifying_contract: currency
49
+ )
50
+ struct = struct_hash(
51
+ from: from,
52
+ to: to,
53
+ value: value,
54
+ valid_after: valid_after,
55
+ valid_before: valid_before,
56
+ nonce: nonce
57
+ )
58
+ keccak256("\x19\x01".b + domain + struct)
59
+ end
60
+
61
+ def recover(authorization:, chain_id:, currency:, payload:)
62
+ hash = signing_hash(
63
+ authorization: authorization,
64
+ chain_id: chain_id,
65
+ currency: currency,
66
+ from: payload["from"],
67
+ to: payload["to"],
68
+ value: payload["value"],
69
+ valid_after: payload["validAfter"],
70
+ valid_before: payload["validBefore"],
71
+ nonce: payload["nonce"]
72
+ )
73
+ recover_address(hash, payload["signature"])
74
+ end
75
+
76
+ def domain_separator(name:, version:, chain_id:, verifying_contract:)
77
+ keccak256(
78
+ abi_encode(
79
+ keccak256(DOMAIN_TYPE_HASH),
80
+ keccak256(name),
81
+ keccak256(version),
82
+ uint256(chain_id),
83
+ address_word(verifying_contract)
84
+ )
85
+ )
86
+ end
87
+
88
+ def struct_hash(from:, to:, value:, valid_after:, valid_before:, nonce:)
89
+ keccak256(
90
+ abi_encode(
91
+ keccak256(TRANSFER_TYPE_HASH),
92
+ address_word(from),
93
+ address_word(to),
94
+ uint256(value),
95
+ uint256(valid_after),
96
+ uint256(valid_before),
97
+ bytes32_word(nonce)
98
+ )
99
+ )
100
+ end
101
+
102
+ def abi_encode(*values)
103
+ values.join
104
+ end
105
+
106
+ def uint256(value)
107
+ value = Integer(value)
108
+ raise ArgumentError, "uint256 out of range" if value.negative? || value >= (1 << 256)
109
+
110
+ [value.to_s(16).rjust(64, "0")].pack("H*")
111
+ end
112
+
113
+ def address_word(value)
114
+ hex = value.to_s.delete_prefix("0x")
115
+ raise ArgumentError, "invalid address: #{value}" unless hex.match?(/\A[a-fA-F0-9]{40}\z/)
116
+
117
+ [hex].pack("H*").rjust(32, "\x00".b)
118
+ end
119
+
120
+ def bytes32_word(value)
121
+ hex = value.to_s.delete_prefix("0x")
122
+ raise ArgumentError, "invalid bytes32: #{value}" unless hex.match?(/\A[a-fA-F0-9]{64}\z/)
123
+
124
+ [hex].pack("H*")
125
+ end
126
+
127
+ def recover_address(hash, signature)
128
+ Kernel.require "eth"
129
+
130
+ sig = signature.to_s
131
+ sig = "0x#{sig}" unless sig.start_with?("0x")
132
+ recovered_key = Eth::Signature.recover(hash, sig)
133
+ Eth::Util.public_key_to_address(recovered_key).to_s
134
+ rescue
135
+ nil
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,129 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+
6
+ module Mpp
7
+ module Methods
8
+ module Evm
9
+ # Server-side EVM charge intent: recover EIP-3009, then settle via facilitator.
10
+ class ChargeIntent
11
+ attr_reader :name, :facilitator, :authorization, :max_timeout_seconds, :route_binding
12
+
13
+ def initialize(authorization:, facilitator:, max_timeout_seconds: 300, route_binding: :resource)
14
+ @name = "charge"
15
+ @authorization = authorization
16
+ @facilitator = facilitator
17
+ @max_timeout_seconds = max_timeout_seconds
18
+ @route_binding = route_binding
19
+ end
20
+
21
+ def verify(credential, request)
22
+ payload = credential.payload
23
+ unless payload.is_a?(Hash) && payload["type"] == "authorization"
24
+ raise Mpp::VerificationError, "EVM authorization credentials are not supported for this challenge"
25
+ end
26
+
27
+ if request.dig("methodDetails", "splits")
28
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization credentials do not support splits")
29
+ end
30
+
31
+ unless addresses_equal?(payload["to"], request["recipient"])
32
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization recipient mismatch")
33
+ end
34
+ unless payload["value"].to_s == request["amount"].to_s
35
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization amount mismatch")
36
+ end
37
+
38
+ unless x402_credential?(credential)
39
+ expected_nonce = Authorization.challenge_hash(credential.challenge)
40
+ unless payload["nonce"].to_s.downcase == expected_nonce.downcase
41
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization challenge hash mismatch")
42
+ end
43
+ end
44
+
45
+ now = Time.now.to_i
46
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization is not valid yet") if Integer(payload["validAfter"]) > now
47
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization has expired") if Integer(payload["validBefore"]) <= now
48
+
49
+ chain_id = request.dig("methodDetails", "chainId")
50
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization requires chainId") if chain_id.nil?
51
+
52
+ signer = Authorization.recover(
53
+ authorization: @authorization,
54
+ chain_id: chain_id,
55
+ currency: request["currency"],
56
+ payload: payload
57
+ )
58
+ unless signer && addresses_equal?(signer, payload["from"])
59
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization signature mismatch")
60
+ end
61
+
62
+ checksummed_from = Authorization.checksum_address(payload["from"])
63
+ source = "did:pkh:eip155:#{chain_id}:#{checksummed_from}"
64
+ if credential.source && credential.source != source
65
+ raise Mpp::VerificationFailedError.new(reason: "EVM authorization source mismatch")
66
+ end
67
+
68
+ settled = settle(payload, request)
69
+ Mpp::Receipt.success(settled.fetch("transaction"), method: "evm")
70
+ end
71
+
72
+ def payment_requirements(request)
73
+ Mpp::X402::Server.to_payment_requirements(
74
+ request,
75
+ authorization: @authorization,
76
+ max_timeout_seconds: @max_timeout_seconds
77
+ )
78
+ end
79
+
80
+ private
81
+
82
+ def settle(payload, request)
83
+ requirements = payment_requirements(request)
84
+ payment_payload = {
85
+ "accepted" => requirements,
86
+ "payload" => {
87
+ "authorization" => {
88
+ "from" => payload["from"],
89
+ "nonce" => payload["nonce"],
90
+ "to" => payload["to"],
91
+ "validAfter" => payload["validAfter"].to_s,
92
+ "validBefore" => payload["validBefore"].to_s,
93
+ "value" => payload["value"].to_s
94
+ },
95
+ "signature" => payload["signature"]
96
+ },
97
+ "x402Version" => Mpp::X402::VERSION
98
+ }
99
+
100
+ verified = @facilitator.verify(payment_payload, requirements)
101
+ unless verified["isValid"]
102
+ raise Mpp::VerificationFailedError.new(
103
+ reason: verified["invalidMessage"] || verified["invalidReason"] || "EVM facilitator verify failed"
104
+ )
105
+ end
106
+
107
+ settled = @facilitator.settle(payment_payload, requirements)
108
+ unless settled["success"]
109
+ raise Mpp::VerificationFailedError.new(
110
+ reason: settled["errorMessage"] || settled["errorReason"] || "EVM facilitator settlement failed"
111
+ )
112
+ end
113
+
114
+ settled
115
+ end
116
+
117
+ def x402_credential?(credential)
118
+ credential.payload.is_a?(Hash) && credential.payload["_x402"] == true
119
+ end
120
+
121
+ def addresses_equal?(left, right)
122
+ return false if left.nil? || right.nil?
123
+
124
+ left.to_s.delete_prefix("0x").downcase == right.to_s.delete_prefix("0x").downcase
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,133 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Evm
7
+ # EVM payment method. Speaks native Payment-auth and x402 exact.
8
+ class EvmMethod
9
+ attr_reader :name, :currency, :recipient, :decimals, :chain_id, :authorization, :x402,
10
+ :on_payment_success
11
+ attr_accessor :intents
12
+
13
+ def initialize(currency:, recipient:, decimals:, chain_id:, authorization:, x402:, on_payment_success: nil, can_offer: nil)
14
+ if !on_payment_success.nil? && !on_payment_success.respond_to?(:call)
15
+ raise ArgumentError, "on_payment_success must be callable"
16
+ end
17
+ if !can_offer.nil? && !can_offer.respond_to?(:call)
18
+ raise ArgumentError, "can_offer must be callable"
19
+ end
20
+
21
+ @name = "evm"
22
+ @currency = Authorization.checksum_address(currency)
23
+ @recipient = Authorization.checksum_address(recipient)
24
+ @decimals = decimals
25
+ @chain_id = chain_id
26
+ @authorization = authorization
27
+ @x402 = x402
28
+ @on_payment_success = on_payment_success
29
+ @can_offer = can_offer
30
+ @intents = {}
31
+ end
32
+
33
+ def can_offer?(request)
34
+ return true unless @can_offer
35
+
36
+ @can_offer.call(request)
37
+ end
38
+
39
+ def transform_request(request, _credential)
40
+ method_details = request.fetch("methodDetails", {})
41
+ method_details = {} unless method_details.is_a?(Hash)
42
+
43
+ method_details["chainId"] = @chain_id
44
+ method_details["credentialTypes"] = ["authorization"]
45
+ request.merge("methodDetails" => method_details, "currency" => @currency, "recipient" => @recipient)
46
+ end
47
+
48
+ def payment_requirements(request)
49
+ charge_intent.payment_requirements(request)
50
+ end
51
+
52
+ def x402_matches?(payload, request)
53
+ accepted = payload["accepted"]
54
+ return false unless accepted.is_a?(Hash)
55
+
56
+ Mpp::X402::Server.canonical_equal?(accepted, payment_requirements(request))
57
+ end
58
+
59
+ def decorate_challenge(headers, challenge, url: nil, http_method: nil, request: nil)
60
+ return headers if url.nil? || url.empty?
61
+
62
+ requirements = payment_requirements(request || challenge.request)
63
+ extensions = Mpp::X402::Server.route_extensions(challenge, http_method)
64
+ body = Mpp::X402::Server.payment_required_body(
65
+ requirements: requirements,
66
+ resource_url: url,
67
+ extensions: extensions,
68
+ error: "#{Mpp::X402::PAYMENT_SIGNATURE_HEADER} header is required"
69
+ )
70
+ headers["PAYMENT-REQUIRED"] = Mpp::X402::Header.encode_payment_required(body)
71
+ headers
72
+ end
73
+
74
+ def decorate_receipt(headers, receipt, credential, payment_signature: nil)
75
+ return headers if payment_signature.nil? || payment_signature.empty?
76
+
77
+ payload = credential.payload
78
+ request = decode_request(credential)
79
+ chain_id = request.dig("methodDetails", "chainId") || @chain_id
80
+ headers["PAYMENT-RESPONSE"] = Mpp::X402::Header.encode_payment_response({
81
+ "network" => "eip155:#{chain_id}",
82
+ "payer" => payload["from"],
83
+ "success" => true,
84
+ "transaction" => receipt.reference
85
+ })
86
+ headers
87
+ end
88
+
89
+ def bind_x402_credential(payment_signature, challenge:, request:, url:, body:, http_method: nil)
90
+ payment_payload = Mpp::X402::Header.decode_payment_signature(payment_signature)
91
+ requirements = payment_requirements(request)
92
+ authorization = Mpp::X402::Server.bind_credential(
93
+ payment_payload: payment_payload,
94
+ requirements: requirements,
95
+ resource_url: url,
96
+ challenge: challenge,
97
+ body: body,
98
+ route_binding: @x402[:route_binding],
99
+ http_method: http_method
100
+ )
101
+ echo = challenge.to_echo
102
+ Mpp::Credential.new(
103
+ challenge: echo,
104
+ payload: authorization.merge("_x402" => true),
105
+ source: source_for(authorization["from"])
106
+ )
107
+ end
108
+
109
+ private
110
+
111
+ def charge_intent
112
+ intent = @intents["charge"]
113
+ raise ArgumentError, "evm method is missing charge intent" unless intent
114
+
115
+ intent
116
+ end
117
+
118
+ def decode_request(credential)
119
+ echo = credential.challenge
120
+ return {} if echo.request.nil? || echo.request.empty?
121
+
122
+ Mpp::Parsing.b64_decode(echo.request)
123
+ rescue Mpp::ParseError
124
+ {}
125
+ end
126
+
127
+ def source_for(address)
128
+ "did:pkh:eip155:#{@chain_id}:#{Authorization.checksum_address(address)}"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,67 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Evm
7
+ autoload :Assets, "mpp/methods/evm/assets"
8
+ autoload :Authorization, "mpp/methods/evm/authorization"
9
+ autoload :ChargeIntent, "mpp/methods/evm/charge_intent"
10
+ autoload :EvmMethod, "mpp/methods/evm/evm_method"
11
+
12
+ # Factory for a server-side EVM charge method with inline x402 exact support.
13
+ def self.charge(currency:, recipient:, x402:, authorization: nil, chain_id: nil, decimals: nil,
14
+ on_payment_success: nil, can_offer: nil)
15
+ resolved = Assets.resolve(
16
+ currency,
17
+ authorization: authorization,
18
+ chain_id: chain_id,
19
+ decimals: decimals
20
+ )
21
+ x402_options = normalize_x402(x402)
22
+
23
+ charge_intent = ChargeIntent.new(
24
+ authorization: resolved.fetch(:authorization),
25
+ facilitator: X402::Facilitator.resolve(x402_options.fetch(:facilitator)),
26
+ max_timeout_seconds: x402_options.fetch(:max_timeout_seconds),
27
+ route_binding: x402_options.fetch(:route_binding)
28
+ )
29
+
30
+ method = EvmMethod.new(
31
+ currency: resolved.fetch(:address),
32
+ recipient: recipient,
33
+ decimals: resolved.fetch(:decimals),
34
+ chain_id: resolved.fetch(:chain_id),
35
+ authorization: resolved.fetch(:authorization),
36
+ x402: x402_options,
37
+ on_payment_success: on_payment_success,
38
+ can_offer: can_offer
39
+ )
40
+ method.intents = {"charge" => charge_intent}
41
+ method
42
+ end
43
+
44
+ def self.normalize_x402(x402)
45
+ options = x402.each_with_object({}) do |(key, value), acc|
46
+ acc[key.to_sym] = value
47
+ end
48
+ facilitator = options[:facilitator]
49
+ if facilitator.nil? || (facilitator.is_a?(String) && facilitator.empty?)
50
+ raise ArgumentError, "evm.charge requires x402: { facilitator: ... }"
51
+ end
52
+
53
+ route_binding = (options[:route_binding] || options[:routeBinding] || :resource).to_sym
54
+ unless [:resource, :required].include?(route_binding)
55
+ raise ArgumentError, "x402 route_binding must be :resource or :required"
56
+ end
57
+
58
+ {
59
+ facilitator: facilitator,
60
+ max_timeout_seconds: Integer(options[:max_timeout_seconds] || options[:maxTimeoutSeconds] || 300),
61
+ route_binding: route_binding
62
+ }
63
+ end
64
+ private_class_method :normalize_x402
65
+ end
66
+ end
67
+ end
@@ -61,10 +61,8 @@ module Mpp
61
61
  payment_method_types: payment_method_types
62
62
  }
63
63
 
64
- # Include metadata from methodDetails if present
65
- if method_details["metadata"].is_a?(Hash)
66
- params[:metadata] = method_details["metadata"].transform_values(&:to_s)
67
- end
64
+ metadata = method_details["metadata"].is_a?(Hash) ? method_details["metadata"].transform_values(&:to_s) : {}
65
+ params[:metadata] = metadata.merge("machine_payment" => "true")
68
66
 
69
67
  unless @client
70
68
  begin
@@ -78,15 +76,16 @@ module Mpp
78
76
  client = @client || ::Stripe::StripeClient.new(@secret_key)
79
77
  result = client.v1.payment_intents.create(
80
78
  params,
81
- {idempotency_key: stripe_idempotency_key(credential)}
79
+ {stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: stripe_idempotency_key(credential)}
82
80
  )
83
81
  rescue => e
84
82
  raise Mpp::VerificationError, e.message
85
83
  end
86
84
 
87
85
  # https://docs.stripe.com/error-low-level#idempotency
88
- if result.respond_to?(:last_response) &&
89
- result.last_response&.headers&.[]("idempotent-replayed") == "true"
86
+ last_response = result.last_response if result.respond_to?(:last_response)
87
+ response_headers = last_response.respond_to?(:http_headers) ? last_response.http_headers : last_response&.headers
88
+ if response_headers&.[]("idempotent-replayed") == "true"
90
89
  raise Mpp::VerificationError, "Payment has already been processed."
91
90
  end
92
91
 
@@ -106,8 +105,11 @@ module Mpp
106
105
 
107
106
  private
108
107
 
108
+ # Include the SPT so a retry with a fresh token is a new PaymentIntent,
109
+ # matching mppx (`prefix_challengeId_spt`). Same challenge + same SPT
110
+ # still collapses via Stripe idempotency.
109
111
  def stripe_idempotency_key(credential)
110
- "mpp-stripe-charge-#{credential.challenge.id}"
112
+ "mpp_#{credential.challenge.id}_#{credential.payload["spt"]}"
111
113
  end
112
114
  end
113
115
  end
@@ -0,0 +1,55 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Records an already-verified crypto transfer as a Stripe PaymentIntent.
8
+ class CryptoPaymentRecorder
9
+ RAW_UNITS_PER_CENT = 10_000
10
+
11
+ def initialize(client:, network:, metadata: nil)
12
+ @client = client
13
+ @network = network
14
+ @metadata = metadata
15
+ end
16
+
17
+ def call(payload)
18
+ reference = payload[:receipt]&.reference
19
+ return unless reference.is_a?(String)
20
+
21
+ amount = Integer(payload.fetch(:request).fetch("amount"))
22
+ cents = (amount + (RAW_UNITS_PER_CENT / 2)) / RAW_UNITS_PER_CENT
23
+ return if cents < 1
24
+
25
+ params = {
26
+ amount: cents,
27
+ currency: "usd",
28
+ confirm: true,
29
+ payment_method_data: {type: "crypto"},
30
+ payment_method_types: ["crypto"],
31
+ payment_method_options: {
32
+ crypto: {
33
+ mode: "transaction_verification",
34
+ transaction_verification_options: {network: @network, transaction_hash: reference}
35
+ }
36
+ }
37
+ }
38
+ metadata = @metadata.is_a?(Hash) ? @metadata.transform_values(&:to_s) : {}
39
+ params[:metadata] = metadata.merge("machine_payment" => "true")
40
+
41
+ @client.v1.payment_intents.create(
42
+ params,
43
+ {stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: reference}
44
+ )
45
+ rescue => error
46
+ Kernel.warn(
47
+ "[stripe] failed to record crypto payment " \
48
+ "network=#{@network.inspect} transaction_hash=#{reference.inspect}: #{error.class}: #{error.message}"
49
+ )
50
+ nil
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -6,6 +6,7 @@ module Mpp
6
6
  module Stripe
7
7
  module Defaults
8
8
  STRIPE_API_BASE = "https://api.stripe.com"
9
+ MACHINE_PAYMENTS_API_VERSION = "2026-07-29.preview"
9
10
  DEFAULT_CURRENCY = "usd"
10
11
  DEFAULT_DECIMALS = 2
11
12
  end