remitmd 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,35 +16,40 @@ module Remitmd
16
16
  module EscrowStatus
17
17
  PENDING = "pending"
18
18
  FUNDED = "funded"
19
- RELEASED = "released"
19
+ ACTIVE = "active"
20
+ COMPLETED = "completed"
20
21
  CANCELLED = "cancelled"
21
- EXPIRED = "expired"
22
+ FAILED = "failed"
22
23
  end
23
24
 
24
25
  module TabStatus
25
- OPEN = "open"
26
- CLOSED = "closed"
27
- SETTLED = "settled"
26
+ OPEN = "open"
27
+ CLOSED = "closed"
28
+ EXPIRED = "expired"
29
+ SUSPENDED = "suspended"
28
30
  end
29
31
 
30
32
  module StreamStatus
31
33
  ACTIVE = "active"
34
+ CLOSED = "closed"
35
+ COMPLETED = "completed"
32
36
  PAUSED = "paused"
33
- ENDED = "ended"
34
37
  CANCELLED = "cancelled"
35
38
  end
36
39
 
37
40
  module BountyStatus
38
41
  OPEN = "open"
42
+ CLOSED = "closed"
39
43
  AWARDED = "awarded"
40
44
  EXPIRED = "expired"
41
- RECLAIMED = "reclaimed"
45
+ CANCELLED = "cancelled"
42
46
  end
43
47
 
44
48
  module DepositStatus
45
49
  LOCKED = "locked"
46
50
  RETURNED = "returned"
47
51
  FORFEITED = "forfeited"
52
+ EXPIRED = "expired"
48
53
  end
49
54
 
50
55
  # ─── Permit & Contract Addresses ─────────────────────────────────────────
@@ -92,12 +97,14 @@ module Remitmd
92
97
  def decimal(value)
93
98
  return value if value.is_a?(BigDecimal)
94
99
  return BigDecimal(value.to_s) if value
100
+
95
101
  nil
96
102
  end
97
103
 
98
104
  def parse_time(value)
99
105
  return value if value.is_a?(Time)
100
106
  return Time.parse(value) if value.is_a?(String)
107
+
101
108
  nil
102
109
  end
103
110
  end
@@ -116,12 +123,11 @@ module Remitmd
116
123
  @deposit = h["deposit"]
117
124
  @fee_calculator = h["fee_calculator"]
118
125
  @key_registry = h["key_registry"]
119
- @arbitration = h["arbitration"]
120
126
  @relayer = h["relayer"]
121
127
  end
122
128
 
123
129
  attr_reader :chain_id, :usdc, :router, :escrow, :tab, :stream,
124
- :bounty, :deposit, :fee_calculator, :key_registry, :arbitration,
130
+ :bounty, :deposit, :fee_calculator, :key_registry,
125
131
  :relayer
126
132
  end
127
133
 
@@ -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
 
@@ -2,8 +2,6 @@
2
2
 
3
3
  require "bigdecimal"
4
4
  require "bigdecimal/util"
5
- require "net/http"
6
- require "uri"
7
5
  require "json"
8
6
 
9
7
  module Remitmd
@@ -14,13 +12,6 @@ module Remitmd
14
12
  "localhost" => "0x5FbDB2315678afecb367f032d93F642f64180aa3",
15
13
  }.freeze
16
14
 
17
- # Default JSON-RPC endpoints per chain (for nonce fetching).
18
- DEFAULT_RPC_URLS = {
19
- "base-sepolia" => "https://sepolia.base.org",
20
- "base" => "https://mainnet.base.org",
21
- "localhost" => "http://127.0.0.1:8545",
22
- }.freeze
23
-
24
15
  # Primary remit.md client. All payment operations are methods on RemitWallet.
25
16
  #
26
17
  # @example Quickstart
@@ -39,16 +30,14 @@ module Remitmd
39
30
  # @param signer [Signer, nil] custom signer (pass instead of private_key)
40
31
  # @param chain [String] chain name — "base", "base_sepolia"
41
32
  # @param api_url [String, nil] override API base URL
42
- # @param rpc_url [String, nil] override JSON-RPC URL for on-chain queries
43
33
  # @param transport [Object, nil] inject mock transport (used by MockRemit)
