remitmd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,623 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+ require "bigdecimal"
6
+ require "bigdecimal/util"
7
+
8
+ module Remitmd
9
+ # In-memory test double for remit.md. Zero network, zero latency, deterministic.
10
+ #
11
+ # MockRemit gives you a RemitWallet backed by in-memory state so you can test
12
+ # your agent's payment logic without a live API or spending real USDC.
13
+ #
14
+ # @example
15
+ # mock = Remitmd::MockRemit.new
16
+ # wallet = mock.wallet
17
+ #
18
+ # wallet.pay("0x0000000000000000000000000000000000000001", 1.50)
19
+ #
20
+ # mock.was_paid?("0x0000000000000000000000000000000000000001", 1.50) # => true
21
+ # mock.total_paid_to("0x0000000000000000000000000000000000000001") # => BigDecimal("1.5")
22
+ # mock.transaction_count # => 1
23
+ class MockRemit
24
+ MOCK_ADDRESS = "0xMockWallet0000000000000000000000000001"
25
+ DEFAULT_BALANCE = BigDecimal("10000")
26
+
27
+ def initialize(balance: DEFAULT_BALANCE)
28
+ @mutex = Mutex.new
29
+ @state = initial_state(BigDecimal(balance.to_s))
30
+ end
31
+
32
+ # Return a RemitWallet backed by this mock. No private key required.
33
+ def wallet
34
+ RemitWallet.new(signer: MockSigner.new(MOCK_ADDRESS), transport: MockTransport.new(@state, @mutex))
35
+ end
36
+
37
+ # Reset all state. Call between test cases to prevent state leakage.
38
+ def reset
39
+ @mutex.synchronize { @state.replace(initial_state(DEFAULT_BALANCE)) }
40
+ end
41
+
42
+ # Override the simulated USDC balance.
43
+ def set_balance(amount)
44
+ @mutex.synchronize { @state[:balance] = BigDecimal(amount.to_s) }
45
+ end
46
+
47
+ # Current mock balance.
48
+ def balance
49
+ @mutex.synchronize { @state[:balance] }
50
+ end
51
+
52
+ # All transactions recorded.
53
+ def transactions
54
+ @mutex.synchronize { @state[:transactions].dup }
55
+ end
56
+
57
+ # Total number of transactions recorded.
58
+ def transaction_count
59
+ @mutex.synchronize { @state[:transactions].length }
60
+ end
61
+
62
+ # True if a payment of exactly +amount+ USDC was sent to +recipient+.
63
+ def was_paid?(recipient, amount)
64
+ d = BigDecimal(amount.to_s)
65
+ @mutex.synchronize do
66
+ @state[:transactions].any? do |tx|
67
+ tx.to.casecmp(recipient).zero? && tx.amount == d
68
+ end
69
+ end
70
+ end
71
+
72
+ # Sum of all USDC sent to +recipient+.
73
+ def total_paid_to(recipient)
74
+ @mutex.synchronize do
75
+ @state[:transactions]
76
+ .select { |tx| tx.to.casecmp(recipient).zero? }
77
+ .sum(BigDecimal("0"), &:amount)
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def initial_state(balance)
84
+ {
85
+ balance: balance,
86
+ transactions: [],
87
+ escrows: {},
88
+ tabs: {},
89
+ streams: {},
90
+ bounties: {},
91
+ deposits: {},
92
+ pending_invoices: {},
93
+ }
94
+ end
95
+ end
96
+
97
+ # ─── Internal: MockSigner ──────────────────────────────────────────────────
98
+
99
+ class MockSigner
100
+ include Signer
101
+
102
+ def initialize(addr)
103
+ @address = addr
104
+ end
105
+
106
+ attr_reader :address
107
+
108
+ def sign(_message)
109
+ "0x" + SecureRandom.hex(32)
110
+ end
111
+ end
112
+
113
+ # ─── Internal: MockTransport ───────────────────────────────────────────────
114
+
115
+ class MockTransport
116
+ def initialize(state, mutex)
117
+ @state = state
118
+ @mutex = mutex
119
+ end
120
+
121
+ def get(path)
122
+ dispatch("GET", path, nil)
123
+ end
124
+
125
+ def post(path, body = nil)
126
+ dispatch("POST", path, body)
127
+ end
128
+
129
+ private
130
+
131
+ def dispatch(method, path, body)
132
+ @mutex.synchronize { handle(method, path, body || {}) }
133
+ end
134
+
135
+ def handle(method, path, b) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
136
+ case [method, path]
137
+
138
+ # Balance
139
+ in ["GET", "/wallet/balance"]
140
+ {
141
+ "usdc" => @state[:balance].to_s("F"),
142
+ "address" => MockRemit::MOCK_ADDRESS,
143
+ "chain_id" => ChainId::BASE_SEPOLIA,
144
+ "updated_at" => now,
145
+ }
146
+
147
+ # Direct payment
148
+ in ["POST", "/payments/direct"]
149
+ to = fetch!(b, :to)
150
+ amount = decimal!(b, :amount)
151
+ check_balance!(amount)
152
+ @state[:balance] -= amount
153
+ tx = make_tx(to: to, amount: amount, memo: b[:memo].to_s)
154
+ @state[:transactions] << tx
155
+ tx_hash(tx)
156
+
157
+ # Invoice create (step 1 of escrow)
158
+ in ["POST", "/invoices"]
159
+ id = fetch!(b, :id)
160
+ @state[:pending_invoices][id] = b
161
+ { "id" => id, "status" => "pending" }
162
+
163
+ # Escrow create (step 2 — fund with invoice_id)
164
+ in ["POST", "/escrows"]
165
+ invoice_id = fetch!(b, :invoice_id)
166
+ inv = @state[:pending_invoices].delete(invoice_id)
167
+ raise not_found(RemitError::ESCROW_NOT_FOUND, invoice_id) unless inv
168
+ payee = (inv[:to_agent] || inv["to_agent"]).to_s
169
+ amount = decimal!(inv, :amount)
170
+ memo = (inv[:task] || inv["task"]).to_s
171
+ check_balance!(amount)
172
+ @state[:balance] -= amount
173
+ esc = make_escrow(payee: payee, amount: amount, memo: memo, id: invoice_id)
174
+ @state[:escrows][esc.id] = esc
175
+ escrow_hash(esc)
176
+
177
+ # Escrow claim-start
178
+ in ["POST", path] if path.end_with?("/claim-start") && path.include?("/escrows/")
179
+ id = extract_id(path, "/escrows/", "/claim-start")
180
+ esc = @state[:escrows].fetch(id) { raise not_found(RemitError::ESCROW_NOT_FOUND, id) }
181
+ escrow_hash(esc)
182
+
183
+ # Escrow release
184
+ in ["POST", path] if path.end_with?("/release") && path.include?("/escrows/")
185
+ id = extract_id(path, "/escrows/", "/release")
186
+ esc = @state[:escrows].fetch(id) { raise not_found(RemitError::ESCROW_NOT_FOUND, id) }
187
+ new_esc = update_escrow(esc, status: EscrowStatus::RELEASED)
188
+ @state[:escrows][id] = new_esc
189
+ tx = make_tx(from: esc.payer, to: esc.payee, amount: esc.amount)
190
+ @state[:transactions] << tx
191
+ tx_hash(tx)
192
+
193
+ # Escrow cancel
194
+ in ["POST", path] if path.end_with?("/cancel") && path.include?("/escrows/")
195
+ id = extract_id(path, "/escrows/", "/cancel")
196
+ esc = @state[:escrows].fetch(id) { raise not_found(RemitError::ESCROW_NOT_FOUND, id) }
197
+ new_esc = update_escrow(esc, status: EscrowStatus::CANCELLED)
198
+ @state[:escrows][id] = new_esc
199
+ @state[:balance] += esc.amount
200
+ tx = make_tx(from: esc.payer, to: esc.payer, amount: esc.amount, memo: "escrow cancelled")
201
+ @state[:transactions] << tx
202
+ tx_hash(tx)
203
+
204
+ # Escrow get
205
+ in ["GET", path] if path.start_with?("/escrows/")
206
+ id = path.delete_prefix("/escrows/")
207
+ esc = @state[:escrows].fetch(id) { raise not_found(RemitError::ESCROW_NOT_FOUND, id) }
208
+ escrow_hash(esc)
209
+
210
+ # Tab create
211
+ in ["POST", "/tabs"]
212
+ provider = fetch!(b, :provider)
213
+ limit = decimal!(b, :limit_amount)
214
+ tab = make_tab(provider: provider, limit: limit)
215
+ @state[:tabs][tab.id] = tab
216
+ tab_hash(tab)
217
+
218
+ # Tab debit (legacy off-chain)
219
+ in ["POST", path] if path.end_with?("/debit") && path.include?("/tabs/")
220
+ id = extract_id(path, "/tabs/", "/debit")
221
+ amount = decimal!(b, :amount)
222
+ tab = @state[:tabs].fetch(id) { raise not_found(RemitError::TAB_NOT_FOUND, id) }
223
+ new_tab = update_tab(tab, used: tab.used + amount, remaining: tab.remaining - amount)
224
+ @state[:tabs][id] = new_tab
225
+ {
226
+ "tab_id" => id,
227
+ "amount" => amount.to_s("F"),
228
+ "cumulative" => new_tab.used.to_s("F"),
229
+ "call_count" => 0,
230
+ "memo" => b[:memo].to_s,
231
+ "sequence" => 1,
232
+ "signature" => "0x00",
233
+ }
234
+
235
+ # Tab charge (EIP-712 signed)
236
+ in ["POST", path] if path.end_with?("/charge") && path.include?("/tabs/")
237
+ id = extract_id(path, "/tabs/", "/charge")
238
+ amount = decimal!(b, :amount)
239
+ tab = @state[:tabs].fetch(id) { raise not_found(RemitError::TAB_NOT_FOUND, id) }
240
+ new_tab = update_tab(tab, used: tab.used + amount, remaining: tab.remaining - amount)
241
+ @state[:tabs][id] = new_tab
242
+ {
243
+ "tab_id" => id,
244
+ "amount" => amount.to_s("F"),
245
+ "cumulative" => new_tab.used.to_s("F"),
246
+ "call_count" => (b[:call_count] || 1).to_i,
247
+ "memo" => b[:memo].to_s,
248
+ "sequence" => 1,
249
+ "signature" => "0x00",
250
+ }
251
+
252
+ # Tab close (settle)
253
+ in ["POST", path] if path.end_with?("/close") && path.include?("/tabs/")
254
+ id = extract_id(path, "/tabs/", "/close")
255
+ tab = @state[:tabs].fetch(id) { raise not_found(RemitError::TAB_NOT_FOUND, id) }
256
+ new_tab = update_tab(tab, status: TabStatus::SETTLED)
257
+ @state[:tabs][id] = new_tab
258
+ tab_hash(new_tab)
259
+
260
+ # Stream create
261
+ in ["POST", "/streams"]
262
+ payee = fetch!(b, :payee)
263
+ rate_per_second = decimal!(b, :rate_per_second)
264
+ max_total = decimal!(b, :max_total)
265
+ check_balance!(max_total)
266
+ @state[:balance] -= max_total
267
+ s = make_stream(recipient: payee, rate_per_sec: rate_per_second, deposited: max_total)
268
+ @state[:streams][s.id] = s
269
+ stream_hash(s)
270
+
271
+ # Stream close
272
+ in ["POST", path] if path.end_with?("/close") && path.include?("/streams/")
273
+ id = extract_id(path, "/streams/", "/close")
274
+ s = @state[:streams].fetch(id) { raise not_found(RemitError::STREAM_NOT_FOUND, id) }
275
+ stream_hash(s)
276
+
277
+ # Stream withdraw
278
+ in ["POST", path] if path.end_with?("/withdraw") && path.include?("/streams/")
279
+ id = extract_id(path, "/streams/", "/withdraw")
280
+ s = @state[:streams].fetch(id) { raise not_found(RemitError::STREAM_NOT_FOUND, id) }
281
+ tx = make_tx(from: s.sender, to: s.recipient, amount: s.deposited, memo: "stream withdraw")
282
+ @state[:transactions] << tx
283
+ tx_hash(tx)
284
+
285
+ # Bounty create
286
+ in ["POST", "/bounties"]
287
+ amount = decimal!(b, :amount)
288
+ task_description = fetch!(b, :task_description)
289
+ check_balance!(amount)
290
+ @state[:balance] -= amount
291
+ bnt = make_bounty(amount: amount, task_description: task_description)
292
+ @state[:bounties][bnt.id] = bnt
293
+ bounty_hash(bnt)
294
+
295
+ # Bounty submit
296
+ in ["POST", path] if path.end_with?("/submit") && path.include?("/bounties/")
297
+ id = extract_id(path, "/bounties/", "/submit")
298
+ bnt = @state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
299
+ {
300
+ "id" => 1,
301
+ "bounty_id" => id,
302
+ "submitter" => MockRemit::MOCK_ADDRESS,
303
+ "evidence_hash" => (b[:evidence_hash] || "0x00").to_s,
304
+ "status" => "pending",
305
+ "created_at" => now,
306
+ }
307
+
308
+ # Bounty award
309
+ in ["POST", path] if path.end_with?("/award") && path.include?("/bounties/")
310
+ id = extract_id(path, "/bounties/", "/award")
311
+ submission_id = (b[:submission_id] || b["submission_id"]).to_i
312
+ bnt = @state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
313
+ new_bnt = update_bounty(bnt, status: BountyStatus::AWARDED)
314
+ @state[:bounties][id] = new_bnt
315
+ bounty_hash(new_bnt)
316
+
317
+ # Deposit create
318
+ in ["POST", "/deposits"]
319
+ provider = fetch!(b, :provider)
320
+ amount = decimal!(b, :amount)
321
+ check_balance!(amount)
322
+ @state[:balance] -= amount
323
+ dep = make_deposit(provider: provider, amount: amount)
324
+ @state[:deposits][dep.id] = dep
325
+ deposit_hash(dep)
326
+
327
+ # Deposit return
328
+ in ["POST", path] if path.end_with?("/return") && path.include?("/deposits/")
329
+ id = extract_id(path, "/deposits/", "/return")
330
+ dep = @state[:deposits].fetch(id) { raise not_found(RemitError::DEPOSIT_NOT_FOUND, id) }
331
+ @state[:balance] += dep.amount
332
+ tx = make_tx(from: dep.provider, to: dep.depositor, amount: dep.amount, memo: "deposit returned")
333
+ @state[:transactions] << tx
334
+ tx_hash(tx)
335
+
336
+ # Reputation
337
+ in ["GET", path] if path.start_with?("/reputation/")
338
+ addr = path.delete_prefix("/reputation/")
339
+ {
340
+ "address" => addr,
341
+ "score" => 750,
342
+ "total_paid" => "1000.0",
343
+ "total_received" => "500.0",
344
+ "transaction_count" => 42,
345
+ "member_since" => now,
346
+ }
347
+
348
+ # Spending summary
349
+ in ["GET", "/wallet/spending"]
350
+ total = @state[:transactions].sum(BigDecimal("0"), &:amount)
351
+ count = @state[:transactions].length
352
+ {
353
+ "address" => MockRemit::MOCK_ADDRESS,
354
+ "period" => "month",
355
+ "total_spent" => total.to_s("F"),
356
+ "total_fees" => (BigDecimal("0.001") * count).to_s("F"),
357
+ "tx_count" => count,
358
+ "top_recipients" => [],
359
+ }
360
+
361
+ # Budget
362
+ in ["GET", "/wallet/budget"]
363
+ {
364
+ "daily_limit" => "10000.0",
365
+ "daily_used" => "0.0",
366
+ "daily_remaining" => "10000.0",
367
+ "monthly_limit" => "100000.0",
368
+ "monthly_used" => "0.0",
369
+ "monthly_remaining" => "100000.0",
370
+ "per_tx_limit" => "1000.0",
371
+ }
372
+
373
+ # History
374
+ in ["GET", path] if path.start_with?("/wallet/history")
375
+ txs = @state[:transactions]
376
+ {
377
+ "items" => txs.map { |tx| tx_hash(tx) },
378
+ "total" => txs.length,
379
+ "page" => 1,
380
+ "per_page" => 50,
381
+ "has_more" => false,
382
+ }
383
+
384
+ # Intents
385
+ in ["POST", "/intents"]
386
+ to = fetch!(b, :to)
387
+ amount = decimal!(b, :amount)
388
+ {
389
+ "id" => new_id("int"),
390
+ "from" => MockRemit::MOCK_ADDRESS,
391
+ "to" => to,
392
+ "amount" => amount.to_s("F"),
393
+ "type" => b[:type] || "direct",
394
+ "expires_at" => now,
395
+ "created_at" => now,
396
+ }
397
+
398
+ else
399
+ {}
400
+ end
401
+ end
402
+
403
+ # ─── Builder helpers ────────────────────────────────────────────────────
404
+
405
+ def make_tx(to:, amount:, from: MockRemit::MOCK_ADDRESS, memo: "")
406
+ Transaction.new(
407
+ "id" => new_id("tx"),
408
+ "tx_hash" => "0x#{SecureRandom.hex(32)}",
409
+ "from" => from,
410
+ "to" => to,
411
+ "amount" => amount.to_s("F"),
412
+ "fee" => "0.001",
413
+ "memo" => memo,
414
+ "chain_id" => ChainId::BASE_SEPOLIA,
415
+ "block_number" => 0,
416
+ "created_at" => now,
417
+ )
418
+ end
419
+
420
+ def make_escrow(payee:, amount:, memo: "", id: nil)
421
+ Escrow.new(
422
+ "id" => id || new_id("esc"),
423
+ "payer" => MockRemit::MOCK_ADDRESS,
424
+ "payee" => payee,
425
+ "amount" => amount.to_s("F"),
426
+ "fee" => "0.001",
427
+ "status" => EscrowStatus::FUNDED,
428
+ "memo" => memo,
429
+ "milestones" => [],
430
+ "splits" => [],
431
+ "created_at" => now,
432
+ )
433
+ end
434
+
435
+ def make_tab(provider:, limit:)
436
+ Tab.new(
437
+ "id" => new_id("tab"),
438
+ "opener" => MockRemit::MOCK_ADDRESS,
439
+ "provider" => provider,
440
+ "limit_amount" => limit.to_s("F"),
441
+ "used" => "0",
442
+ "remaining" => limit.to_s("F"),
443
+ "status" => TabStatus::OPEN,
444
+ "created_at" => now,
445
+ )
446
+ end
447
+
448
+ def make_stream(recipient:, rate_per_sec:, deposited:)
449
+ Stream.new(
450
+ "id" => new_id("str"),
451
+ "sender" => MockRemit::MOCK_ADDRESS,
452
+ "recipient" => recipient,
453
+ "rate_per_sec" => rate_per_sec.to_s("F"),
454
+ "deposited" => deposited.to_s("F"),
455
+ "withdrawn" => "0",
456
+ "status" => StreamStatus::ACTIVE,
457
+ "started_at" => now,
458
+ )
459
+ end
460
+
461
+ def make_bounty(amount:, task_description:)
462
+ Bounty.new(
463
+ "id" => new_id("bnt"),
464
+ "poster" => MockRemit::MOCK_ADDRESS,
465
+ "amount" => amount.to_s("F"),
466
+ "task_description" => task_description,
467
+ "status" => BountyStatus::OPEN,
468
+ "winner" => "",
469
+ "created_at" => now,
470
+ )
471
+ end
472
+
473
+ def make_deposit(provider:, amount:)
474
+ Deposit.new(
475
+ "id" => new_id("dep"),
476
+ "depositor" => MockRemit::MOCK_ADDRESS,
477
+ "provider" => provider,
478
+ "amount" => amount.to_s("F"),
479
+ "status" => DepositStatus::LOCKED,
480
+ "created_at" => now,
481
+ )
482
+ end
483
+
484
+ def update_escrow(esc, **changes)
485
+ h = escrow_hash(esc).merge(changes.transform_keys(&:to_s))
486
+ Escrow.new(h)
487
+ end
488
+
489
+ def update_tab(tab, **changes)
490
+ h = tab_hash(tab).merge(changes.transform_keys(&:to_s))
491
+ Tab.new(h)
492
+ end
493
+
494
+ def update_bounty(bnt, **changes)
495
+ h = bounty_hash(bnt).merge(changes.transform_keys(&:to_s))
496
+ Bounty.new(h)
497
+ end
498
+
499
+ def tx_hash(tx)
500
+ {
501
+ "id" => tx.id,
502
+ "tx_hash" => tx.tx_hash,
503
+ "from" => tx.from,
504
+ "to" => tx.to,
505
+ "amount" => tx.amount.to_s("F"),
506
+ "fee" => tx.fee.to_s("F"),
507
+ "memo" => tx.memo,
508
+ "chain_id" => tx.chain_id,
509
+ "block_number" => tx.block_number,
510
+ "created_at" => now,
511
+ }
512
+ end
513
+
514
+ def escrow_hash(esc)
515
+ {
516
+ "id" => esc.id,
517
+ "payer" => esc.payer,
518
+ "payee" => esc.payee,
519
+ "amount" => esc.amount.to_s("F"),
520
+ "fee" => esc.fee.to_s("F"),
521
+ "status" => esc.status,
522
+ "memo" => esc.memo,
523
+ "milestones" => esc.milestones,
524
+ "splits" => esc.splits,
525
+ "expires_at" => esc.expires_at&.iso8601,
526
+ "created_at" => now,
527
+ }
528
+ end
529
+
530
+ def tab_hash(tab)
531
+ {
532
+ "id" => tab.id,
533
+ "opener" => tab.opener,
534
+ "provider" => tab.provider,
535
+ "limit_amount" => tab.limit.to_s("F"),
536
+ "used" => tab.used.to_s("F"),
537
+ "remaining" => tab.remaining.to_s("F"),
538
+ "status" => tab.status,
539
+ "created_at" => now,
540
+ }
541
+ end
542
+
543
+ def stream_hash(s)
544
+ {
545
+ "id" => s.id,
546
+ "sender" => s.sender,
547
+ "recipient" => s.recipient,
548
+ "rate_per_sec" => s.rate_per_sec.to_s("F"),
549
+ "deposited" => s.deposited.to_s("F"),
550
+ "withdrawn" => s.withdrawn.to_s("F"),
551
+ "status" => s.status,
552
+ "started_at" => now,
553
+ }
554
+ end
555
+
556
+ def bounty_hash(bnt)
557
+ {
558
+ "id" => bnt.id,
559
+ "poster" => bnt.poster,
560
+ "amount" => bnt.amount.to_s("F"),
561
+ "task_description" => bnt.task_description,
562
+ "status" => bnt.status,
563
+ "winner" => bnt.winner,
564
+ "expires_at" => bnt.expires_at&.iso8601,
565
+ "created_at" => now,
566
+ }
567
+ end
568
+
569
+ def deposit_hash(dep)
570
+ {
571
+ "id" => dep.id,
572
+ "depositor" => dep.depositor,
573
+ "provider" => dep.provider,
574
+ "amount" => dep.amount.to_s("F"),
575
+ "status" => dep.status,
576
+ "expires_at" => dep.expires_at&.iso8601,
577
+ "created_at" => now,
578
+ }
579
+ end
580
+
581
+ # ─── Utilities ──────────────────────────────────────────────────────────
582
+
583
+ def new_id(prefix)
584
+ "#{prefix}_#{SecureRandom.hex(4)}"
585
+ end
586
+
587
+ def now
588
+ Time.now.utc.iso8601
589
+ end
590
+
591
+ def fetch!(h, key)
592
+ val = h[key] || h[key.to_s]
593
+ raise RemitError.new(RemitError::SERVER_ERROR, "Missing field: #{key}") if val.nil?
594
+
595
+ val.to_s
596
+ end
597
+
598
+ def decimal!(h, key)
599
+ s = fetch!(h, key)
600
+ BigDecimal(s)
601
+ rescue ArgumentError
602
+ raise RemitError.new(RemitError::INVALID_AMOUNT, "Invalid decimal for #{key}: #{s.inspect}")
603
+ end
604
+
605
+ def check_balance!(amount)
606
+ bal = @state[:balance]
607
+ return if bal >= amount
608
+ raise RemitError.new(
609
+ RemitError::INSUFFICIENT_FUNDS,
610
+ "Insufficient balance: have #{bal.to_s("F")} USDC, need #{amount.to_s("F")} USDC",
611
+ context: { balance: bal.to_s("F"), amount: amount.to_s("F") }
612
+ )
613
+ end
614
+
615
+ def extract_id(path, prefix, suffix)
616
+ path.delete_prefix(prefix).delete_suffix(suffix)
617
+ end
618
+
619
+ def not_found(code, id)
620
+ RemitError.new(code, "#{code}: #{id} not found")
621
+ end
622
+ end
623
+ end