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.
@@ -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.5"
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.5
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-23 00:00:00.000000000 Z
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