remitmd 0.1.2 → 0.1.3

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: 56eaf04c2c080f030bbcb4060933868af74915180552e0298dd104a494695ad3
4
- data.tar.gz: 1c361a1a7f2151eba09f157e5e468d5f31e9a5094accf739f893eb4e66233d6e
3
+ metadata.gz: 92f3192f6e981e0a39fd2faf98437858b5b25f8f462ae4daea65163c1b9d210b
4
+ data.tar.gz: 2d20d2c5dfda21c3ce11ff9876a8d27a488076091535b7186d3d4252b7f78b7d
5
5
  SHA512:
6
- metadata.gz: 2f1d6a5f15272787c10b6eebc483aa51b18a10c88345d3dbccf6ad2624406010c47861b96afaf7ddeb380385760541a0226b2d3fb173c7c71a1f3917a0670e14
7
- data.tar.gz: 7b0082e9088121b98d97504bd4f879ec46a1670b73c902481ee556128a91cff5243da8d0c43510c7fe2095616a3ca56628f88a8f2584604874ec4b1d2e7b3bd7
6
+ metadata.gz: 8c90a39e2ce798a689270c82ec4cc8e69a65494f3969f0f4263f0476decf45ffd5ee2886574992113dd467dc50946e7bf00213ae81ec9e3f01e89c3db7a9a820
7
+ data.tar.gz: a36878cebc127b81bdf28ea8a5b9e9a9a1c4dd809e3ca59a0ef54c4060fd4c0216e3ccd2af87b9be1214fbc3a6958d74a833f2b67f9e794c62fd629a09da7a62
data/README.md CHANGED
@@ -38,26 +38,17 @@ Or from environment variables:
38
38
  ```ruby
39
39
  wallet = Remitmd::RemitWallet.from_env
40
40
  # Requires: REMITMD_PRIVATE_KEY
41
- # Optional: REMITMD_CHAIN (default: "base"), REMITMD_API_URL
41
+ # Optional: REMITMD_CHAIN (default: "base"), REMITMD_API_URL, REMITMD_RPC_URL
42
42
  ```
43
43
 
44
- ## Permits (Gasless USDC Approval)
45
-
46
- ```ruby
47
- contracts = wallet.get_contracts
48
-
49
- # Use permits on any payment method
50
- tx = wallet.pay("0xRecipient...", 5.00, permit: permit)
51
- ```
52
-
53
- Permits are optional on: `pay`, `create_escrow`, `create_tab`, `create_stream`, `create_bounty`, `place_deposit`.
44
+ Permits are auto-signed. Every payment method fetches the on-chain USDC nonce, signs an EIP-2612 permit, and includes it automatically.
54
45
 
55
46
  ## Payment Models
56
47
 
57
48
  ### Direct Payment
58
49
 
59
50
  ```ruby
60
- tx = wallet.pay("0xRecipient...", 5.00, memo: "AI inference fee", permit: permit)
51
+ tx = wallet.pay("0xRecipient...", 5.00, memo: "AI inference fee")
61
52
  ```
62
53
 
63
54
  ### Escrow
@@ -73,10 +64,11 @@ tx = wallet.cancel_escrow(escrow.id) # refund yourself
73
64
  ### Metered Tab (off-chain billing)
74
65
 
75
66
  ```ruby
76
- tab = wallet.create_tab("0xProvider...", 50.00, 0.003, permit: permit)
67
+ tab = wallet.create_tab("0xProvider...", 50.00, 0.003)
77
68
 
78
69
  # Provider charges with EIP-712 signature
79
- sig = wallet.sign_tab_charge(contracts["tab"], tab.id, 3_000_000, 1)
70
+ contracts = wallet.get_contracts
71
+ sig = wallet.sign_tab_charge(contracts.tab, tab.id, 3_000_000, 1)
80
72
  wallet.charge_tab(tab.id, 0.003, 0.003, 1, sig)
81
73
 
82
74
  # Close when done — unused funds return
@@ -104,7 +96,7 @@ tx = wallet.award_bounty(bounty.id, "0xWinner...")
104
96
  ### Security Deposit
105
97
 
106
98
  ```ruby
