remitmd 0.1.6 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77a3a0333d3f1afc8315ad1d523bc9adb70e74d2f5af29bc20b608233b0f67b1
4
- data.tar.gz: b04b273b6340c25f8adcc5e755e99a4f32c9e804aec91d2e28058a66f507e9e9
3
+ metadata.gz: b29ac02ca95fe1053bed848cca89111698f1744bbeb145681a76a3f9ed2d8e66
4
+ data.tar.gz: d55c4f33eaaa686fa0036e3a0566ee6382a0a544c01093a3d9887e745b2eaa01
5
5
  SHA512:
6
- metadata.gz: 3e343856eb6688f7a5bee1a10fa85ae119e9ac548eeedfb4bb95a21f4a5565d8ad4921efccfcad940f3f8a89fb7a0fd313243c7dd511f132c0540b69e05b2f82
7
- data.tar.gz: 284848e90b0c9c53bc80a07fa7a2bc90ea7f3e1afef282b10308a9fc5805b9c750392d75a6055bf152660d15a1b3948d06fddbf179f5daf2ae3b586f0e18a35a
6
+ metadata.gz: 9c00d48fc571e6975d9dfd32c679d5b9cf46205742a8a725022907eb997d42de7a61ae040c22dfafb7d5a994277d25f5dafd2e589611e849f94a70c692d71102
7
+ data.tar.gz: dff3c27cf317ce994999efd38d57f1173f0a1136da0bf4dbcdbf1ff5879a9f737bef96875802108ca130b4e46b3b94eec1cc0f52cace3152c89357018d9f9621
data/lib/remitmd/a2a.rb CHANGED
@@ -4,7 +4,7 @@ require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
6
 
7
- module RemitMd
7
+ module Remitmd
8
8
  # A2A capability extension declared in an agent card.
9
9
  AgentExtension = Data.define(:uri, :description, :required)
10
10
 
@@ -37,7 +37,7 @@ module RemitMd
37
37
 
38
38
  # Fetch and parse the A2A agent card from +base_url/.well-known/agent-card.json+.
39
39
  #
40
- # card = RemitMd::AgentCard.discover("https://remit.md")
40
+ # card = Remitmd::AgentCard.discover("https://remit.md")
41
41
  # puts card.name # => "remit.md"
42
42
  # puts card.url # => "https://remit.md/a2a"
43
43
  #
@@ -55,4 +55,190 @@ module RemitMd
55
55
  new(JSON.parse(response.body))
56
56
  end
57
57
  end