44
- def initialize(private_key: nil, signer: nil, chain: "base", api_url: nil, router_address: nil, rpc_url: nil, transport: nil)
34
+ def initialize(private_key: nil, signer: nil, chain: "base", api_url: nil, router_address: nil, transport: nil)
45
35
  if transport
46
36
  # MockRemit path: transport + signer injected directly
47
37
  @signer = signer
48
38
  @transport = transport
49
39
  @chain_key = "base-sepolia"
50
40
  @chain_id = ChainId::BASE_SEPOLIA
51
- @rpc_url = DEFAULT_RPC_URLS["base-sepolia"]
52
41
  @mock_mode = true
53
42
  return
54
43
  end
@@ -61,18 +50,14 @@ module Remitmd
61
50
  end
62
51
 
63
52
  @signer = signer || PrivateKeySigner.new(private_key)
64
- # Normalize chain key for USDC/RPC lookups (underscore → hyphen).
53
+ # Normalize chain key (underscore → hyphen). Full chain name is sent in API bodies.
65
54
  @chain_key = chain.tr("_", "-")
66
- # Normalize to the base chain name (strip testnet suffix) for use in pay body.
67
- # The server accepts "base" — not "base_sepolia" etc.
68
- @chain = chain.sub(/_sepolia\z/, "").sub(/-sepolia\z/, "")
69
- cfg = CHAIN_CONFIG.fetch(chain) do
55
+ cfg = CHAIN_CONFIG.fetch(chain) do
70
56
  raise ArgumentError, "Unknown chain: #{chain}. Valid: #{CHAIN_CONFIG.keys.join(", ")}"
71
57
  end
72
58
  base_url = api_url || cfg[:url]
73
59
  @chain_id = cfg[:chain_id]
74
60
  router_address ||= ""