107
- dep = wallet.place_deposit("0xCounterpart...", 100.00, expires_in_secs: 86_400, permit: permit)
99
+ dep = wallet.place_deposit("0xCounterpart...", 100.00, expires_in_secs: 86_400)
108
100
  wallet.return_deposit(dep.id)
109
101
  ```
110
102
 
@@ -181,6 +173,10 @@ wallet.close_tab(tab_id, final_amount: nil, provider_sig: nil)
181
173
  # Tab provider (signing charges)
182
174
  wallet.sign_tab_charge(tab_contract, tab_id, total_charged, call_count) # String
183
175
 
176
+ # EIP-2612 Permit (auto-signed when omitted from payment methods)
177
+ wallet.sign_permit(spender, amount, deadline: nil) # PermitSignature
178
+ wallet.sign_usdc_permit(spender, value, deadline, nonce, usdc_address: nil) # PermitSignature
179
+
184
180
  # Streams
185
181
  wallet.create_stream(payee, rate_per_second, max_total, permit: nil) # Stream
186
182
  wallet.close_stream(stream_id) # Stream
@@ -249,6 +245,41 @@ Remitmd::RemitWallet.new(private_key: key, chain: "base") # Base mainne
249
245
  Remitmd::RemitWallet.new(private_key: key, chain: "base_sepolia") # Base Sepolia testnet
250
246
  ```
251
247
 
248
+ ## Advanced
249
+
250
+ ### Manual Permit Signing
251
+
252
+ Permits are auto-signed by default. If you need manual control (custom deadline, pre-signed permits, or offline signing), pass a `PermitSignature` explicitly:
253
+
254
+ ```ruby
255
+ # sign_permit: convenience — auto-fetches nonce, converts amount to base units
256
+ permit = wallet.sign_permit("0xRouterAddress...", 5.00, deadline: Time.now.to_i + 7200)
257
+ tx = wallet.pay("0xRecipient...", 5.00, permit: permit)
258
+
259
+ # sign_usdc_permit: full control — raw base units, explicit nonce
260
+ permit = wallet.sign_usdc_permit(
261
+ "0xRouterAddress...", # spender
262
+ 5_000_000, # value in base units (6 decimals)
263
+ Time.now.to_i + 3600, # deadline
264
+ 0, # nonce
265
+ usdc_address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
266
+ )
267
+ tx = wallet.pay("0xRecipient...", 5.00, permit: permit)
268
+ ```
269
+
270
+ ### Custom RPC URL
271
+
272
+ Override the JSON-RPC endpoint used for nonce fetching:
273
+
274
+ ```ruby
275
+ wallet = Remitmd::RemitWallet.new(
276
+ private_key: key,
277
+ chain: "base_sepolia",
278
+ rpc_url: "https://your-rpc-provider.com/v1/base-sepolia"
279
+ )
280
+ # Or via environment: REMITMD_RPC_URL
281
+ ```
282
+
252
283
  ## License
253
284
 
254
285
  MIT — see [LICENSE](LICENSE)
data/lib/remitmd/mock.rb CHANGED
@@ -106,7 +106,7 @@ module Remitmd
106
106
  attr_reader :address
107
107
 
108
108
  def sign(_message)
109
- "0x" + SecureRandom.hex(32)
109
+ "0x" + SecureRandom.hex(32) + SecureRandom.hex(32) + "1b"
110
110
  end
111
111
  end
112
112
 
@@ -135,6 +135,22 @@ module Remitmd
135
135
  def handle(method, path, b) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
136
136
  case [method, path]
137
137
 
138
+ # Contracts (for auto_permit)
139
+ in ["GET", "/contracts"]
140
+ {
141
+ "chain_id" => ChainId::BASE_SEPOLIA,
142
+ "usdc" => "0xMockUSDC0000000000000000000000000000001",
143
+ "router" => "0xMockRouter000000000000000000000000001",
144
+ "escrow" => "0xMockEscrow000000000000000000000000001",
145
+ "tab" => "0xMockTab00000000000000000000000000001",
146
+ "stream" => "0xMockStream000000000000000000000000001",
147
+ "bounty" => "0xMockBounty000000000000000000000000001",
148
+ "deposit" => "0xMockDeposit00000000000000000000000001",
149
+ "fee_calculator" => "0xMockFeeCalc0000000000000000000000001",
150
+ "key_registry" => "0xMockKeyReg000000000000000000000000001",
151
+ "arbitration" => "0xMockArbitr000000000000000000000000001",
152
+ }
153
+
138
154
  # Balance
