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
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|