58
+
59
+ # ─── A2A task types ──────────────────────────────────────────────────────────
60
+
61
+ # Status of an A2A task.
62
+ class A2ATaskStatus
63
+ attr_reader :state, :message
64
+
65
+ def initialize(data)
66
+ @state = data["state"].to_s
67
+ @message = data["message"]
68
+ end
69
+ end
70
+
71
+ # An artifact part within an A2A artifact.
72
+ A2AArtifactPart = Data.define(:kind, :data)
73
+
74
+ # An artifact returned by an A2A task.
75
+ class A2AArtifact
76
+ attr_reader :name, :parts
77
+
78
+ def initialize(data)
79
+ @name = data["name"]
80
+ @parts = (data["parts"] || []).map do |p|
81
+ A2AArtifactPart.new(kind: p["kind"].to_s, data: p["data"] || {})
82
+ end
83
+ end
84
+ end
85
+
86
+ # An A2A task returned by message/send, tasks/get, or tasks/cancel.
87
+ class A2ATask
88
+ attr_reader :id, :status, :artifacts
89
+
90
+ def initialize(data)
91
+ @id = data["id"].to_s
92
+ @status = A2ATaskStatus.new(data["status"] || {})
93
+ @artifacts = (data["artifacts"] || []).map { |a| A2AArtifact.new(a) }
94
+ end
95
+
96
+ # Extract txHash from task artifacts, if present.
97
+ # @return [String, nil]
98
+ def tx_hash
99
+ artifacts.each do |artifact|
100
+ artifact.parts.each do |part|
101
+ tx = part.data["txHash"] if part.data.is_a?(Hash)
102
+ return tx if tx.is_a?(String)
103
+ end
104
+ end
105
+ nil
106
+ end
107
+ end
108
+
109
+ # ─── IntentMandate ──────────────────────────────────────────────────────────
110
+
111
+ # A mandate authorizing a payment intent.
112
+ class IntentMandate
113
+ attr_reader :mandate_id, :expires_at, :issuer, :max_amount, :currency
114
+
115
+ def initialize(mandate_id:, expires_at:, issuer:, max_amount:, currency: "USDC")
116
+ @mandate_id = mandate_id
117
+ @expires_at = expires_at
118
+ @issuer = issuer
119
+ @max_amount = max_amount
120
+ @currency = currency
121
+ end
122
+
123
+ def to_h
124
+ {
125
+ mandateId: @mandate_id,
126
+ expiresAt: @expires_at,
127
+ issuer: @issuer,
128
+ allowance: {
129
+ maxAmount: @max_amount,
130
+ currency: @currency,
131
+ },
132
+ }
133
+ end
134
+ end
135
+
136
+ # ─── A2A JSON-RPC client ────────────────────────────────────────────────────
137
+
138
+ # A2A JSON-RPC client — send payments and manage tasks via the A2A protocol.
139
+ #
140
+ # @example
141
+ # card = Remitmd::AgentCard.discover("https://remit.md")
142
+ # signer = Remitmd::PrivateKeySigner.new(ENV["REMITMD_KEY"])
143
+ # client = Remitmd::A2AClient.from_card(card, signer)
144
+ # task = client.send(to: "0xRecipient...", amount: 10)
145
+ # puts task.status.state
146
+ #
147
+ class A2AClient
148
+ CHAIN_IDS = {
149
+ "base" => 8453,
150
+ "base-sepolia" => 84_532,
151
+ }.freeze
152
+
153
+ # @param endpoint [String] full A2A endpoint URL from the agent card
154
+ # @param signer [#sign, #address] a signer for EIP-712 authentication
155
+ # @param chain_id [Integer] chain ID
156
+ # @param verifying_contract [String] verifying contract address
157
+ def initialize(endpoint:, signer:, chain_id:, verifying_contract: "")
158
+ parsed = URI(endpoint)
159
+ @base_url = "#{parsed.scheme}://#{parsed.host}#{parsed.port == parsed.default_port ? "" : ":#{parsed.port}"}"
160
+ @path = parsed.path.empty? ? "/a2a" : parsed.path
161
+ @transport = HttpTransport.new(
162
+ base_url: @base_url,
163
+ signer: signer,
164
+ chain_id: chain_id,
165
+ router_address: verifying_contract
166
+ )
167
+ end
168
+
169
+ # Convenience constructor from an AgentCard and a signer.
170
+ # @param card [AgentCard]
171
+ # @param signer [#sign, #address]
172
+ # @param chain [String] chain name (default: "base")
173
+ # @param verifying_contract [String]
174
+ # @return [A2AClient]
175
+ def self.from_card(card, signer, chain: "base", verifying_contract: "")
176
+ chain_id = CHAIN_IDS[chain] || CHAIN_IDS["base"]
177
+ new(
178
+ endpoint: card.url,
179
+ signer: signer,
180
+ chain_id: chain_id,
181
+ verifying_contract: verifying_contract
182
+ )
183
+ end
184
+
185
+ # Send a direct USDC payment via message/send.
186
+ # @param to [String] recipient 0x address
187
+ # @param amount [Numeric] amount in USDC
188
+ # @param memo [String] optional memo
189
+ # @param mandate [IntentMandate, nil] optional intent mandate
190
+ # @return [A2ATask]
191
+ def send(to:, amount:, memo: "", mandate: nil)
192
+ nonce = SecureRandom.hex(16)
193
+ message_id = SecureRandom.hex(16)
194
+
195
+ message = {
196
+ messageId: message_id,
197
+ role: "user",
198
+ parts: [
199
+ {
200
+ kind: "data",
201
+ data: {
202
+ model: "direct",
203
+ to: to,
204
+ amount: format("%.2f", amount),
205
+ memo: memo,
206
+ nonce: nonce,
207
+ },
208
+ },
209
+ ],
210
+ }
211
+
212
+ message[:metadata] = { mandate: mandate.to_h } if mandate
213
+
214
+ rpc("message/send", { message: message }, message_id)
215
+ end
216
+
217
+ # Fetch the current state of an A2A task by ID.
218
+ # @param task_id [String]
219
+ # @return [A2ATask]
220
+ def get_task(task_id)
221
+ rpc("tasks/get", { id: task_id }, task_id[0, 16])
222
+ end
223
+
224
+ # Cancel an in-progress A2A task.
225
+ # @param task_id [String]
226
+ # @return [A2ATask]
227
+ def cancel_task(task_id)
228
+ rpc("tasks/cancel", { id: task_id }, task_id[0, 16])
229
+ end
230
+
231
+ private
232
+
233
+ def rpc(method, params, call_id)
234
+ body = { jsonrpc: "2.0", id: call_id, method: method, params: params }
235
+ data = @transport.post(@path, body)
236
+ if data.is_a?(Hash) && data["error"]
237
+ err_msg = data["error"]["message"] || JSON.generate(data["error"])
238
+ raise RemitError.new("SERVER_ERROR", "A2A error: #{err_msg}")
239
+ end
240
+ result = data.is_a?(Hash) ? (data["result"] || data) : data
241
+ A2ATask.new(result)
242
+ end
243
+ end
58
244
  end
@@ -15,29 +15,64 @@ module Remitmd
15
15
  # puts e.doc_url # => "https://remit.md/docs/api-reference/error-codes#invalid_address"
16
16
  # end
17
17
  class RemitError < StandardError
18
- # Error code constants
19
- INVALID_ADDRESS = "INVALID_ADDRESS"
20
- INVALID_AMOUNT = "INVALID_AMOUNT"
21
- INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS"
18
+ # Error code constants — matches TS SDK (28 codes)
19
+ # Auth errors
20
+ INVALID_SIGNATURE = "INVALID_SIGNATURE"
21
+ NONCE_REUSED = "NONCE_REUSED"
22
+ TIMESTAMP_EXPIRED = "TIMESTAMP_EXPIRED"
23
+ UNAUTHORIZED = "UNAUTHORIZED"
24
+
25
+ # Balance / funds
26
+ INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"
27
+ BELOW_MINIMUM = "BELOW_MINIMUM"
28
+
29
+ # Escrow errors
22
30
  ESCROW_NOT_FOUND = "ESCROW_NOT_FOUND"