139
155
  in ["GET", "/wallet/balance"]
140
156
  {
@@ -2,8 +2,25 @@
2
2
 
3
3
  require "bigdecimal"
4
4
  require "bigdecimal/util"
5
+ require "net/http"
6
+ require "uri"
7
+ require "json"
5
8
 
6
9
  module Remitmd
10
+ # Known USDC contract addresses per chain (EIP-2612 compatible).
11
+ USDC_ADDRESSES = {
12
+ "base-sepolia" => "0x142aD61B8d2edD6b3807D9266866D97C35Ee0317",
13
+ "base" => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
14
+ "localhost" => "0x5FbDB2315678afecb367f032d93F642f64180aa3",
15
+ }.freeze
16
+
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
+
7
24
  # Primary remit.md client. All payment operations are methods on RemitWallet.
8
25
  #
9
26
  # @example Quickstart
@@ -22,12 +39,17 @@ module Remitmd
22
39
  # @param signer [Signer, nil] custom signer (pass instead of private_key)
23
40
  # @param chain [String] chain name — "base", "base_sepolia"
24
41
  # @param api_url [String, nil] override API base URL
42
+ # @param rpc_url [String, nil] override JSON-RPC URL for on-chain queries
25
43
  # @param transport [Object, nil] inject mock transport (used by MockRemit)
26
- def initialize(private_key: nil, signer: nil, chain: "base", api_url: nil, router_address: nil, transport: nil)
44
+ def initialize(private_key: nil, signer: nil, chain: "base", api_url: nil, router_address: nil, rpc_url: nil, transport: nil)
27
45
  if transport
28
46
  # MockRemit path: transport + signer injected directly
29
47
  @signer = signer
30
48
  @transport = transport
49
+ @chain_key = "base-sepolia"
50
+ @chain_id = ChainId::BASE_SEPOLIA
51
+ @rpc_url = DEFAULT_RPC_URLS["base-sepolia"]
52
+ @mock_mode = true
31
53
  return
32
54
  end
33
55
 
@@ -39,6 +61,8 @@ module Remitmd
39
61
  end
40
62
 
41
63
  @signer = signer || PrivateKeySigner.new(private_key)
64
+ # Normalize chain key for USDC/RPC lookups (underscore → hyphen).
65
+ @chain_key = chain.tr("_", "-")
42
66
  # Normalize to the base chain name (strip testnet suffix) for use in pay body.
43
67
  # The server accepts "base" — not "base_sepolia" etc.
44
68
  @chain = chain.sub(/_sepolia\z/, "").sub(/-sepolia\z/, "")
@@ -46,24 +70,26 @@ module Remitmd
46
70
  raise ArgumentError, "Unknown chain: #{chain}. Valid: #{CHAIN_CONFIG.keys.join(", ")}"
47
71
  end
48
72
  base_url = api_url || cfg[:url]
49
- chain_id = cfg[:chain_id]
73
+ @chain_id = cfg[:chain_id]
50
74
  router_address ||= ""
75
+ @rpc_url = rpc_url || ENV["REMITMD_RPC_URL"] || DEFAULT_RPC_URLS[@chain_key] || DEFAULT_RPC_URLS["base-sepolia"]
51
76
  @transport = HttpTransport.new(
52
77
  base_url: base_url,
53
78
  signer: @signer,
54
- chain_id: chain_id,
79
+ chain_id: @chain_id,
55
80
  router_address: router_address
56
81
  )
57
82
  end
58
83
 
59
84
  # Build a RemitWallet from environment variables.
60
- # Reads: REMITMD_PRIVATE_KEY, REMITMD_CHAIN, REMITMD_API_URL, REMITMD_ROUTER_ADDRESS.
85
+ # Reads: REMITMD_PRIVATE_KEY, REMITMD_CHAIN, REMITMD_API_URL, REMITMD_ROUTER_ADDRESS, REMITMD_RPC_URL.
61
86
  def self.from_env
62
87
  key = ENV.fetch("REMITMD_PRIVATE_KEY") { raise ArgumentError, "REMITMD_PRIVATE_KEY not set" }
63
88
  chain = ENV.fetch("REMITMD_CHAIN", "base")
64
89
  api_url = ENV["REMITMD_API_URL"]
65
90
  router_address = ENV["REMITMD_ROUTER_ADDRESS"]
66
- new(private_key: key, chain: chain, api_url: api_url, router_address: 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)
67
93
  end
68
94
 
69
95
  # The Ethereum address associated with this wallet.
@@ -128,14 +154,15 @@ module Remitmd
128
154
  # @param to [String] recipient 0x-prefixed address
129
155
  # @param amount [Numeric, BigDecimal] amount in USDC (e.g. 1.50)
130
156
  # @param memo [String, nil] optional note
131
- # @param permit [PermitSignature, nil] optional EIP-2612 permit for gasless approval
157
+ # @param permit [PermitSignature, nil] EIP-2612 permit auto-signed if nil
132
158
  # @return [Transaction]
133
159
  def pay(to, amount, memo: nil, permit: nil)
134
160
  validate_address!(to)
135
161
  validate_amount!(amount)
162
+ resolved = permit || auto_permit("router", amount.to_f)
136
163
  nonce = SecureRandom.hex(16)
137
164
  body = { to: to, amount: amount.to_s, task: memo || "", chain: @chain, nonce: nonce, signature: "0x" }
138
- body[:permit] = permit.to_h if permit
165
+ body[:permit] = resolved.to_h
139
166
  Transaction.new(@transport.post("/payments/direct", body))
140
167
  end
141
168
 
@@ -146,11 +173,12 @@ module Remitmd
146
173
  # @param amount [Numeric] amount in USDC
147
174
  # @param memo [String, nil] optional note
148
175
  # @param expires_in_secs [Integer, nil] optional expiry in seconds from now
149
- # @param permit [PermitSignature, nil] optional EIP-2612 permit for gasless approval
176
+ # @param permit [PermitSignature, nil] EIP-2612 permit auto-signed if nil
150
177
  # @return [Escrow]
151
178
  def create_escrow(payee, amount, memo: nil, expires_in_secs: nil, permit: nil)
152
179
  validate_address!(payee)
153
180
  validate_amount!(amount)
181
+ resolved = permit || auto_permit("escrow", amount.to_f)
154
182
 
155
183
  # Step 1: create invoice on server.
156
184
  invoice_id = SecureRandom.hex(16)
@@ -165,8 +193,7 @@ module Remitmd
165
193
  @transport.post("/invoices", inv_body)
166
194
 
167
195
  # Step 2: fund the escrow.
168
- esc_body = { invoice_id: invoice_id }
169
- esc_body[:permit] = permit.to_h if permit
196
+ esc_body = { invoice_id: invoice_id, permit: resolved.to_h }
170
197
  Escrow.new(@transport.post("/escrows", esc_body))
171
198
  end
172
199
 
@@ -207,19 +234,20 @@ module Remitmd
207
234
  # @param limit_amount [Numeric] maximum tab credit in USDC
208
235
  # @param per_unit [Numeric] USDC per API call
209
236
  # @param expires_in_secs [Integer] optional expiry duration in seconds (default: 86400)
210
- # @param permit [PermitSignature, nil] optional EIP-2612 permit for gasless approval
237
+ # @param permit [PermitSignature, nil] EIP-2612 permit auto-signed if nil
211
238
  # @return [Tab]
212
239
  def create_tab(provider, limit_amount, per_unit = 0.0, expires_in_secs: 86_400, permit: nil)
213
240
  validate_address!(provider)
214
241
  validate_amount!(limit_amount)
242
+ resolved = permit || auto_permit("tab", limit_amount.to_f)
215
243
  body = {
216
244
  chain: @chain,
217
245
  provider: provider,
218
246
  limit_amount: limit_amount.to_f,
219
247
  per_unit: per_unit.to_f,
220
- expiry: Time.now.to_i + expires_in_secs
248
+ expiry: Time.now.to_i + expires_in_secs,
249
+ permit: resolved.to_h
221
250
  }
222
- body[:permit] = permit.to_h if permit
223
251
  Tab.new(@transport.post("/tabs", body))
224
252
  end
225
253
 
@@ -310,25 +338,92 @@ module Remitmd
310
338
  @signer.sign(digest)
311
339
  end
312
340
 
341
+ # ─── EIP-2612 Permit ─────────────────────────────────────────────────────
342
+
343
+ # Sign an EIP-2612 permit for USDC approval.
344
+ # Domain: name="USD Coin", version="2", chainId, verifyingContract=USDC address
345
+ # Type: Permit(address owner, address spender, uint256 value, uint256 nonce, uint256 deadline)
346
+ # @param spender [String] contract address that will be approved
347
+ # @param value [Integer] amount in USDC base units (6 decimals)
348
+ # @param deadline [Integer] permit deadline (Unix timestamp)
349
+ # @param nonce [Integer] current permit nonce for this wallet
350
+ # @param usdc_address [String, nil] override the USDC contract address
351
+ # @return [PermitSignature]
352
+ def sign_usdc_permit(spender, value, deadline, nonce = 0, usdc_address: nil)
353
+ usdc_addr = usdc_address || USDC_ADDRESSES[@chain_key] || ""
354
+ chain_id = @chain_id || ChainId::BASE_SEPOLIA
355
+
356
+ # Domain separator for USDC (EIP-2612)
357
+ domain_type_hash = keccak256_raw(
358
+ "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
359
+ )
360
+ name_hash = keccak256_raw("USD Coin")
361
+ version_hash = keccak256_raw("2")
362
+ chain_id_enc = abi_uint256(chain_id)
363
+ contract_enc = abi_address(usdc_addr)
364
+
365
+ domain_data = domain_type_hash + name_hash + version_hash + chain_id_enc + contract_enc
366
+ domain_sep = keccak256_raw(domain_data)
367
+
368
+ # Permit struct hash
369
+ type_hash = keccak256_raw(
370
+ "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
371
+ )
372
+ owner_enc = abi_address(address)
373
+ spender_enc = abi_address(spender)
374
+ value_enc = abi_uint256(value)
375
+ nonce_enc = abi_uint256(nonce)
376
+ deadline_enc = abi_uint256(deadline)
377
+
378
+ struct_data = type_hash + owner_enc + spender_enc + value_enc + nonce_enc + deadline_enc
379
+ struct_hash = keccak256_raw(struct_data)
380
+
381
+ # EIP-712 digest
382
+ digest = keccak256_raw("\x19\x01".b + domain_sep + struct_hash)
383
+ sig_hex = @signer.sign(digest)
384
+
385
+ # Parse r, s, v from the 65-byte signature
386
+ sig_bytes = sig_hex.delete_prefix("0x")
387
+ r = "0x#{sig_bytes[0, 64]}"
388
+ s = "0x#{sig_bytes[64, 64]}"
389
+ v = sig_bytes[128, 2].to_i(16)
390
+
391
+ PermitSignature.new(value: value, deadline: deadline, v: v, r: r, s: s)
392
+ end
393
+
394
+ # Convenience: sign a USDC permit. Auto-fetches nonce, defaults deadline to 1 hour.
395
+ # @param spender [String] contract address to approve (e.g. router, escrow)
396
+ # @param amount [Numeric] amount in USDC (e.g. 5.0 for $5.00)
397
+ # @param deadline [Integer, nil] optional Unix timestamp; defaults to 1 hour from now
398
+ # @return [PermitSignature]
399
+ def sign_permit(spender, amount, deadline: nil)
400
+ usdc_addr = USDC_ADDRESSES[@chain_key] || ""
401
+ nonce = fetch_usdc_nonce(usdc_addr)
402
+ dl = deadline || (Time.now.to_i + 3600)
403
+ raw = (amount * 1_000_000).round.to_i
404
+ sign_usdc_permit(spender, raw, dl, nonce, usdc_address: usdc_addr)
405
+ end
406
+
313
407
  # ─── Streams (Payment Streaming) ─────────────────────────────────────────
314
408
 
315
409
  # Create a real-time payment stream.
316
410
  # @param payee [String] 0x-prefixed address of the stream recipient
317
411
  # @param rate_per_second [Numeric] USDC per second
318
412
  # @param max_total [Numeric] maximum total USDC for the stream
319
- # @param permit [PermitSignature, nil] optional EIP-2612 permit for gasless approval
413
+ # @param permit [PermitSignature, nil] EIP-2612 permit auto-signed if nil
320
414
  # @return [Stream]
321
415
  def create_stream(payee, rate_per_second, max_total, permit: nil)
322
416
  validate_address!(payee)
323
417
  validate_amount!(rate_per_second)
324
418
  validate_amount!(max_total)
419
+ resolved = permit || auto_permit("stream", max_total.to_f)
325
420
  body = {
326
421
  chain: @chain,
327
422
  payee: payee,
328
423
  rate_per_second: rate_per_second.to_s,
329
- max_total: max_total.to_s
424
+ max_total: max_total.to_s,
425
+ permit: resolved.to_h
330
426
  }
331
- body[:permit] = permit.to_h if permit
332
427
  Stream.new(@transport.post("/streams", body))
333
428
  end
334
429
 
@@ -353,18 +448,19 @@ module Remitmd
353
448
  # @param task_description [String] task description
354
449
  # @param deadline [Integer] deadline as Unix timestamp
355
450
  # @param max_attempts [Integer] maximum submission attempts (default: 10)
356
- # @param permit [PermitSignature, nil] optional EIP-2612 permit for gasless approval
451
+ # @param permit [PermitSignature, nil] EIP-2612 permit auto-signed if nil
357
452
  # @return [Bounty]
358
453
  def create_bounty(amount, task_description, deadline, max_attempts: 10, permit: nil)
359
454
  validate_amount!(amount)
455
+ resolved = permit || auto_permit("bounty", amount.to_f)
360
456
  body = {
361
457
  chain: @chain,
362
458
  amount: amount.to_f,
363
459
  task_description: task_description,
364
460
  deadline: deadline,
365
- max_attempts: max_attempts
461
+ max_attempts: max_attempts,
462
+ permit: resolved.to_h
366
463
  }
367
- body[:permit] = permit.to_h if permit
368
464
  Bounty.new(@transport.post("/bounties", body))
369
465
  end
370
466
 
@@ -406,18 +502,19 @@ module Remitmd
406
502
  # @param provider [String] 0x-prefixed provider address
407
503
  # @param amount [Numeric] amount in USDC
408
504
  # @param expires_in_secs [Integer] expiry duration in seconds (default: 3600)
409
- # @param permit [PermitSignature, nil] optional EIP-2612 permit for gasless approval
505
+ # @param permit [PermitSignature, nil] EIP-2612 permit auto-signed if nil
410
506
  # @return [Deposit]
411
507
  def place_deposit(provider, amount, expires_in_secs: 3600, permit: nil)
412
508
  validate_address!(provider)
413
509
  validate_amount!(amount)
510
+ resolved = permit || auto_permit("deposit", amount.to_f)
414
511
  body = {
415
512
  chain: @chain,
416
513
  provider: provider,
417
514
  amount: amount.to_f,
418
- expiry: Time.now.to_i + expires_in_secs
515
+ expiry: Time.now.to_i + expires_in_secs,
516
+ permit: resolved.to_h
419
517
  }
420
- body[:permit] = permit.to_h if permit
421
518
  Deposit.new(@transport.post("/deposits", body))
422
519
  end
423
520
 
@@ -464,15 +561,25 @@ module Remitmd
464
561
  # ─── One-time operator links ───────────────────────────────────────────────
465
562
 
466
563
  # Generate a one-time URL for the operator to fund this wallet.
564
+ # @param messages [Array<Hash>, nil] chat-style messages (each with :role and :text)
565
+ # @param agent_name [String, nil] agent display name shown on the funding page
467
566
  # @return [LinkResponse]
468
- def create_fund_link
469
- LinkResponse.new(@transport.post("/links/fund", {}))
567
+ def create_fund_link(messages: nil, agent_name: nil)
568
+ body = {}
569
+ body[:messages] = messages if messages
570
+ body[:agent_name] = agent_name if agent_name
571
+ LinkResponse.new(@transport.post("/links/fund", body))
470
572
  end
471
573
 
472
574
  # Generate a one-time URL for the operator to withdraw funds.
575
+ # @param messages [Array<Hash>, nil] chat-style messages (each with :role and :text)
576
+ # @param agent_name [String, nil] agent display name shown on the withdraw page
473
577
  # @return [LinkResponse]
474
- def create_withdraw_link
475
- LinkResponse.new(@transport.post("/links/withdraw", {}))
578
+ def create_withdraw_link(messages: nil, agent_name: nil)
579
+ body = {}
580
+ body[:messages] = messages if messages
581
+ body[:agent_name] = agent_name if agent_name
582
+ LinkResponse.new(@transport.post("/links/withdraw", body))
476
583
  end
477
584
 
478
585
  # ─── Testnet ──────────────────────────────────────────────────────────────
@@ -514,7 +621,7 @@ module Remitmd
514
621
  )
