remitmd 0.1.5 → 0.1.7
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 +3 -14
- data/lib/remitmd/a2a.rb +188 -2
- data/lib/remitmd/errors.rb +52 -17
- data/lib/remitmd/http.rb +16 -8
- data/lib/remitmd/keccak.rb +1 -1
- data/lib/remitmd/mock.rb +8 -7
- data/lib/remitmd/models.rb +88 -58
- data/lib/remitmd/wallet.rb +92 -85
- data/lib/remitmd/x402_client.rb +201 -0
- data/lib/remitmd/x402_paywall.rb +170 -0
- data/lib/remitmd.rb +4 -1
- metadata +4 -2
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "base64"
|
|
8
|
+
|
|
9
|
+
module Remitmd
|
|
10
|
+
# Raised when an x402 payment amount exceeds the configured auto-pay limit.
|
|
11
|
+
class AllowanceExceededError < RemitError
|
|
12
|
+
attr_reader :amount_usdc, :limit_usdc
|
|
13
|
+
|
|
14
|
+
def initialize(amount_usdc, limit_usdc)
|
|
15
|
+
@amount_usdc = amount_usdc
|
|
16
|
+
@limit_usdc = limit_usdc
|
|
17
|
+
super(
|
|
18
|
+
"ALLOWANCE_EXCEEDED",
|
|
19
|
+
"x402 payment #{format("%.6f", amount_usdc)} USDC exceeds auto-pay limit #{format("%.6f", limit_usdc)} USDC"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# x402 client — fetch wrapper that auto-pays HTTP 402 Payment Required responses.
|
|
25
|
+
#
|
|
26
|
+
# On receiving a 402, the client:
|
|
27
|
+
# 1. Decodes the PAYMENT-REQUIRED header (base64 JSON)
|
|
28
|
+
# 2. Checks the amount is within max_auto_pay_usdc
|
|
29
|
+
# 3. Builds and signs an EIP-3009 transferWithAuthorization
|
|
30
|
+
# 4. Base64-encodes the PAYMENT-SIGNATURE header
|
|
31
|
+
# 5. Retries the original request with payment attached
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# signer = Remitmd::PrivateKeySigner.new("0x...")
|
|
35
|
+
# client = Remitmd::X402Client.new(wallet: signer)
|
|
36
|
+
# response = client.fetch("https://api.provider.com/v1/data")
|
|
37
|
+
#
|
|
38
|
+
class X402Client
|
|
39
|
+
attr_reader :last_payment
|
|
40
|
+
|
|
41
|
+
# @param wallet [#sign, #address] a signer that can sign EIP-712 digests
|
|
42
|
+
# @param max_auto_pay_usdc [Float] maximum USDC amount to auto-pay per request (default: 0.10)
|
|
43
|
+
def initialize(wallet:, max_auto_pay_usdc: 0.10)
|
|
44
|
+
@wallet = wallet
|
|
45
|
+
@max_auto_pay_usdc = max_auto_pay_usdc
|
|
46
|
+
@last_payment = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Make an HTTP request, auto-paying any 402 responses within the configured limit.
|
|
50
|
+
#
|
|
51
|
+
# @param url [String] the URL to fetch
|
|
52
|
+
# @param method [Symbol] HTTP method (:get, :post, etc.)
|
|
53
|
+
# @param headers [Hash] additional request headers
|
|
54
|
+
# @param body [String, nil] request body (for POST/PUT)
|
|
55
|
+
# @return [Net::HTTPResponse]
|
|
56
|
+
def fetch(url, method: :get, headers: {}, body: nil)
|
|
57
|
+
uri = URI(url)
|
|
58
|
+
resp = make_request(uri, method, headers, body)
|
|
59
|
+
|
|
60
|
+
if resp.code.to_i == 402
|
|
61
|
+
handle402(uri, resp, method, headers, body)
|
|
62
|
+
else
|
|
63
|
+
resp
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def make_request(uri, method, headers, body)
|
|
70
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
71
|
+
http.use_ssl = uri.scheme == "https"
|
|
72
|
+
http.read_timeout = 15
|
|
73
|
+
|
|
74
|
+
req = case method
|
|
75
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
76
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
77
|
+
when :put then Net::HTTP::Put.new(uri)
|
|
78
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
79
|
+
else Net::HTTP::Get.new(uri)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
headers.each { |k, v| req[k.to_s] = v.to_s }
|
|
83
|
+
req.body = body if body
|
|
84
|
+
http.request(req)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle402(uri, response, method, headers, body)
|
|
88
|
+
# 1. Decode PAYMENT-REQUIRED header.
|
|
89
|
+
raw = response["payment-required"] || response["PAYMENT-REQUIRED"]
|
|
90
|
+
raise RemitError.new("SERVER_ERROR", "402 response missing PAYMENT-REQUIRED header") unless raw
|
|
91
|
+
|
|
92
|
+
required = JSON.parse(Base64.decode64(raw))
|
|
93
|
+
|
|
94
|
+
# 2. Only "exact" scheme is supported.
|
|
95
|
+
unless required["scheme"] == "exact"
|
|
96
|
+
raise RemitError.new("SERVER_ERROR", "Unsupported x402 scheme: #{required["scheme"]}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Store for caller inspection (V2 fields: resource, description, mimeType).
|
|
100
|
+
@last_payment = required
|
|
101
|
+
|
|
102
|
+
# 3. Check auto-pay limit.
|
|
103
|
+
amount_base_units = required["amount"].to_i
|
|
104
|
+
amount_usdc = amount_base_units / 1_000_000.0
|
|
105
|
+
if amount_usdc > @max_auto_pay_usdc
|
|
106
|
+
raise AllowanceExceededError.new(amount_usdc, @max_auto_pay_usdc)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# 4. Parse chainId from CAIP-2 network string (e.g. "eip155:84532" -> 84532).
|
|
110
|
+
chain_id = required["network"].split(":")[1].to_i
|
|
111
|
+
|
|
112
|
+
# 5. Build EIP-3009 authorization fields.
|
|
113
|
+
now_secs = Time.now.to_i
|
|
114
|
+
valid_before = now_secs + (required["maxTimeoutSeconds"] || 60).to_i
|
|
115
|
+
nonce_bytes = SecureRandom.bytes(32)
|
|
116
|
+
nonce_hex = "0x#{nonce_bytes.unpack1("H*")}"
|
|
117
|
+
|
|
118
|
+
# 6. Sign EIP-712 typed data for TransferWithAuthorization.
|
|
119
|
+
digest = eip3009_digest(
|
|
120
|
+
chain_id: chain_id,
|
|
121
|
+
asset: required["asset"],
|
|
122
|
+
from: @wallet.address,
|
|
123
|
+
to: required["payTo"],
|
|
124
|
+
value: amount_base_units,
|
|
125
|
+
valid_after: 0,
|
|
126
|
+
valid_before: valid_before,
|
|
127
|
+
nonce_bytes: nonce_bytes
|
|
128
|
+
)
|
|
129
|
+
signature = @wallet.sign(digest)
|
|
130
|
+
|
|
131
|
+
# 7. Build PAYMENT-SIGNATURE JSON payload.
|
|
132
|
+
payment_payload = {
|
|
133
|
+
scheme: required["scheme"],
|
|
134
|
+
network: required["network"],
|
|
135
|
+
x402Version: 1,
|
|
136
|
+
payload: {
|
|
137
|
+
signature: signature,
|
|
138
|
+
authorization: {
|
|
139
|
+
from: @wallet.address,
|
|
140
|
+
to: required["payTo"],
|
|
141
|
+
value: required["amount"],
|
|
142
|
+
validAfter: "0",
|
|
143
|
+
validBefore: valid_before.to_s,
|
|
144
|
+
nonce: nonce_hex,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
payment_header = Base64.strict_encode64(JSON.generate(payment_payload))
|
|
149
|
+
|
|
150
|
+
# 8. Retry with PAYMENT-SIGNATURE header.
|
|
151
|
+
new_headers = headers.merge("PAYMENT-SIGNATURE" => payment_header)
|
|
152
|
+
make_request(uri, method, new_headers, body)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Compute the EIP-712 hash for EIP-3009 TransferWithAuthorization.
|
|
156
|
+
def eip3009_digest(chain_id:, asset:, from:, to:, value:, valid_after:, valid_before:, nonce_bytes:)
|
|
157
|
+
# Domain separator: USD Coin / version 2
|
|
158
|
+
domain_type_hash = keccak256(
|
|
159
|
+
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
|
|
160
|
+
)
|
|
161
|
+
name_hash = keccak256("USD Coin")
|
|
162
|
+
version_hash = keccak256("2")
|
|
163
|
+
chain_id_enc = abi_uint256(chain_id)
|
|
164
|
+
contract_enc = abi_address(asset)
|
|
165
|
+
|
|
166
|
+
domain_data = domain_type_hash + name_hash + version_hash + chain_id_enc + contract_enc
|
|
167
|
+
domain_separator = keccak256(domain_data)
|
|
168
|
+
|
|
169
|
+
# TransferWithAuthorization struct hash
|
|
170
|
+
type_hash = keccak256(
|
|
171
|
+
"TransferWithAuthorization(address from,address to,uint256 value," \
|
|
172
|
+
"uint256 validAfter,uint256 validBefore,bytes32 nonce)"
|
|
173
|
+
)
|
|
174
|
+
struct_data = type_hash +
|
|
175
|
+
abi_address(from) +
|
|
176
|
+
abi_address(to) +
|
|
177
|
+
abi_uint256(value) +
|
|
178
|
+
abi_uint256(valid_after) +
|
|
179
|
+
abi_uint256(valid_before) +
|
|
180
|
+
nonce_bytes
|
|
181
|
+
|
|
182
|
+
struct_hash = keccak256(struct_data)
|
|
183
|
+
|
|
184
|
+
# Final EIP-712 hash
|
|
185
|
+
keccak256("\x19\x01" + domain_separator + struct_hash)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def keccak256(data)
|
|
189
|
+
Remitmd::Keccak.digest(data.b)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def abi_uint256(value)
|
|
193
|
+
[value.to_i.to_s(16).rjust(64, "0")].pack("H*")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def abi_address(addr)
|
|
197
|
+
hex = addr.to_s.delete_prefix("0x").rjust(64, "0")
|
|
198
|
+
[hex].pack("H*")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "base64"
|
|
7
|
+
|
|
8
|
+
module Remitmd
|
|
9
|
+
# x402 paywall for service providers — gate HTTP endpoints behind payments.
|
|
10
|
+
#
|
|
11
|
+
# Providers use this class to:
|
|
12
|
+
# - Return HTTP 402 responses with properly formatted PAYMENT-REQUIRED headers
|
|
13
|
+
# - Verify incoming PAYMENT-SIGNATURE headers against the remit.md facilitator
|
|
14
|
+
#
|
|
15
|
+
# @example Rack middleware
|
|
16
|
+
# paywall = Remitmd::X402Paywall.new(
|
|
17
|
+
# wallet_address: "0xYourProviderWallet",
|
|
18
|
+
# amount_usdc: 0.001,
|
|
19
|
+
# network: "eip155:84532",
|
|
20
|
+
# asset: "0x2d846325766921935f37d5b4478196d3ef93707c"
|
|
21
|
+
# )
|
|
22
|
+
# use paywall.rack_middleware
|
|
23
|
+
#
|
|
24
|
+
class X402Paywall
|
|
25
|
+
# @param wallet_address [String] provider's checksummed Ethereum address (the payTo field)
|
|
26
|
+
# @param amount_usdc [Float] price per request in USDC (e.g. 0.001)
|
|
27
|
+
# @param network [String] CAIP-2 network string (e.g. "eip155:84532")
|
|
28
|
+
# @param asset [String] USDC contract address on the target network
|
|
29
|
+
# @param facilitator_url [String] base URL of the remit.md facilitator
|
|
30
|
+
# @param facilitator_token [String] bearer JWT for authenticating calls to /api/v1/x402/verify
|
|
31
|
+
# @param max_timeout_seconds [Integer] how long the payment authorization remains valid
|
|
32
|
+
# @param resource [String, nil] V2 — URL or path of the resource being protected
|
|
33
|
+
# @param description [String, nil] V2 — human-readable description
|
|
34
|
+
# @param mime_type [String, nil] V2 — MIME type of the resource
|
|
35
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
36
|
+
wallet_address:,
|
|
37
|
+
amount_usdc:,
|
|
38
|
+
network:,
|
|
39
|
+
asset:,
|
|
40
|
+
facilitator_url: "https://remit.md",
|
|
41
|
+
facilitator_token: "",
|
|
42
|
+
max_timeout_seconds: 60,
|
|
43
|
+
resource: nil,
|
|
44
|
+
description: nil,
|
|
45
|
+
mime_type: nil
|
|
46
|
+
)
|
|
47
|
+
@wallet_address = wallet_address
|
|
48
|
+
@amount_base_units = (amount_usdc * 1_000_000).round.to_s
|
|
49
|
+
@network = network
|
|
50
|
+
@asset = asset
|
|
51
|
+
@facilitator_url = facilitator_url.chomp("/")
|
|
52
|
+
@facilitator_token = facilitator_token
|
|
53
|
+
@max_timeout_seconds = max_timeout_seconds
|
|
54
|
+
@resource = resource
|
|
55
|
+
@description = description
|
|
56
|
+
@mime_type = mime_type
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Return the base64-encoded JSON PAYMENT-REQUIRED header value.
|
|
60
|
+
# @return [String]
|
|
61
|
+
def payment_required_header
|
|
62
|
+
payload = {
|
|
63
|
+
scheme: "exact",
|
|
64
|
+
network: @network,
|
|
65
|
+
amount: @amount_base_units,
|
|
66
|
+
asset: @asset,
|
|
67
|
+
payTo: @wallet_address,
|
|
68
|
+
maxTimeoutSeconds: @max_timeout_seconds,
|
|
69
|
+
}
|
|
70
|
+
payload[:resource] = @resource if @resource
|
|
71
|
+
payload[:description] = @description if @description
|
|
72
|
+
payload[:mimeType] = @mime_type if @mime_type
|
|
73
|
+
Base64.strict_encode64(JSON.generate(payload))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check whether a PAYMENT-SIGNATURE header represents a valid payment.
|
|
77
|
+
# Calls the remit.md facilitator's /api/v1/x402/verify endpoint.
|
|
78
|
+
#
|
|
79
|
+
# @param payment_sig [String, nil] the raw header value (base64 JSON), or nil if absent
|
|
80
|
+
# @return [Hash] { is_valid: true/false, invalid_reason: String or nil }
|
|
81
|
+
def check(payment_sig)
|
|
82
|
+
return { is_valid: false } unless payment_sig
|
|
83
|
+
|
|
84
|
+
payment_payload = begin
|
|
85
|
+
JSON.parse(Base64.decode64(payment_sig))
|
|
86
|
+
rescue JSON::ParserError
|
|
87
|
+
return { is_valid: false, invalid_reason: "INVALID_PAYLOAD" }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
body = {
|
|
91
|
+
paymentPayload: payment_payload,
|
|
92
|
+
paymentRequired: payment_required_object,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
uri = URI("#{@facilitator_url}/api/v1/x402/verify")
|
|
96
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
97
|
+
http.use_ssl = uri.scheme == "https"
|
|
98
|
+
http.read_timeout = 10
|
|
99
|
+
|
|
100
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
101
|
+
req["Content-Type"] = "application/json"
|
|
102
|
+
req["Authorization"] = "Bearer #{@facilitator_token}" unless @facilitator_token.empty?
|
|
103
|
+
req.body = JSON.generate(body)
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
resp = http.request(req)
|
|
107
|
+
unless resp.is_a?(Net::HTTPSuccess)
|
|
108
|
+
return { is_valid: false, invalid_reason: "FACILITATOR_ERROR" }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
data = JSON.parse(resp.body)
|
|
112
|
+
rescue StandardError
|
|
113
|
+
return { is_valid: false, invalid_reason: "FACILITATOR_ERROR" }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
is_valid: data["isValid"] == true,
|
|
118
|
+
invalid_reason: data["invalidReason"],
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Rack middleware adapter.
|
|
123
|
+
#
|
|
124
|
+
# @example
|
|
125
|
+
# use paywall.rack_middleware
|
|
126
|
+
#
|
|
127
|
+
# @return [Class] a Rack middleware class
|
|
128
|
+
def rack_middleware
|
|
129
|
+
paywall = self
|
|
130
|
+
Class.new do
|
|
131
|
+
define_method(:initialize) do |app|
|
|
132
|
+
@app = app
|
|
133
|
+
@paywall = paywall
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
define_method(:call) do |env|
|
|
137
|
+
payment_sig = env["HTTP_PAYMENT_SIGNATURE"]
|
|
138
|
+
result = @paywall.check(payment_sig)
|
|
139
|
+
|
|
140
|
+
unless result[:is_valid]
|
|
141
|
+
headers = {
|
|
142
|
+
"Content-Type" => "application/json",
|
|
143
|
+
"PAYMENT-REQUIRED" => @paywall.payment_required_header,
|
|
144
|
+
}
|
|
145
|
+
body = JSON.generate({
|
|
146
|
+
error: "Payment required",
|
|
147
|
+
invalidReason: result[:invalid_reason],
|
|
148
|
+
})
|
|
149
|
+
return [402, headers, [body]]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
@app.call(env)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def payment_required_object
|
|
160
|
+
{
|
|
161
|
+
scheme: "exact",
|
|
162
|
+
network: @network,
|
|
163
|
+
amount: @amount_base_units,
|
|
164
|
+
asset: @asset,
|
|
165
|
+
payTo: @wallet_address,
|
|
166
|
+
maxTimeoutSeconds: @max_timeout_seconds,
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/remitmd.rb
CHANGED
|
@@ -7,6 +7,9 @@ require_relative "remitmd/signer"
|
|
|
7
7
|
require_relative "remitmd/http"
|
|
8
8
|
require_relative "remitmd/wallet"
|
|
9
9
|
require_relative "remitmd/mock"
|
|
10
|
+
require_relative "remitmd/a2a"
|
|
11
|
+
require_relative "remitmd/x402_client"
|
|
12
|
+
require_relative "remitmd/x402_paywall"
|
|
10
13
|
|
|
11
14
|
# remit.md Ruby SDK — universal payment protocol for AI agents.
|
|
12
15
|
#
|
|
@@ -22,5 +25,5 @@ require_relative "remitmd/mock"
|
|
|
22
25
|
# mock.was_paid?("0x0000000000000000000000000000000000000001", 1.00) # => true
|
|
23
26
|
#
|
|
24
27
|
module Remitmd
|
|
25
|
-
VERSION = "0.1.
|
|
28
|
+
VERSION = "0.1.7"
|
|
26
29
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: remitmd
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- remit.md
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -58,6 +58,8 @@ files:
|
|
|
58
58
|
- lib/remitmd/models.rb
|
|
59
59
|
- lib/remitmd/signer.rb
|
|
60
60
|
- lib/remitmd/wallet.rb
|
|
61
|
+
- lib/remitmd/x402_client.rb
|
|
62
|
+
- lib/remitmd/x402_paywall.rb
|
|
61
63
|
homepage: https://remit.md
|
|
62
64
|
licenses:
|
|
63
65
|
- MIT
|