31
+ ESCROW_ALREADY_FUNDED = "ESCROW_ALREADY_FUNDED"
32
+ ESCROW_EXPIRED = "ESCROW_EXPIRED"
33
+
34
+ # Invoice errors
35
+ INVALID_INVOICE = "INVALID_INVOICE"
36
+ DUPLICATE_INVOICE = "DUPLICATE_INVOICE"
37
+ SELF_PAYMENT = "SELF_PAYMENT"
38
+ INVALID_PAYMENT_TYPE = "INVALID_PAYMENT_TYPE"
39
+
40
+ # Tab errors
41
+ TAB_DEPLETED = "TAB_DEPLETED"
42
+ TAB_EXPIRED = "TAB_EXPIRED"
23
43
  TAB_NOT_FOUND = "TAB_NOT_FOUND"
44
+
45
+ # Stream errors
24
46
  STREAM_NOT_FOUND = "STREAM_NOT_FOUND"
47
+ RATE_EXCEEDS_CAP = "RATE_EXCEEDS_CAP"
48
+
49
+ # Bounty errors
50
+ BOUNTY_EXPIRED = "BOUNTY_EXPIRED"
51
+ BOUNTY_CLAIMED = "BOUNTY_CLAIMED"
52
+ BOUNTY_MAX_ATTEMPTS = "BOUNTY_MAX_ATTEMPTS"
25
53
  BOUNTY_NOT_FOUND = "BOUNTY_NOT_FOUND"
26
- DEPOSIT_NOT_FOUND = "DEPOSIT_NOT_FOUND"
27
- UNAUTHORIZED = "UNAUTHORIZED"
28
- RATE_LIMITED = "RATE_LIMITED"
54
+
55
+ # Chain errors
56
+ CHAIN_MISMATCH = "CHAIN_MISMATCH"
57
+ CHAIN_UNSUPPORTED = "CHAIN_UNSUPPORTED"
58
+
59
+ # Rate limiting
60
+ RATE_LIMITED = "RATE_LIMITED"
61
+
62
+ # Cancellation errors
63
+ CANCEL_BLOCKED_CLAIM_START = "CANCEL_BLOCKED_CLAIM_START"
64
+ CANCEL_BLOCKED_EVIDENCE = "CANCEL_BLOCKED_EVIDENCE"
65
+
66
+ # Protocol errors
67
+ VERSION_MISMATCH = "VERSION_MISMATCH"
29
68
  NETWORK_ERROR = "NETWORK_ERROR"
69
+
70
+ # Legacy aliases (kept for backward compat within the SDK)
71
+ INVALID_ADDRESS = "INVALID_ADDRESS"
72
+ INVALID_AMOUNT = "INVALID_AMOUNT"
73
+ INSUFFICIENT_FUNDS = INSUFFICIENT_BALANCE
30
74
  SERVER_ERROR = "SERVER_ERROR"
31
- NONCE_REUSED = "NONCE_REUSED"
32
- SIGNATURE_INVALID = "SIGNATURE_INVALID"
33
- ESCROW_ALREADY_COMPLETED = "ESCROW_ALREADY_COMPLETED"
34
- ESCROW_EXPIRED = "ESCROW_EXPIRED"
35
- TAB_LIMIT_EXCEEDED = "TAB_LIMIT_EXCEEDED"
36
- BOUNTY_ALREADY_AWARDED = "BOUNTY_ALREADY_AWARDED"
37
- STREAM_NOT_ACTIVE = "STREAM_NOT_ACTIVE"
38
- DEPOSIT_ALREADY_RESOLVED = "DEPOSIT_ALREADY_RESOLVED"
39
- USDC_TRANSFER_FAILED = "USDC_TRANSFER_FAILED"
40
- CHAIN_UNAVAILABLE = "CHAIN_UNAVAILABLE"
75
+ DEPOSIT_NOT_FOUND = "DEPOSIT_NOT_FOUND"
41
76
 
42
77
  attr_reader :code, :doc_url, :context
43
78
 
data/lib/remitmd/http.rb CHANGED
@@ -43,7 +43,7 @@ module Remitmd
43
43
  def request(method, path, body)
44
44
  attempt = 0
45
45
  # Generate idempotency key once per request (stable across retries).
46
- idempotency_key = (method == :post || method == :put || method == :patch) ? SecureRandom.uuid : nil
46
+ idempotency_key = %i[post put patch].include?(method) ? SecureRandom.uuid : nil
47
47
  begin
48
48
  attempt += 1
49
49
  req = build_request(method, path, body, idempotency_key)
@@ -74,9 +74,10 @@ module Remitmd
74
74
  nonce_hex = "0x#{nonce_bytes.unpack1("H*")}"
75
75
  timestamp = Time.now.to_i
76
76
 
77
- # Compute EIP-712 hash and sign it.
77
+ # Strip query string before signing — only the path is included in EIP-712.
78
+ sign_path = full_path.split("?").first
78
79
  http_method = method.to_s.upcase
79
- digest = eip712_hash(http_method, full_path, timestamp, nonce_bytes)
80
+ digest = eip712_hash(http_method, sign_path, timestamp, nonce_bytes)
80
81
  signature = @signer.sign(digest)
81
82
 