515
622
  end
516
623
 
517
- # ─── EIP-712 helpers (used by sign_tab_charge) ────────────────────────
624
+ # ─── EIP-712 helpers (used by sign_tab_charge / sign_usdc_permit) ─────
518
625
 
519
626
  def keccak256_raw(data)
520
627
  Remitmd::Keccak.digest(data.b)
@@ -528,5 +635,69 @@ module Remitmd
528
635
  hex = addr.to_s.delete_prefix("0x").rjust(64, "0")
529
636
  [hex].pack("H*")
530
637
  end
638
+
639
+ # ─── Permit helpers ──────────────────────────────────────────────────
640
+
641
+ # Fetch the current EIP-2612 nonce for this wallet from the USDC contract.
642
+ # Uses JSON-RPC eth_call with selector 0x7ecebe00 (nonces(address)).
643
+ # @param usdc_address [String] the USDC contract address
644
+ # @return [Integer] current nonce
645
+ def fetch_usdc_nonce(usdc_address)
646
+ return 0 if @mock_mode
647
+
648
+ padded = address.downcase.delete_prefix("0x").rjust(64, "0")
649
+ data = "0x7ecebe00#{padded}"
650
+ payload = {
651
+ jsonrpc: "2.0",
652
+ id: 1,
653
+ method: "eth_call",
654
+ params: [{ to: usdc_address, data: data }, "latest"]
655
+ }
656
+
657
+ uri = URI.parse(@rpc_url)
658
+ http = Net::HTTP.new(uri.host, uri.port)
659
+ http.use_ssl = (uri.scheme == "https")
660
+ http.read_timeout = 10
661
+ http.open_timeout = 5
662
+
663
+ req = Net::HTTP::Post.new(uri.request_uri)
664
+ req["Content-Type"] = "application/json"
665
+ req.body = payload.to_json
666
+
667
+ resp = http.request(req)
668
+ result = JSON.parse(resp.body)
669
+
670
+ if result["error"]
671
+ msg = result["error"].is_a?(Hash) ? result["error"]["message"] : result["error"].to_s
672
+ raise RemitError.new(RemitError::NETWORK_ERROR, "RPC error fetching nonce: #{msg}")
673
+ end
674
+
675
+ (result["result"] || "0x0").to_i(16)
676
+ end
677
+
678
+ # Auto-sign a permit for the given contract type and amount.
679
+ # @param contract [String] contract key — "router", "escrow", "tab", etc.
680
+ # @param amount [Numeric] amount in USDC
681
+ # @return [PermitSignature]
682
+ def auto_permit(contract, amount)
683
+ contracts = get_contracts
684
+ spender = contracts.send(contract.to_sym)
685
+ raise RemitError.new(
686
+ RemitError::SERVER_ERROR,
687
+ "No #{contract} contract address available"
688
+ ) unless spender
689
+
690
+ sign_permit(spender, amount)
691
+ end
692
+
693
+ # Spender contract mapping for auto_permit.
694
+ PERMIT_SPENDER = {
695
+ pay: :router,
696
+ create_escrow: :escrow,
697
+ create_tab: :tab,
698
+ create_stream: :stream,
699
+ create_bounty: :bounty,
700
+ place_deposit: :deposit,
701
+ }.freeze
531
702
  end
532
703
  end
data/lib/remitmd.rb CHANGED
@@ -22,5 +22,5 @@ require_relative "remitmd/mock"
22
22
  # mock.was_paid?("0x0000000000000000000000000000000000000001", 1.00) # => true
23
23
  #
24
24
  module Remitmd
25
- VERSION = "0.1.2"
25
+ VERSION = "0.1.3"
26
26
  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.2
4
+ version: 0.1.3
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-19 00:00:00.000000000 Z
11
+ date: 2026-03-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec