mpp-rb 0.1.3 → 0.1.5
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 +63 -1
- data/lib/mpp/challenge.rb +15 -5
- data/lib/mpp/client/transport.rb +5 -4
- 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 +117 -0
- data/lib/mpp/methods/evm.rb +64 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +4 -1
- data/lib/mpp/methods/tempo/attribution.rb +3 -9
- data/lib/mpp/methods/tempo/client_method.rb +17 -6
- data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
- data/lib/mpp/methods/tempo/intents.rb +27 -7
- 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 +2 -0
- data/lib/mpp/receipt.rb +4 -3
- data/lib/mpp/server/accept_payment.rb +194 -0
- data/lib/mpp/server/compose.rb +387 -0
- data/lib/mpp/server/decorator.rb +28 -7
- data/lib/mpp/server/middleware.rb +162 -30
- data/lib/mpp/server/mpp_handler.rb +267 -33
- data/lib/mpp/server/result.rb +100 -0
- 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 +6 -3
- metadata +32 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c38b87554f2c1cbef8acfb7d2ba2197b14140500e52bff79276602c76051f8ed
|
|
4
|
+
data.tar.gz: c11b04aef7e116a9b298e3b4b4286dfc4194348b6bd2aca60b1d3f235911ac25
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 795895e5b9b8a768d96468c7f34b468b9a205e694c2b212dc1fca00ecfd2e5906c95ec95455ac79432f1ba95b21a91ba31cd71ccecbb1dbc1226a3cf274ccbf8
|
|
7
|
+
data.tar.gz: 81855362a29e248846445b3cebb0a1bce2a4f2cc2164c49e462ac923ade2fc1d30ef527786579313ffe270d02c89ec8b32a0dd0c09a74403bc69820de112ee10
|
data/README.md
CHANGED
|
@@ -49,6 +49,61 @@ else
|
|
|
49
49
|
end
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
### Multiple methods
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
server = Mpp.create(methods: [tempo, evm, stripe])
|
|
56
|
+
|
|
57
|
+
paid = server.compose(
|
|
58
|
+
[tempo, {amount: "0.01"}],
|
|
59
|
+
[evm, {amount: "0.01"}],
|
|
60
|
+
[stripe, {amount: "0.01", currency: "usd"}]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
result = paid.call(
|
|
64
|
+
authorization: env["HTTP_AUTHORIZATION"],
|
|
65
|
+
payment_signature: env["HTTP_PAYMENT_SIGNATURE"],
|
|
66
|
+
accept_payment: env["HTTP_ACCEPT_PAYMENT"],
|
|
67
|
+
url: request.url,
|
|
68
|
+
http_method: request.request_method
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if result.payment_required?
|
|
72
|
+
resp = result.to_response
|
|
73
|
+
else
|
|
74
|
+
credential, receipt = result.payment
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`evm.charge` additionally emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` (x402 v2 exact) when a facilitator is configured:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# Public / testnet facilitator
|
|
82
|
+
x402: {facilitator: "https://x402.org/facilitator"}
|
|
83
|
+
|
|
84
|
+
# Per-request headers (bearer token, CDP JWT, etc.)
|
|
85
|
+
x402: {facilitator: {url: facilitator_url, headers: -> { {"Authorization" => "Bearer #{token}"} }}}
|
|
86
|
+
|
|
87
|
+
# The proc may take the request path (`/verify`, `/settle`) when headers differ per call
|
|
88
|
+
x402: {facilitator: {url: cdp_url, headers: ->(path) { cdp_headers(path) }}}
|
|
89
|
+
|
|
90
|
+
# Any client with #verify / #settle
|
|
91
|
+
x402: {facilitator: cdp_client}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Tempo charge can sponsor gas through a hosted fee payer, or skip local RPC by sending credentials to a Tempo API-compatible relay. Both use the same `{url:, headers:}` shape as the x402 facilitator:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# Hosted fee payer (JSON-RPC eth_signRawTransaction)
|
|
98
|
+
fee_payer: {url: sponsor_url, headers: -> { {"Authorization" => "Bearer #{token}"} }}
|
|
99
|
+
|
|
100
|
+
# Local co-sign
|
|
101
|
+
fee_payer: Mpp::Methods::Tempo::Account.from_key(ENV.fetch("FEE_PAYER_KEY"))
|
|
102
|
+
|
|
103
|
+
# Relay (POST /v1/mpp/validate then /v1/mpp/broadcast)
|
|
104
|
+
relay: {url: "https://api.tempo.xyz", headers: -> { {"tempo-api-key" => ENV.fetch("TEMPO_API_KEY")} }}
|
|
105
|
+
```
|
|
106
|
+
|
|
52
107
|
### Client
|
|
53
108
|
|
|
54
109
|
```ruby
|
|
@@ -122,6 +177,10 @@ env["mpp.charge"] = { amount: "0.50", description: "Paid endpoint" }
|
|
|
122
177
|
|---------|-------------|
|
|
123
178
|
| [tempo_charge](./examples/tempo_charge/) | Tempo testnet payments via Sinatra |
|
|
124
179
|
| [stripe_charge](./examples/stripe_charge/) | Stripe payments via Shared Payment Tokens |
|
|
180
|
+
| [compose](./examples/compose/) | Tempo + Base USDC + Stripe SPTs on one endpoint |
|
|
181
|
+
| [evm_x402](./examples/evm_x402/) | EVM charge with x402 exact compatibility |
|
|
182
|
+
| [tempo_feepayer](./examples/tempo_feepayer/) | Tempo charge with a hosted fee-payer `{url:, headers:}` |
|
|
183
|
+
| [tempo_relay](./examples/tempo_relay/) | Tempo charge delegated to an MPP relay `{url:, headers:}` |
|
|
125
184
|
|
|
126
185
|
Each example is a standalone Sinatra app with `/free` and `/paid` endpoints. To run one:
|
|
127
186
|
|
|
@@ -143,8 +202,11 @@ npx mppx http://localhost:4567/paid
|
|
|
143
202
|
|--------|---------------|---------------|
|
|
144
203
|
| Tempo | Yes | Yes |
|
|
145
204
|
| Stripe | Yes | Yes |
|
|
205
|
+
| EVM (`evm.charge`, x402 exact) | No | Yes |
|
|
206
|
+
|
|
207
|
+
Tempo charge transaction construction is implemented directly in Ruby. Runtime dependency: `keccak` (Tempo attribution memos). Optional dependencies: `eth` (account signing, EIP-3009 recovery) and `rlp` (fee payer envelope).
|
|
146
208
|
|
|
147
|
-
|
|
209
|
+
`Mpp.create` accepts a single `method:` (unchanged) or `methods:` to register several payment methods. `server.compose` presents every method as multiple `WWW-Authenticate` challenges; `evm.charge` also emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` when a facilitator is configured. The Ruby HTTP client does not yet sign EVM or x402 credentials.
|
|
148
210
|
|
|
149
211
|
## Protocol
|
|
150
212
|
|
data/lib/mpp/challenge.rb
CHANGED
|
@@ -58,19 +58,29 @@ module Mpp
|
|
|
58
58
|
Mpp::Parsing.parse_www_authenticate(header)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
|
|
61
|
+
# Split a merged WWW-Authenticate header value into the individual
|
|
62
|
+
# `Payment ...` challenge chunks it contains, handling RFC 9110 §11.6.1
|
|
63
|
+
# comma-separated authentication schemes. Returns the raw chunk strings so
|
|
64
|
+
# callers can parse each challenge independently; a malformed chunk then
|
|
65
|
+
# does not prevent the remaining chunks from being considered.
|
|
66
|
+
def self.www_authenticate_chunks(header)
|
|
64
67
|
payment_scheme_indices(header).map do |start_idx|
|
|
65
68
|
# End each chunk at the next scheme boundary of any kind, so an
|
|
66
69
|
# interleaved non-Payment scheme (e.g. "Payment ..., Bearer ...,
|
|
67
70
|
# Payment ...") is not folded into the preceding Payment challenge.
|
|
68
71
|
end_idx = next_auth_scheme_index(header, start_idx + "Payment".length) || header.length
|
|
69
|
-
|
|
70
|
-
from_www_authenticate(chunk)
|
|
72
|
+
T.must(header[start_idx...end_idx]).sub(/,\s*$/, "")
|
|
71
73
|
end
|
|
72
74
|
end
|
|
73
75
|
|
|
76
|
+
# Parse every Payment challenge from a merged WWW-Authenticate header.
|
|
77
|
+
# Raises Mpp::ParseError on the first malformed chunk; callers that need to
|
|
78
|
+
# tolerate a malformed challenge among valid ones should iterate
|
|
79
|
+
# `www_authenticate_chunks` and parse each chunk independently instead.
|
|
80
|
+
def self.from_www_authenticate_list(header)
|
|
81
|
+
www_authenticate_chunks(header).map { |chunk| from_www_authenticate(chunk) }
|
|
82
|
+
end
|
|
83
|
+
|
|
74
84
|
def self.payment_scheme_indices(header)
|
|
75
85
|
indices = []
|
|
76
86
|
each_auth_scheme_index(header) do |index, scheme|
|
data/lib/mpp/client/transport.rb
CHANGED
|
@@ -205,12 +205,13 @@ module Mpp
|
|
|
205
205
|
sig { params(www_auth_headers: T.untyped, input: T.untyped, response: T.untyped).returns(T::Array[T.untyped]) }
|
|
206
206
|
def find_matching_challenge(www_auth_headers, input: nil, response: nil)
|
|
207
207
|
www_auth_headers.each do |header|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
begin
|
|
211
|
-
parsed = Mpp::Challenge.from_www_authenticate(header)
|
|
208
|
+
Mpp::Challenge.www_authenticate_chunks(header).each do |chunk|
|
|
209
|
+
parsed = Mpp::Challenge.from_www_authenticate(chunk)
|
|
212
210
|
return [parsed, @methods[parsed.method]] if @methods.key?(parsed.method)
|
|
213
211
|
rescue Mpp::ParseError => e
|
|
212
|
+
# Skip a malformed challenge but keep scanning the rest: a bad chunk
|
|
213
|
+
# earlier in a merged value must not hide a supported challenge that
|
|
214
|
+
# follows it.
|
|
214
215
|
if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
|
|
215
216
|
@events.emit(Mpp::Events::PAYMENT_FAILED, {
|
|
216
217
|
error: e,
|
data/lib/mpp/errors.rb
CHANGED
|
@@ -30,6 +30,7 @@ module Mpp
|
|
|
30
30
|
end
|
|
31
31
|
subclass.instance_variable_set(:@title, to_title(name)) unless subclass.instance_variable_defined?(:@title)
|
|
32
32
|
subclass.instance_variable_set(:@status, 402) unless subclass.instance_variable_defined?(:@status)
|
|
33
|
+
subclass.instance_variable_set(:@hint, nil) unless subclass.instance_variable_defined?(:@hint)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
class << self
|
|
@@ -44,6 +45,9 @@ module Mpp
|
|
|
44
45
|
sig { returns(T.nilable(String)) }
|
|
45
46
|
attr_reader :title
|
|
46
47
|
|
|
48
|
+
sig { returns(T.nilable(String)) }
|
|
49
|
+
attr_reader :hint
|
|
50
|
+
|
|
47
51
|
private
|
|
48
52
|
|
|
49
53
|
sig { params(name: String).returns(String) }
|
|
@@ -60,6 +64,7 @@ module Mpp
|
|
|
60
64
|
@status = T.let(402, Integer)
|
|
61
65
|
@type = T.let("#{BASE_URI}/payment-error", String)
|
|
62
66
|
@title = T.let("Payment Error", String)
|
|
67
|
+
@hint = T.let(nil, T.nilable(String))
|
|
63
68
|
|
|
64
69
|
sig { returns(T.untyped) }
|
|
65
70
|
def status = self.class.status
|
|
@@ -67,6 +72,8 @@ module Mpp
|
|
|
67
72
|
def type = self.class.type
|
|
68
73
|
sig { returns(T.untyped) }
|
|
69
74
|
def title = self.class.title
|
|
75
|
+
sig { returns(T.untyped) }
|
|
76
|
+
def hint = self.class.hint
|
|
70
77
|
|
|
71
78
|
# Convert to RFC 9457 Problem Details format.
|
|
72
79
|
sig { params(challenge_id: T.untyped).returns(T::Hash[T.untyped, T.untyped]) }
|
|
@@ -77,14 +84,26 @@ module Mpp
|
|
|
77
84
|
"status" => status,
|
|
78
85
|
"detail" => message
|
|
79
86
|
}
|
|
87
|
+
details["hint"] = hint if hint
|
|
80
88
|
details["challengeId"] = challenge_id if challenge_id
|
|
81
89
|
details
|
|
82
90
|
end
|
|
83
91
|
end
|
|
84
92
|
|
|
93
|
+
# Default hint messages for payment errors.
|
|
94
|
+
module Hints
|
|
95
|
+
PAYMENT_REQUIRED = "Use a supported wallet to pay for this resource using one of the supported " \
|
|
96
|
+
"payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md"
|
|
97
|
+
MALFORMED_CREDENTIAL = "Use a supported wallet to construct valid credentials for one of the supported " \
|
|
98
|
+
"payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md"
|
|
99
|
+
METHOD_UNSUPPORTED = PAYMENT_REQUIRED
|
|
100
|
+
end
|
|
101
|
+
|
|
85
102
|
class PaymentRequiredError < PaymentError
|
|
86
103
|
extend T::Sig
|
|
87
104
|
|
|
105
|
+
@hint = T.let(Hints::PAYMENT_REQUIRED, T.nilable(String))
|
|
106
|
+
|
|
88
107
|
sig { params(realm: T.untyped, description: T.untyped).void }
|
|
89
108
|
def initialize(realm: nil, description: nil)
|
|
90
109
|
parts = ["Payment is required"]
|
|
@@ -97,6 +116,8 @@ module Mpp
|
|
|
97
116
|
class MalformedCredentialError < PaymentError
|
|
98
117
|
extend T::Sig
|
|
99
118
|
|
|
119
|
+
@hint = T.let(Hints::MALFORMED_CREDENTIAL, T.nilable(String))
|
|
120
|
+
|
|
100
121
|
sig { params(reason: T.untyped).void }
|
|
101
122
|
def initialize(reason: nil)
|
|
102
123
|
msg = reason ? "Credential is malformed: #{reason}." : "Credential is malformed."
|
|
@@ -173,6 +194,7 @@ module Mpp
|
|
|
173
194
|
@status = T.let(400, Integer)
|
|
174
195
|
@type = T.let("#{BASE_URI}/method-unsupported", String)
|
|
175
196
|
@title = T.let("Method Unsupported", String)
|
|
197
|
+
@hint = T.let(Hints::METHOD_UNSUPPORTED, T.nilable(String))
|
|
176
198
|
|
|
177
199
|
sig { params(method: T.untyped).void }
|
|
178
200
|
def initialize(method: nil)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Http
|
|
6
|
+
# Shared `{url:, headers:}` helpers used by the x402 facilitator and Tempo
|
|
7
|
+
# fee-payer and relay HTTP clients.
|
|
8
|
+
#
|
|
9
|
+
# `headers` may be:
|
|
10
|
+
# * a Hash of static header names/values
|
|
11
|
+
# * a zero-arity proc returning that Hash
|
|
12
|
+
# * a proc receiving the request path (or JSON-RPC method) and returning
|
|
13
|
+
# that Hash
|
|
14
|
+
# * a nested Hash keyed by operation (`"verify"`, `"broadcast"`, …)
|
|
15
|
+
module Headers
|
|
16
|
+
extend T::Sig
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
sig { params(url: T.untyped).returns(String) }
|
|
21
|
+
def normalize_base_url(url)
|
|
22
|
+
base = url.to_s
|
|
23
|
+
base = base.chomp("/") while base.end_with?("/")
|
|
24
|
+
base
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(config: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
28
|
+
def symbolize(config)
|
|
29
|
+
config.each_with_object({}) do |(key, value), acc|
|
|
30
|
+
acc[key.to_sym] = value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(headers: T.nilable(T::Hash[T.untyped, T.untyped])).returns(T::Hash[String, String]) }
|
|
35
|
+
def stringify(headers)
|
|
36
|
+
return {} if headers.nil?
|
|
37
|
+
|
|
38
|
+
headers.each_with_object({}) do |(key, value), acc|
|
|
39
|
+
acc[key.to_s] = value.to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(source: T.untyped, path: String).returns(T::Hash[String, String]) }
|
|
44
|
+
def resolve(source, path)
|
|
45
|
+
return {} if source.nil?
|
|
46
|
+
|
|
47
|
+
if source.respond_to?(:call)
|
|
48
|
+
arity = source.respond_to?(:arity) ? source.arity : 1
|
|
49
|
+
source = (arity == 0) ? source.call : source.call(path)
|
|
50
|
+
end
|
|
51
|
+
return {} unless source.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
operation = path.to_s.delete_prefix("/")
|
|
54
|
+
last = T.let(operation.split("/").last, T.nilable(String))
|
|
55
|
+
keyed = source[operation] || source[operation.to_sym]
|
|
56
|
+
keyed ||= source[last] || source[last.to_sym] if last && last != operation
|
|
57
|
+
stringify(keyed.is_a?(Hash) ? keyed : source)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/mpp/http.rb
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Evm
|
|
7
|
+
# Known EIP-3009 assets plus helpers to resolve a currency config.
|
|
8
|
+
module Assets
|
|
9
|
+
Asset = Struct.new(:address, :decimals, :chain_id, :name, :version, keyword_init: true) do
|
|
10
|
+
def network
|
|
11
|
+
"eip155:#{chain_id}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.hex_address(*chunks)
|
|
16
|
+
"0x#{chunks.join}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
BASE_USDC = Asset.new(
|
|
20
|
+
address: hex_address("833589fCD6eDb6E08f", "4c7C32D4f71b54bdA02913"),
|
|
21
|
+
decimals: 6,
|
|
22
|
+
chain_id: 8453,
|
|
23
|
+
name: "USD Coin",
|
|
24
|
+
version: "2"
|
|
25
|
+
)
|
|
26
|
+
BASE_SEPOLIA_USDC = Asset.new(
|
|
27
|
+
address: hex_address("036CbD53842c542663", "4e7929541eC2318f3dCF7e"),
|
|
28
|
+
decimals: 6,
|
|
29
|
+
chain_id: 84532,
|
|
30
|
+
name: "USDC",
|
|
31
|
+
version: "2"
|
|
32
|
+
)
|
|
33
|
+
CELO_USDC = Asset.new(
|
|
34
|
+
address: hex_address("cebA9300f2b948710d", "2653dD7B07f33A8B32118C"),
|
|
35
|
+
decimals: 6,
|
|
36
|
+
chain_id: 42220,
|
|
37
|
+
name: "USDC",
|
|
38
|
+
version: "2"
|
|
39
|
+
)
|
|
40
|
+
CELO_USDT = Asset.new(
|
|
41
|
+
address: hex_address("48065fbBE25f71C928", "2ddf5e1cD6D6A887483D5e"),
|
|
42
|
+
decimals: 6,
|
|
43
|
+
chain_id: 42220,
|
|
44
|
+
name: "Tether USD",
|
|
45
|
+
version: "1"
|
|
46
|
+
)
|
|
47
|
+
CELO_SEPOLIA_USDC = Asset.new(
|
|
48
|
+
address: hex_address("01C5C0122039549AD1", "493B8220cABEdD739BC44E"),
|
|
49
|
+
decimals: 6,
|
|
50
|
+
chain_id: 11142220,
|
|
51
|
+
name: "USDC",
|
|
52
|
+
version: "2"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
private_class_method :hex_address
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
def resolve(currency, authorization: nil, chain_id: nil, decimals: nil)
|
|
60
|
+
if currency.is_a?(Asset)
|
|
61
|
+
auth = authorization_hash(authorization) || {"name" => currency.name, "version" => currency.version}
|
|
62
|
+
return {
|
|
63
|
+
address: checksum(currency.address),
|
|
64
|
+
chain_id: chain_id || currency.chain_id,
|
|
65
|
+
decimals: decimals || currency.decimals,
|
|
66
|
+
authorization: auth
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
address = currency.to_s
|
|
71
|
+
Kernel.raise ArgumentError, "EVM currency must be a known asset or 0x-prefixed address" unless address.match?(/\A0x[a-fA-F0-9]{40}\z/)
|
|
72
|
+
Kernel.raise ArgumentError, "EVM authorization requires `chain_id`." if chain_id.nil?
|
|
73
|
+
Kernel.raise ArgumentError, "EVM authorization requires `decimals`." if decimals.nil?
|
|
74
|
+
|
|
75
|
+
auth = authorization_hash(authorization)
|
|
76
|
+
Kernel.raise ArgumentError, "EVM authorization requires `authorization` metadata." unless auth
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
address: checksum(address),
|
|
80
|
+
chain_id: Kernel.Integer(chain_id),
|
|
81
|
+
decimals: Kernel.Integer(decimals),
|
|
82
|
+
authorization: auth
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def checksum(address)
|
|
87
|
+
Authorization.checksum_address(address)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def authorization_hash(authorization)
|
|
91
|
+
return nil if authorization.nil?
|
|
92
|
+
|
|
93
|
+
name = authorization[:name] || authorization["name"]
|
|
94
|
+
version = authorization[:version] || authorization["version"]
|
|
95
|
+
Kernel.raise ArgumentError, "authorization requires name and version" if name.to_s.empty? || version.to_s.empty?
|
|
96
|
+
|
|
97
|
+
{"name" => name.to_s, "version" => version.to_s}
|
|
98
|
+
end
|
|
99
|
+
private_class_method :authorization_hash
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -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
|