82
83
  req["Content-Type"] = "application/json"
@@ -155,8 +156,10 @@ module Remitmd
155
156
  when 200..299
156
157
  parsed
157
158
  when 400
158
- code = parsed["code"] || RemitError::SERVER_ERROR
159
- raise RemitError.new(code, parsed["message"] || "Bad request", context: parsed)
159
+ # Support nested error format: { "error": { "code": "...", "message": "..." } }
160
+ err = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : parsed
161
+ code = err["code"] || RemitError::SERVER_ERROR
162
+ raise RemitError.new(code, err["message"] || "Bad request", context: parsed)
160
163
  when 401
161
164
  raise RemitError.new(RemitError::UNAUTHORIZED,
162
165
  "Authentication failed — check your private key and chain ID")
@@ -77,7 +77,7 @@ module Remitmd
77
77
  RC.each { |rc| keccak_round!(state, rc) }
78
78
  end
79
79
 
80
- def keccak_round!(state, rc) # rubocop:disable Metrics/MethodLength
80
+ def keccak_round!(state, rc)
81
81
  # Theta
82
82
  c = Array.new(5) do |x|
83
83
  state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20]
data/lib/remitmd/mock.rb CHANGED
@@ -180,6 +180,7 @@ module Remitmd
180
180
  invoice_id = fetch!(b, :invoice_id)
181
181
  inv = @state[:pending_invoices].delete(invoice_id)
182
182
  raise not_found(RemitError::ESCROW_NOT_FOUND, invoice_id) unless inv
183
+
183
184
  payee = (inv[:to_agent] || inv["to_agent"]).to_s
184
185
  amount = decimal!(inv, :amount)
185
186
  memo = (inv[:task] || inv["task"]).to_s
@@ -310,7 +311,7 @@ module Remitmd
310
311
  # Bounty submit
311
312
  in ["POST", path] if path.end_with?("/submit") && path.include?("/bounties/")
312
313
  id = extract_id(path, "/bounties/", "/submit")
313
- bnt = @state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
314
+ @state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
314
315
  {
315
316
  "id" => 1,
316
317
  "bounty_id" => id,
@@ -322,9 +323,9 @@ module Remitmd
322
323
 
323
324
  # Bounty award
324
325
  in ["POST", path] if path.end_with?("/award") && path.include?("/bounties/")
325
- id = extract_id(path, "/bounties/", "/award")
326
- submission_id = (b[:submission_id] || b["submission_id"]).to_i
327
- bnt = @state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
326
+ id = extract_id(path, "/bounties/", "/award")
327
+ _submission_id = (b[:submission_id] || b["submission_id"]).to_i
328
+ bnt = @state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
328
329
  new_bnt = update_bounty(bnt, status: BountyStatus::AWARDED)
329
330
  @state[:bounties][id] = new_bnt
330
331
  bounty_hash(new_bnt)
@@ -620,6 +621,7 @@ module Remitmd
620
621
  def check_balance!(amount)
621
622
  bal = @state[:balance]
622
623
  return if bal >= amount
624
+
623
625
  raise RemitError.new(
624
626
  RemitError::INSUFFICIENT_FUNDS,
625
627
  "Insufficient balance: have #{bal.to_s("F")} USDC, need #{amount.to_s("F")} USDC",
@@ -23,20 +23,23 @@ module Remitmd
23
23
  end
24
24
 
25
25
  module TabStatus
26
- OPEN = "open"
27
- CLOSED = "closed"
28
- EXPIRED = "expired"
26
+ OPEN = "open"
27
+ CLOSED = "closed"
28
+ EXPIRED = "expired"
29
+ SUSPENDED = "suspended"
29
30
  end
30
31
 
31
32
  module StreamStatus
32
33
  ACTIVE = "active"
33
34
  CLOSED = "closed"
34
35
  COMPLETED = "completed"
36
+ PAUSED = "paused"
37
+ CANCELLED = "cancelled"
35
38
  end
36
39
 
37
40
  module BountyStatus
38
41
  OPEN = "open"
39
- CLAIMED = "claimed"
42
+ CLOSED = "closed"
40
43
  AWARDED = "awarded"
41
44
  EXPIRED = "expired"
42
45
  CANCELLED = "cancelled"
@@ -46,6 +49,7 @@ module Remitmd
46
49
  LOCKED = "locked"
47
50
  RETURNED = "returned"
48
51
  FORFEITED = "forfeited"
52
+ EXPIRED = "expired"
49
53
  end
50
54
 
51
55
  # ─── Permit & Contract Addresses ─────────────────────────────────────────
@@ -93,12 +97,14 @@ module Remitmd
93
97
  def decimal(value)
94
98
  return value if value.is_a?(BigDecimal)
95
99
  return BigDecimal(value.to_s) if value
100
+
96
101
  nil
97
102
  end
98
103
 
99
104
  def parse_time(value)
100
105
  return value if value.is_a?(Time)
101
106
  return Time.parse(value) if value.is_a?(String)
107
+
102
108
  nil
103
109
  end
104
110
  end
@@ -136,7 +142,7 @@ module Remitmd
136
142
  @fee = decimal(h["fee"] || "0")
137
143
  @memo = h["memo"] || ""
138
144
  @chain_id = h["chain_id"]&.to_i
139
- @block_number = h["block_number"]&.to_i || 0
145
+ @block_number = h["block_number"].to_i
140
146
  @created_at = parse_time(h["created_at"])
141
147
  end
142
148
 
@@ -164,10 +170,10 @@ module Remitmd
164
170
  def initialize(attrs)
165
171
  h = attrs.transform_keys(&:to_s)
166
172
  @address = h["address"]
167
- @score = h["score"]&.to_i || 0
173
+ @score = h["score"].to_i
168
174
  @total_paid = decimal(h["total_paid"])
169
175
  @total_received = decimal(h["total_received"])
170
- @transaction_count = h["transaction_count"]&.to_i || 0
176
+ @transaction_count = h["transaction_count"].to_i
171
177
  @member_since = parse_time(h["member_since"])
172
178
  end
173
179
 
@@ -203,21 +209,24 @@ module Remitmd
203
209
  def initialize(attrs)
204
210
  h = attrs.transform_keys(&:to_s)
205
211
  @id = h["id"]
206
- @opener = h["opener"] || h["payer"]
207
- @provider = h["provider"] || h["counterpart"]
212
+ @payer = h["payer"] || h["opener"]
213
+ @payee = h["payee"] || h["provider"] || h["counterpart"]
208
214
  @limit = decimal(h["limit_amount"] || h["limit"])
209
- @used = decimal(h["used"] || "0")
215
+ @spent = decimal(h["spent"] || h["used"] || "0")
210
216
  @remaining = decimal(h["remaining"] || h["limit_amount"] || h["limit"])
211
217
  @status = h["status"]
212
218
  @created_at = parse_time(h["created_at"])
213
219
  @closes_at = parse_time(h["closes_at"])
214
220
  end
215
221
 
216
- attr_reader :id, :opener, :provider, :limit, :used, :remaining,
222
+ attr_reader :id, :payer, :payee, :limit, :spent, :remaining,
217
223
  :status, :created_at, :closes_at
218
224
 
219
- # Backward compatibility alias
220
- alias counterpart provider
225
+ # Backward compatibility aliases
226
+ alias opener payer
227
+ alias provider payee
228
+ alias counterpart payee
229
+ alias used spent
221
230
 
222
231
  private :decimal, :parse_time
223
232
  end
@@ -228,9 +237,9 @@ module Remitmd
228
237
  @tab_id = h["tab_id"]
229
238
  @amount = decimal(h["amount"])
230
239
  @cumulative = decimal(h["cumulative"])
231
- @call_count = h["call_count"]&.to_i || 0
240
+ @call_count = h["call_count"].to_i
232
241
  @memo = h["memo"] || ""
233
- @sequence = h["sequence"]&.to_i || 0
242
+ @sequence = h["sequence"].to_i
234
243
  @signature = h["signature"]
235
244
  end
236
245
 
@@ -242,19 +251,29 @@ module Remitmd
242
251
  class Stream < Model
243
252
  def initialize(attrs)
244
253
  h = attrs.transform_keys(&:to_s)
245
- @id = h["id"]
246
- @sender = h["sender"]
247
- @recipient = h["recipient"]
248
- @rate_per_sec = decimal(h["rate_per_sec"])
249
- @deposited = decimal(h["deposited"])
250
- @withdrawn = decimal(h["withdrawn"] || "0")
251
- @status = h["status"]
252
- @started_at = parse_time(h["started_at"])
253
- @ends_at = parse_time(h["ends_at"])
254
+ @id = h["id"]
255
+ @payer = h["payer"] || h["sender"]
256
+ @payee = h["payee"] || h["recipient"]
257
+ @rate_per_second = decimal(h["rate_per_second"] || h["rate_per_sec"])
258
+ @deposited = decimal(h["deposited"])
259
+ @total_streamed = decimal(h["total_streamed"] || h["withdrawn"] || "0")
260
+ @max_duration = h["max_duration"]
261
+ @max_total = decimal(h["max_total"])
262
+ @status = h["status"]
263
+ @started_at = parse_time(h["started_at"])
264
+ @ends_at = parse_time(h["ends_at"])
265
+ @closed_at = parse_time(h["closed_at"])
254
266
  end
255
267
 
256
- attr_reader :id, :sender, :recipient, :rate_per_sec, :deposited,
257
- :withdrawn, :status, :started_at, :ends_at
268
+ attr_reader :id, :payer, :payee, :rate_per_second, :deposited,
269
+ :total_streamed, :max_duration, :max_total,
270
+ :status, :started_at, :ends_at, :closed_at
271
+
272
+ # Backward compatibility aliases
273
+ alias sender payer
274
+ alias recipient payee
275
+ alias rate_per_sec rate_per_second
276
+ alias withdrawn total_streamed
258
277
 
259
278
  private :decimal, :parse_time
260
279
  end
@@ -262,22 +281,27 @@ module Remitmd
262
281
  class Bounty < Model
263
282
  def initialize(attrs)
264
283
  h = attrs.transform_keys(&:to_s)
265
- @id = h["id"]
266
- @poster = h["poster"]
267
- @amount = decimal(h["amount"] || h["award"])
268
- @task_description = h["task_description"] || h["description"]
269
- @status = h["status"]
270
- @winner = h["winner"] || ""
271
- @expires_at = parse_time(h["expires_at"])
272
- @created_at = parse_time(h["created_at"])
284
+ @id = h["id"]
285
+ @poster = h["poster"]
286
+ @amount = decimal(h["amount"] || h["award"])
287
+ @task = h["task"] || h["task_description"] || h["description"]
288
+ @submissions = h["submissions"] || []
289
+ @validation = h["validation"]
290
+ @max_attempts = h["max_attempts"]
291
+ @deadline = h["deadline"]
292
+ @status = h["status"]
293
+ @winner = h["winner"] || ""
294
+ @expires_at = parse_time(h["expires_at"])
295
+ @created_at = parse_time(h["created_at"])
273
296
  end
274
297
 
275
- attr_reader :id, :poster, :amount, :task_description, :status,
276
- :winner, :expires_at, :created_at
298
+ attr_reader :id, :poster, :amount, :task, :submissions, :validation,
299
+ :max_attempts, :deadline, :status, :winner, :expires_at, :created_at
277
300
 
278
301
  # Backward compatibility aliases
279
302
  alias award amount
280
- alias description task_description
303
+ alias task_description task
304
+ alias description task
281
305
 
282
306
  private :decimal, :parse_time
283
307
  end
@@ -285,15 +309,19 @@ module Remitmd
285
309
  class BountySubmission < Model
286
310
  def initialize(attrs)
287
311
  h = attrs.transform_keys(&:to_s)
288
- @id = h["id"]
289
- @bounty_id = h["bounty_id"]
290
- @submitter = h["submitter"]
291
- @evidence_hash = h["evidence_hash"]
292
- @status = h["status"]
293
- @created_at = parse_time(h["created_at"])
312
+ @id = h["id"]
313
+ @bounty_id = h["bounty_id"]
314
+ @submitter = h["submitter"]
315
+ @evidence_uri = h["evidence_uri"] || h["evidence_hash"]
316
+ @accepted = h.key?("accepted") ? h["accepted"] : h["status"]
317
+ @created_at = parse_time(h["created_at"])
294
318
  end
295
319
 
296
- attr_reader :id, :bounty_id, :submitter, :evidence_hash, :status, :created_at
320
+ attr_reader :id, :bounty_id, :submitter, :evidence_uri, :accepted, :created_at
321
+
322
+ # Backward compatibility aliases
323
+ alias evidence_hash evidence_uri
324
+ alias status accepted
297
325
 
298
326
  private :parse_time
299
327
  end
@@ -302,19 +330,21 @@ module Remitmd
302
330
  def initialize(attrs)
303
331
  h = attrs.transform_keys(&:to_s)
304
332
  @id = h["id"]
305
- @depositor = h["depositor"] || h["payer"]
306
- @provider = h["provider"] || h["beneficiary"]
333
+ @payer = h["payer"] || h["depositor"]
334
+ @payee = h["payee"] || h["provider"] || h["beneficiary"]
307
335
  @amount = decimal(h["amount"])
308
336
  @status = h["status"]
309
337
  @expires_at = parse_time(h["expires_at"])
310
338
  @created_at = parse_time(h["created_at"])
311
339
  end
312
340
 
313
- attr_reader :id, :depositor, :provider, :amount,
341
+ attr_reader :id, :payer, :payee, :amount,
314
342
  :status, :expires_at, :created_at
315
343
 
316
- # Backward compatibility alias
317
- alias beneficiary provider
344
+ # Backward compatibility aliases
345
+ alias depositor payer
346
+ alias provider payee
347
+ alias beneficiary payee
318
348
 
319
349
  private :decimal, :parse_time
320
350
  end
@@ -361,7 +391,7 @@ module Remitmd
361
391
  @period = h["period"]
362
392
  @total_spent = decimal(h["total_spent"])
363
393
  @total_fees = decimal(h["total_fees"])
364
- @tx_count = h["tx_count"]&.to_i || 0
394
+ @tx_count = h["tx_count"].to_i
365
395
  @top_recipients = h["top_recipients"] || []
366
396
  end
367
397
 
@@ -407,9 +437,9 @@ module Remitmd
407
437
  h = attrs.transform_keys(&:to_s)
408
438
  items_raw = h["items"] || []
409
439
  @items = items_raw.map { |tx| Transaction.new(tx) }
410
- @total = h["total"]&.to_i || 0
411
- @page = h["page"]&.to_i || 1
412
- @per_page = h["per_page"]&.to_i || 50
440
+ @total = h["total"].to_i
441
+ @page = (h["page"] || 1).to_i
442
+ @per_page = (h["per_page"] || 50).to_i
413
443
  @has_more = h["has_more"] == true
414
444
  end
415
445
 
@@ -50,12 +50,9 @@ module Remitmd
50
50
  end
51
51
 
52
52
  @signer = signer || PrivateKeySigner.new(private_key)
53
- # Normalize chain key for USDC lookups (underscore → hyphen).
53
+ # Normalize chain key (underscore → hyphen). Full chain name is sent in API bodies.
54
54
  @chain_key = chain.tr("_", "-")
55
- # Normalize to the base chain name (strip testnet suffix) for use in pay body.
56
- # The server accepts "base" — not "base_sepolia" etc.
57
- @chain = chain.sub(/_sepolia\z/, "").sub(/-sepolia\z/, "")
58
- cfg = CHAIN_CONFIG.fetch(chain) do
55
+ cfg = CHAIN_CONFIG.fetch(chain) do
59
56
  raise ArgumentError, "Unknown chain: #{chain}. Valid: #{CHAIN_CONFIG.keys.join(", ")}"
60
57
  end
61
58
  base_url = api_url || cfg[:url]
@@ -101,7 +98,7 @@ module Remitmd
101
98
  # Get deployed contract addresses. Cached for the lifetime of this client.
102
99
  # @return [ContractAddresses]
103
100
  def get_contracts
104
- @contracts_cache ||= ContractAddresses.new(@transport.get("/contracts"))
101
+ @get_contracts ||= ContractAddresses.new(@transport.get("/contracts"))
105
102
  end
106
103
 
107
104
  # ─── Balance & Analytics ─────────────────────────────────────────────────
@@ -154,8 +151,8 @@ module Remitmd
154
151
  validate_amount!(amount)
155
152
  resolved = permit || auto_permit("router", amount.to_f)
156
153
  nonce = SecureRandom.hex(16)
157
- body = { to: to, amount: amount.to_s, task: memo || "", chain: @chain, nonce: nonce, signature: "0x" }
158
- body[:permit] = resolved.to_h
154
+ body = { to: to, amount: amount.to_f, task: memo || "", chain: @chain_key, nonce: nonce, signature: "0x" }
155
+ body[:permit] = resolved&.to_h
159
156
  Transaction.new(@transport.post("/payments/direct", body))
160
157
  end
161
158
 
@@ -177,16 +174,16 @@ module Remitmd
177
174
  invoice_id = SecureRandom.hex(16)
178
175
  nonce = SecureRandom.hex(16)
179
176
  inv_body = {
180
- id: invoice_id, chain: @chain,
177
+ id: invoice_id, chain: @chain_key,
181
178
  from_agent: address.downcase, to_agent: payee.downcase,
182
- amount: amount.to_s, type: "escrow",
179
+ amount: amount.to_f, type: "escrow",
183
180
  task: memo || "", nonce: nonce, signature: "0x"
184
181
  }
185
182
  inv_body[:escrow_timeout] = expires_in_secs if expires_in_secs
186
183
  @transport.post("/invoices", inv_body)
187
184
 
188
185
  # Step 2: fund the escrow.
189
- esc_body = { invoice_id: invoice_id, permit: resolved.to_h }
186
+ esc_body = { invoice_id: invoice_id, permit: resolved&.to_h }
190
187
  Escrow.new(@transport.post("/escrows", esc_body))
191
188
  end
192
189
 
@@ -234,12 +231,12 @@ module Remitmd
234
231
  validate_amount!(limit_amount)
235
232
  resolved = permit || auto_permit("tab", limit_amount.to_f)
236
233
  body = {
237
- chain: @chain,
234
+ chain: @chain_key,
238
235
  provider: provider,
239
236
  limit_amount: limit_amount.to_f,
240
237
  per_unit: per_unit.to_f,
241
238
  expiry: Time.now.to_i + expires_in_secs,
242
- permit: resolved.to_h
239
+ permit: resolved&.to_h
243
240
  }
244
241
  Tab.new(@transport.post("/tabs", body))
245
242
  end
@@ -278,9 +275,10 @@ module Remitmd
278
275
  # @param provider_sig [String, nil] EIP-712 signature from the provider
279
276
  # @return [Tab]
280
277
  def close_tab(tab_id, final_amount: nil, provider_sig: nil)
281
- body = {}
282
- body[:final_amount] = final_amount.to_f if final_amount
283
- body[:provider_sig] = provider_sig if provider_sig
278
+ body = {
279
+ final_amount: final_amount ? final_amount.to_f : 0,
280
+ provider_sig: provider_sig || "0x"
281
+ }
284
282
  Tab.new(@transport.post("/tabs/#{tab_id}/close", body))
285
283
  end
286
284
 
@@ -427,11 +425,11 @@ module Remitmd
427
425
  validate_amount!(max_total)
428
426
  resolved = permit || auto_permit("stream", max_total.to_f)
429
427
  body = {
430
- chain: @chain,
428
+ chain: @chain_key,
431
429
  payee: payee,
432
430
  rate_per_second: rate_per_second.to_s,
433
431
  max_total: max_total.to_s,
434
- permit: resolved.to_h
432
+ permit: resolved&.to_h
435
433
  }
436
434
  Stream.new(@transport.post("/streams", body))
437
435
  end
@@ -463,12 +461,12 @@ module Remitmd
463
461
  validate_amount!(amount)
464
462
  resolved = permit || auto_permit("bounty", amount.to_f)
465
463
  body = {
466
- chain: @chain,
464
+ chain: @chain_key,
467
465
  amount: amount.to_f,
468
466
  task_description: task_description,
469
467
  deadline: deadline,
470
468
  max_attempts: max_attempts,
471
- permit: resolved.to_h
469
+ permit: resolved&.to_h
472
470
  }
473
471
  Bounty.new(@transport.post("/bounties", body))
474
472
  end
@@ -518,11 +516,11 @@ module Remitmd
518
516
  validate_amount!(amount)
519
517
  resolved = permit || auto_permit("deposit", amount.to_f)
520
518
  body = {
521
- chain: @chain,
519
+ chain: @chain_key,
522
520
  provider: provider,
523
521
  amount: amount.to_f,
524
522
  expiry: Time.now.to_i + expires_in_secs,
525
- permit: resolved.to_h
523
+ permit: resolved&.to_h
526
524
  }
527
525
  Deposit.new(@transport.post("/deposits", body))
528
526
  end
@@ -563,7 +561,7 @@ module Remitmd
563
561
  # @return [Webhook]
564
562
  def register_webhook(url, events, chains: nil)
565
563
  body = { url: url, events: events }
566
- body[:chains] = chains if chains
564
+ body[:chains] = chains || [@chain_key]
567
565
  Webhook.new(@transport.post("/webhooks", body))
568
566
  end
569
567
 
@@ -580,7 +578,7 @@ module Remitmd
580
578
  body[:agent_name] = agent_name if agent_name
581
579
  begin
582
580
  resolved = permit || auto_permit("relayer", 999_999_999.0)
583
- body[:permit] = resolved.to_h
581
+ body[:permit] = resolved&.to_h
584
582
  rescue StandardError => e
585
583
  warn "[remitmd] create_fund_link: auto-permit failed: #{e.message}"
586
584
  end
@@ -598,7 +596,7 @@ module Remitmd
598
596
  body[:agent_name] = agent_name if agent_name
599
597
  begin
600
598
  resolved = permit || auto_permit("relayer", 999_999_999.0)
601
- body[:permit] = resolved.to_h
599
+ body[:permit] = resolved&.to_h
602
600
  rescue StandardError => e
603
601
  warn "[remitmd] create_withdraw_link: auto-permit failed: #{e.message}"
604
602
  end
@@ -608,10 +606,25 @@ module Remitmd
608
606
  # ─── Testnet ──────────────────────────────────────────────────────────────
609
607
 
610
608
  # Mint testnet USDC. Max $2,500 per call, once per hour per wallet.
609
+ # Uses unauthenticated HTTP (no EIP-712 auth headers).
611
610
  # @param amount [Numeric] amount in USDC
612
611
  # @return [Hash] { "tx_hash" => "0x...", "balance" => 1234.56 }
613
612
  def mint(amount)
614
- @transport.post("/mint", { wallet: address, amount: amount })
613
+ if @mock_mode
614
+ @transport.post("/mint", { wallet: address, amount: amount })
615
+ else
616
+ cfg = CHAIN_CONFIG[@chain_key]
617
+ base_url = cfg ? cfg[:url] : "https://testnet.remit.md/api/v1"
618
+ uri = URI("#{base_url}/mint")
619
+ http = Net::HTTP.new(uri.host, uri.port)
620
+ http.use_ssl = uri.scheme == "https"
621
+ http.read_timeout = 15
622
+ req = Net::HTTP::Post.new(uri.path)
623
+ req["Content-Type"] = "application/json"
624
+ req.body = { wallet: address, amount: amount }.to_json
625
+ resp = http.request(req)
626
+ JSON.parse(resp.body.to_s)
627
+ end
615
628
  end
616
629
 
617
630
  private
@@ -620,6 +633,7 @@ module Remitmd
620
633
 
621
634
  def validate_address!(addr)
622
635
  return if addr.match?(ADDRESS_RE)
636
+
623
637
  raise RemitError.new(
624
638
  RemitError::INVALID_ADDRESS,
625
639
  "Invalid address #{addr.inspect}: expected 0x-prefixed 40-character hex string. " \
@@ -631,6 +645,7 @@ module Remitmd
631
645
  def validate_amount!(amount)
632
646
  d = BigDecimal(amount.to_s)
633
647
  return if d >= MIN_AMOUNT
648
+
634
649
  raise RemitError.new(
635
650
  RemitError::INVALID_AMOUNT,
636
651
  "Amount #{amount} is below the minimum of #{MIN_AMOUNT} USDC.",
@@ -664,7 +679,7 @@ module Remitmd
664
679
  # Fetch the EIP-2612 permit nonce from the API.
665
680
  # @param usdc_address [String] the USDC contract address
666
681
  # @return [Integer] current nonce
667
- def fetch_permit_nonce(usdc_address)
682
+ def fetch_permit_nonce(_usdc_address)
668
683
  return 0 if @mock_mode
669
684
 
670
685
  data = @transport.get("/status/#{address}")
@@ -681,18 +696,19 @@ module Remitmd
681
696
  end
682
697
 
683
698
  # Auto-sign a permit for the given contract type and amount.
699
+ # Returns nil on failure instead of raising, so callers can proceed without a permit.
684
700
  # @param contract [String] contract key — "router", "escrow", "tab", etc.
685
701
  # @param amount [Numeric] amount in USDC
686
- # @return [PermitSignature]
702
+ # @return [PermitSignature, nil]
687
703
  def auto_permit(contract, amount)
688
704
  contracts = get_contracts
689
705
  spender = contracts.send(contract.to_sym)
690
- raise RemitError.new(
691
- RemitError::SERVER_ERROR,
692
- "No #{contract} contract address available"
693
- ) unless spender
706
+ return nil unless spender
694
707
 
695
708
  sign_permit(spender, amount)
709
+ rescue => e
710
+ warn "[remitmd] auto-permit failed for #{contract} (amount=#{amount}): #{e.message}"
711
+ nil
696
712
  end
697
713
 
698
714
  # Spender contract mapping for auto_permit.
@@ -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.6"
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.6
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-24 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