75
- @rpc_url = rpc_url || ENV["REMITMD_RPC_URL"] || DEFAULT_RPC_URLS[@chain_key] || DEFAULT_RPC_URLS["base-sepolia"]
76
61
  @transport = HttpTransport.new(
77
62
  base_url: base_url,
78
63
  signer: @signer,
@@ -82,14 +67,19 @@ module Remitmd
82
67
  end
83
68
 
84
69
  # Build a RemitWallet from environment variables.
85
- # Reads: REMITMD_PRIVATE_KEY, REMITMD_CHAIN, REMITMD_API_URL, REMITMD_ROUTER_ADDRESS, REMITMD_RPC_URL.
70
+ # Reads: REMITMD_KEY (primary) or REMITMD_PRIVATE_KEY (deprecated fallback),
71
+ # REMITMD_CHAIN, REMITMD_API_URL, REMITMD_ROUTER_ADDRESS.
86
72
  def self.from_env
87
- key = ENV.fetch("REMITMD_PRIVATE_KEY") { raise ArgumentError, "REMITMD_PRIVATE_KEY not set" }
73
+ key = ENV["REMITMD_KEY"] || ENV["REMITMD_PRIVATE_KEY"]
74
+ if ENV["REMITMD_PRIVATE_KEY"] && !ENV["REMITMD_KEY"]
75
+ warn "[remitmd] REMITMD_PRIVATE_KEY is deprecated, use REMITMD_KEY instead"
76
+ end
77
+ raise ArgumentError, "REMITMD_KEY not set" unless key
78
+
88
79
  chain = ENV.fetch("REMITMD_CHAIN", "base")
89
80
  api_url = ENV["REMITMD_API_URL"]
90
81
  router_address = ENV["REMITMD_ROUTER_ADDRESS"]
91
- rpc_url = ENV["REMITMD_RPC_URL"]
92
- new(private_key: key, chain: chain, api_url: api_url, router_address: router_address, rpc_url: rpc_url)
82
+ new(private_key: key, chain: chain, api_url: api_url, router_address: router_address)
93
83
  end
94
84
 
95
85
  # The Ethereum address associated with this wallet.
@@ -108,7 +98,7 @@ module Remitmd
108
98
  # Get deployed contract addresses. Cached for the lifetime of this client.
109
99
  # @return [ContractAddresses]
110
100
  def get_contracts
111
- @contracts_cache ||= ContractAddresses.new(@transport.get("/contracts"))
101
+ @get_contracts ||= ContractAddresses.new(@transport.get("/contracts"))
112
102
  end
113
103
 
114
104
  # ─── Balance & Analytics ─────────────────────────────────────────────────
@@ -161,8 +151,8 @@ module Remitmd
161
151
  validate_amount!(amount)
162
152
  resolved = permit || auto_permit("router", amount.to_f)
163
153
  nonce = SecureRandom.hex(16)
164
- body = { to: to, amount: amount.to_s, task: memo || "", chain: @chain, nonce: nonce, signature: "0x" }
165
- 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
166
156
  Transaction.new(@transport.post("/payments/direct", body))
167
157
  end
168
158
 
@@ -184,16 +174,16 @@ module Remitmd
184
174
  invoice_id = SecureRandom.hex(16)
185
175
  nonce = SecureRandom.hex(16)
186
176
  inv_body = {
187
- id: invoice_id, chain: @chain,
177
+ id: invoice_id, chain: @chain_key,
188
178
  from_agent: address.downcase, to_agent: payee.downcase,
189
- amount: amount.to_s, type: "escrow",
179
+ amount: amount.to_f, type: "escrow",
190
180
  task: memo || "", nonce: nonce, signature: "0x"
191
181
  }
192
182
  inv_body[:escrow_timeout] = expires_in_secs if expires_in_secs
193
183
  @transport.post("/invoices", inv_body)
194
184
 
195
185
  # Step 2: fund the escrow.
196
- esc_body = { invoice_id: invoice_id, permit: resolved.to_h }
186
+ esc_body = { invoice_id: invoice_id, permit: resolved&.to_h }
197
187
  Escrow.new(@transport.post("/escrows", esc_body))
198
188
  end
199
189
 
@@ -241,12 +231,12 @@ module Remitmd
241
231
  validate_amount!(limit_amount)
242
232
  resolved = permit || auto_permit("tab", limit_amount.to_f)
243
233
  body = {
244
- chain: @chain,
234
+ chain: @chain_key,
245
235
  provider: provider,
246
236
  limit_amount: limit_amount.to_f,
247
237
  per_unit: per_unit.to_f,
248
238
  expiry: Time.now.to_i + expires_in_secs,
249
- permit: resolved.to_h
239
+ permit: resolved&.to_h
250
240
  }
251
241
  Tab.new(@transport.post("/tabs", body))
252
242
  end
@@ -285,9 +275,10 @@ module Remitmd
285
275
  # @param provider_sig [String, nil] EIP-712 signature from the provider
286
276
  # @return [Tab]
287
277
  def close_tab(tab_id, final_amount: nil, provider_sig: nil)
288
- body = {}
289
- body[:final_amount] = final_amount.to_f if final_amount
290
- 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
+ }
291
282
  Tab.new(@transport.post("/tabs/#{tab_id}/close", body))
292
283
  end
293
284
 
@@ -350,7 +341,15 @@ module Remitmd
350
341
  # @param usdc_address [String, nil] override the USDC contract address
351
342
  # @return [PermitSignature]
352
343
  def sign_usdc_permit(spender, value, deadline, nonce = 0, usdc_address: nil)
353
- usdc_addr = usdc_address || USDC_ADDRESSES[@chain_key] || ""
344
+ usdc_addr = usdc_address || USDC_ADDRESSES[@chain_key]
345
+ if usdc_addr.nil? || usdc_addr.empty?
346
+ raise RemitError.new(
347
+ RemitError::INVALID_ADDRESS,
348
+ "No USDC address configured for chain #{@chain_key.inspect}. " \
349
+ "Valid chains: #{USDC_ADDRESSES.keys.join(", ")}",
350
+ context: { chain: @chain_key }
351
+ )
352
+ end
354
353
  chain_id = @chain_id || ChainId::BASE_SEPOLIA
355
354
 
356
355
  # Domain separator for USDC (EIP-2612)
@@ -397,8 +396,16 @@ module Remitmd
397
396
  # @param deadline [Integer, nil] optional Unix timestamp; defaults to 1 hour from now
398
397
  # @return [PermitSignature]
399
398
  def sign_permit(spender, amount, deadline: nil)
