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 +4 -4
- data/lib/remitmd/a2a.rb +188 -2
- data/lib/remitmd/errors.rb +52 -17
- data/lib/remitmd/http.rb +8 -5
- data/lib/remitmd/keccak.rb +1 -1
- data/lib/remitmd/mock.rb +6 -4
- data/lib/remitmd/models.rb +83 -53
- data/lib/remitmd/wallet.rb +48 -32
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b29ac02ca95fe1053bed848cca89111698f1744bbeb145681a76a3f9ed2d8e66
|
|
4
|
+
data.tar.gz: d55c4f33eaaa686fa0036e3a0566ee6382a0a544c01093a3d9887e745b2eaa01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 =
|
|
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
|
data/lib/remitmd/errors.rb
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
|
|
159
|
-
|
|
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")
|
data/lib/remitmd/keccak.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
326
|
-
|
|
327
|
-
bnt
|
|
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",
|
data/lib/remitmd/models.rb
CHANGED
|
@@ -23,20 +23,23 @@ module Remitmd
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
module TabStatus
|
|
26
|
-
OPEN
|
|
27
|
-
CLOSED
|
|
28
|
-
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
|
-
|
|
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"]
|
|
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"]
|
|
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"]
|
|
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
|
-
@
|
|
207
|
-
@
|
|
212
|
+
@payer = h["payer"] || h["opener"]
|
|
213
|
+
@payee = h["payee"] || h["provider"] || h["counterpart"]
|
|
208
214
|
@limit = decimal(h["limit_amount"] || h["limit"])
|
|
209
|
-
@
|
|
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, :
|
|
222
|
+
attr_reader :id, :payer, :payee, :limit, :spent, :remaining,
|
|
217
223
|
:status, :created_at, :closes_at
|
|
218
224
|
|
|
219
|
-
# Backward compatibility
|
|
220
|
-
alias
|
|
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"]
|
|
240
|
+
@call_count = h["call_count"].to_i
|
|
232
241
|
@memo = h["memo"] || ""
|
|
233
|
-
@sequence = h["sequence"]
|
|
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
|
|
246
|
-
@
|
|
247
|
-
@
|
|
248
|
-
@
|
|
249
|
-
@deposited
|
|
250
|
-
@
|
|
251
|
-
@
|
|
252
|
-
@
|
|
253
|
-
@
|
|
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, :
|
|
257
|
-
:
|
|
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
|
|
266
|
-
@poster
|
|
267
|
-
@amount
|
|
268
|
-
@
|
|
269
|
-
@
|
|
270
|
-
@
|
|
271
|
-
@
|
|
272
|
-
@
|
|
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, :
|
|
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
|
|
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
|
|
289
|
-
@bounty_id
|
|
290
|
-
@submitter
|
|
291
|
-
@
|
|
292
|
-
@
|
|
293
|
-
@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, :
|
|
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
|
-
@
|
|
306
|
-
@
|
|
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, :
|
|
341
|
+
attr_reader :id, :payer, :payee, :amount,
|
|
314
342
|
:status, :expires_at, :created_at
|
|
315
343
|
|
|
316
|
-
# Backward compatibility
|
|
317
|
-
alias
|
|
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"]
|
|
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"]
|
|
411
|
-
@page = h["page"]
|
|
412
|
-
@per_page = h["per_page"]
|
|
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
|
|
data/lib/remitmd/wallet.rb
CHANGED
|
@@ -50,12 +50,9 @@ module Remitmd
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
@signer = signer || PrivateKeySigner.new(private_key)
|
|
53
|
-
# Normalize chain key
|
|
53
|
+
# Normalize chain key (underscore → hyphen). Full chain name is sent in API bodies.
|
|
54
54
|
@chain_key = chain.tr("_", "-")
|
|
55
|
-
|
|
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
|
-
@
|
|
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.
|
|
158
|
-
body[:permit] = resolved
|
|
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: @
|
|
177
|
+
id: invoice_id, chain: @chain_key,
|
|
181
178
|
from_agent: address.downcase, to_agent: payee.downcase,
|
|
182
|
-
amount: amount.
|
|
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
|
|
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: @
|
|
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
|
|
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
|
-
|
|
283
|
-
|
|
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: @
|
|
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
|
|
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: @
|
|
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
|
|
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: @
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
@
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|