400
- usdc_addr = USDC_ADDRESSES[@chain_key] || ""
401
- nonce = fetch_usdc_nonce(usdc_addr)
399
+ usdc_addr = USDC_ADDRESSES[@chain_key]
400
+ if usdc_addr.nil? || usdc_addr.empty?
401
+ raise RemitError.new(
402
+ RemitError::INVALID_ADDRESS,
403
+ "No USDC address configured for chain #{@chain_key.inspect}. " \
404
+ "Valid chains: #{USDC_ADDRESSES.keys.join(", ")}",
405
+ context: { chain: @chain_key }
406
+ )
407
+ end
408
+ nonce = fetch_permit_nonce(usdc_addr)
402
409
  dl = deadline || (Time.now.to_i + 3600)
403
410
  raw = (amount * 1_000_000).round.to_i
404
411
  sign_usdc_permit(spender, raw, dl, nonce, usdc_address: usdc_addr)
@@ -418,11 +425,11 @@ module Remitmd
418
425
  validate_amount!(max_total)
419
426
  resolved = permit || auto_permit("stream", max_total.to_f)
420
427
  body = {
421
- chain: @chain,
428
+ chain: @chain_key,
422
429
  payee: payee,
423
430
  rate_per_second: rate_per_second.to_s,
424
431
  max_total: max_total.to_s,
425
- permit: resolved.to_h
432
+ permit: resolved&.to_h
426
433
  }
427
434
  Stream.new(@transport.post("/streams", body))
428
435
  end
@@ -454,12 +461,12 @@ module Remitmd
454
461
  validate_amount!(amount)
455
462
  resolved = permit || auto_permit("bounty", amount.to_f)
456
463
  body = {
457
- chain: @chain,
464
+ chain: @chain_key,
458
465
  amount: amount.to_f,
459
466
  task_description: task_description,
460
467
  deadline: deadline,
461
468
  max_attempts: max_attempts,
462
- permit: resolved.to_h
469
+ permit: resolved&.to_h
463
470
  }
464
471
  Bounty.new(@transport.post("/bounties", body))
465
472
  end
@@ -509,11 +516,11 @@ module Remitmd
509
516
  validate_amount!(amount)
510
517
  resolved = permit || auto_permit("deposit", amount.to_f)
511
518
  body = {
512
- chain: @chain,
519
+ chain: @chain_key,
513
520
  provider: provider,
514
521
  amount: amount.to_f,
515
522
  expiry: Time.now.to_i + expires_in_secs,
516
- permit: resolved.to_h
523
+ permit: resolved&.to_h
517
524
  }
518
525
  Deposit.new(@transport.post("/deposits", body))
519
526
  end
@@ -554,7 +561,7 @@ module Remitmd
554
561
  # @return [Webhook]
555
562
  def register_webhook(url, events, chains: nil)
556
563
  body = { url: url, events: events }
557
- body[:chains] = chains if chains
564
+ body[:chains] = chains || [@chain_key]
558
565
  Webhook.new(@transport.post("/webhooks", body))
559
566
  end
560
567
 
@@ -571,9 +578,9 @@ module Remitmd
571
578
  body[:agent_name] = agent_name if agent_name
572
579
  begin
573
580
  resolved = permit || auto_permit("relayer", 999_999_999.0)
574
- body[:permit] = resolved.to_h
575
- rescue StandardError
576
- # permit signing failed — proceed without permit (custodial fallback)
581
+ body[:permit] = resolved&.to_h
582
+ rescue StandardError => e
583
+ warn "[remitmd] create_fund_link: auto-permit failed: #{e.message}"
577
584
  end
578
585
  LinkResponse.new(@transport.post("/links/fund", body))
579
586
  end
@@ -589,9 +596,9 @@ module Remitmd
589
596
  body[:agent_name] = agent_name if agent_name
590
597
  begin
591
598
  resolved = permit || auto_permit("relayer", 999_999_999.0)
592
- body[:permit] = resolved.to_h
593
- rescue StandardError
594
- # permit signing failed — proceed without permit (custodial fallback)
599
+ body[:permit] = resolved&.to_h
600
+ rescue StandardError => e
601
+ warn "[remitmd] create_withdraw_link: auto-permit failed: #{e.message}"
595
602
  end
596
603
  LinkResponse.new(@transport.post("/links/withdraw", body))
597
604
  end
@@ -599,10 +606,25 @@ module Remitmd
599
606
  # ─── Testnet ──────────────────────────────────────────────────────────────
600
607
 
601
608
  # Mint testnet USDC. Max $2,500 per call, once per hour per wallet.
609
+ # Uses unauthenticated HTTP (no EIP-712 auth headers).
602
610
  # @param amount [Numeric] amount in USDC
603
611
  # @return [Hash] { "tx_hash" => "0x...", "balance" => 1234.56 }
604
612
  def mint(amount)
605
- @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
606
628
  end
607
629
 
608
630
  private
@@ -611,6 +633,7 @@ module Remitmd
611
633
 
612
634
  def validate_address!(addr)
613
635
  return if addr.match?(ADDRESS_RE)
636
+
614
637
  raise RemitError.new(
615
638
  RemitError::INVALID_ADDRESS,
616
639
  "Invalid address #{addr.inspect}: expected 0x-prefixed 40-character hex string. " \
@@ -622,6 +645,7 @@ module Remitmd
622
645
  def validate_amount!(amount)
623
646
  d = BigDecimal(amount.to_s)
624
647
  return if d >= MIN_AMOUNT
648
+
625
649
  raise RemitError.new(
626
650
  RemitError::INVALID_AMOUNT,
627
651
  "Amount #{amount} is below the minimum of #{MIN_AMOUNT} USDC.",
@@ -652,56 +676,39 @@ module Remitmd
652
676
 
653
677
  # ─── Permit helpers ──────────────────────────────────────────────────
654
678
 
655
- # Fetch the current EIP-2612 nonce for this wallet from the USDC contract.
656
- # Uses JSON-RPC eth_call with selector 0x7ecebe00 (nonces(address)).
679
+ # Fetch the EIP-2612 permit nonce from the API.
657
680
  # @param usdc_address [String] the USDC contract address
658
681
  # @return [Integer] current nonce
659
- def fetch_usdc_nonce(usdc_address)
682
+ def fetch_permit_nonce(_usdc_address)
660
683
  return 0 if @mock_mode
661
684
 
662
- padded = address.downcase.delete_prefix("0x").rjust(64, "0")
663
- data = "0x7ecebe00#{padded}"
664
- payload = {
665
- jsonrpc: "2.0",
666
- id: 1,
667
- method: "eth_call",
668
- params: [{ to: usdc_address, data: data }, "latest"]
669
- }
670
-
671
- uri = URI.parse(@rpc_url)
672
- http = Net::HTTP.new(uri.host, uri.port)
673
- http.use_ssl = (uri.scheme == "https")
674
- http.read_timeout = 10
675
- http.open_timeout = 5
676
-
677
- req = Net::HTTP::Post.new(uri.request_uri)
678
- req["Content-Type"] = "application/json"
679
- req.body = payload.to_json
680
-
681
- resp = http.request(req)
682
- result = JSON.parse(resp.body)
683
-
684
- if result["error"]
685
- msg = result["error"].is_a?(Hash) ? result["error"]["message"] : result["error"].to_s
686
- raise RemitError.new(RemitError::NETWORK_ERROR, "RPC error fetching nonce: #{msg}")
685
+ data = @transport.get("/status/#{address}")
686
+ nonce = data.is_a?(Hash) ? data["permit_nonce"] : nil
687
+ if nonce.nil?
688
+ raise RemitError.new(
689
+ RemitError::NETWORK_ERROR,
690
+ "permit_nonce not available from API for #{address}. " \
691
+ "Ensure the server supports the permit_nonce field in GET /api/v1/status.",
692
+ context: { address: address }
693
+ )
687
694
  end
688
-
689
- (result["result"] || "0x0").to_i(16)
695
+ nonce.to_i
690
696
  end
691
697
 
692
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.
693
700
  # @param contract [String] contract key — "router", "escrow", "tab", etc.
694
701
  # @param amount [Numeric] amount in USDC
695
- # @return [PermitSignature]
702
+ # @return [PermitSignature, nil]
696
703
  def auto_permit(contract, amount)
697
704
  contracts = get_contracts
698
705
  spender = contracts.send(contract.to_sym)
699
- raise RemitError.new(
700
- RemitError::SERVER_ERROR,
701
- "No #{contract} contract address available"
702
- ) unless spender
706
+ return nil unless spender
703
707
 
704
708
  sign_permit(spender, amount)
709
+ rescue => e
710
+ warn "[remitmd] auto-permit failed for #{contract} (amount=#{amount}): #{e.message}"
711
+ nil
705
712
  end
706
713
 
707
714
  # Spender contract mapping for auto_permit.