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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ed253579479f34977bda545675b25e12f9b73333b7cf36e640ae328c6b13073
4
- data.tar.gz: f49f3163a37cb08a328e17de589fcc894ae168a3a486af06a419bd2edc1c79b9
3
+ metadata.gz: b29ac02ca95fe1053bed848cca89111698f1744bbeb145681a76a3f9ed2d8e66
4
+ data.tar.gz: d55c4f33eaaa686fa0036e3a0566ee6382a0a544c01093a3d9887e745b2eaa01
5
5
  SHA512:
6
- metadata.gz: ebb1c4b3e6b91a29634e3ed89207d4d95e4de701595d33e654f41128ba9d6aba9a76344c6a6f9ad1991f87dcda32c60480cc2fb75d593ea894fd784e2aefbb27
7
- data.tar.gz: ba0a10cb153c66c3ca62c919c33d19dff4d34b50c4c81f129455d1f5bc147778b985731c593bfb55525934b3baeafb2fafea3d09cddb1117d435cbc52a0944b2
6
+ metadata.gz: 9c00d48fc571e6975d9dfd32c679d5b9cf46205742a8a725022907eb997d42de7a61ae040c22dfafb7d5a994277d25f5dafd2e589611e849f94a70c692d71102
7
+ data.tar.gz: dff3c27cf317ce994999efd38d57f1173f0a1136da0bf4dbcdbf1ff5879a9f737bef96875802108ca130b4e46b3b94eec1cc0f52cace3152c89357018d9f9621
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # remit.md Ruby SDK
2
2
 
3
+ > [Skill MD](https://remit.md) · [Docs](https://remit.md/docs) · [Agent Spec](https://remit.md/agent.md)
4
+
3
5
  Universal payment protocol for AI agents — Ruby client library.
4
6
 
5
7
  [![CI](https://github.com/remit-md/sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/remit-md/sdk/actions/workflows/ci.yml)
@@ -38,7 +40,7 @@ Or from environment variables:
38
40
  ```ruby
39
41
  wallet = Remitmd::RemitWallet.from_env
40
42
  # Requires: REMITMD_PRIVATE_KEY
41
- # Optional: REMITMD_CHAIN (default: "base"), REMITMD_API_URL, REMITMD_RPC_URL
43
+ # Optional: REMITMD_CHAIN (default: "base"), REMITMD_API_URL
42
44
  ```
43
45
 
44
46
  Permits are auto-signed. Every payment method fetches the on-chain USDC nonce, signs an EIP-2612 permit, and includes it automatically.
@@ -267,19 +269,6 @@ permit = wallet.sign_usdc_permit(
267
269
  tx = wallet.pay("0xRecipient...", 5.00, permit: permit)
268
270
  ```
269
271
 
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
-
283
272
  ## License
284
273
 
285
274
  MIT — see [LICENSE](LICENSE)
data/lib/remitmd/a2a.rb CHANGED
@@ -4,7 +4,7 @@ require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
6
 
7
- module RemitMd
7
+ module Remitmd
8
8
  # A2A capability extension declared in an agent card.
9
9
  AgentExtension = Data.define(:uri, :description, :required)
10
10
 
@@ -37,7 +37,7 @@ module RemitMd
37
37
 
38
38
  # Fetch and parse the A2A agent card from +base_url/.well-known/agent-card.json+.
39
39
  #
40
- # card = RemitMd::AgentCard.discover("https://remit.md")
40
+ # card = Remitmd::AgentCard.discover("https://remit.md")
41
41
  # puts card.name # => "remit.md"
42
42
  # puts card.url # => "https://remit.md/a2a"
43
43
  #
@@ -55,4 +55,190 @@ module RemitMd
55
55
  new(JSON.parse(response.body))
56
56
  end
57
57
  end
58
+
59
+ # ─── A2A task types ──────────────────────────────────────────────────────────
60
+
61
+ # Status of an A2A task.
62
+ class A2ATaskStatus
63
+ attr_reader :state, :message
64
+
65
+ def initialize(data)
66
+ @state = data["state"].to_s
67
+ @message = data["message"]
68
+ end
69
+ end
70
+
71
+ # An artifact part within an A2A artifact.
72
+ A2AArtifactPart = Data.define(:kind, :data)
73
+
74
+ # An artifact returned by an A2A task.
75
+ class A2AArtifact
76
+ attr_reader :name, :parts
77
+
78
+ def initialize(data)
79
+ @name = data["name"]
80
+ @parts = (data["parts"] || []).map do |p|
81
+ A2AArtifactPart.new(kind: p["kind"].to_s, data: p["data"] || {})
82
+ end
83
+ end
84
+ end
85
+
86
+ # An A2A task returned by message/send, tasks/get, or tasks/cancel.
87
+ class A2ATask
88
+ attr_reader :id, :status, :artifacts
89
+
90
+ def initialize(data)
91
+ @id = data["id"].to_s
92
+ @status = A2ATaskStatus.new(data["status"] || {})
93
+ @artifacts = (data["artifacts"] || []).map { |a| A2AArtifact.new(a) }
94
+ end
95
+
96
+ # Extract txHash from task artifacts, if present.
97
+ # @return [String, nil]
98
+ def tx_hash
99
+ artifacts.each do |artifact|
100
+ artifact.parts.each do |part|
101
+ tx = part.data["txHash"] if part.data.is_a?(Hash)
102
+ return tx if tx.is_a?(String)
103
+ end
104
+ end
105
+ nil
106
+ end
107
+ end
108
+
109
+ # ─── IntentMandate ──────────────────────────────────────────────────────────
110
+
111
+ # A mandate authorizing a payment intent.
112
+ class IntentMandate
113
+ attr_reader :mandate_id, :expires_at, :issuer, :max_amount, :currency
114
+
115
+ def initialize(mandate_id:, expires_at:, issuer:, max_amount:, currency: "USDC")
116
+ @mandate_id = mandate_id
117
+ @expires_at = expires_at
118
+ @issuer = issuer
119
+ @max_amount = max_amount
120
+ @currency = currency
121
+ end
122
+
123
+ def to_h
124
+ {
125
+ mandateId: @mandate_id,
126
+ expiresAt: @expires_at,
127
+ issuer: @issuer,
128
+ allowance: {
129
+ maxAmount: @max_amount,
130
+ currency: @currency,
131
+ },
132
+ }
133
+ end
134
+ end
135
+
136
+ # ─── A2A JSON-RPC client ────────────────────────────────────────────────────
137
+
138
+ # A2A JSON-RPC client — send payments and manage tasks via the A2A protocol.
139
+ #
140
+ # @example
141
+ # card = Remitmd::AgentCard.discover("https://remit.md")
142
+ # signer = Remitmd::PrivateKeySigner.new(ENV["REMITMD_KEY"])
143
+ # client = Remitmd::A2AClient.from_card(card, signer)
144
+ # task = client.send(to: "0xRecipient...", amount: 10)
145
+ # puts task.status.state
146
+ #
147
+ class A2AClient
148
+ CHAIN_IDS = {
149
+ "base" => 8453,
150
+ "base-sepolia" => 84_532,
151
+ }.freeze
152
+
153
+ # @param endpoint [String] full A2A endpoint URL from the agent card
154
+ # @param signer [#sign, #address] a signer for EIP-712 authentication
155
+ # @param chain_id [Integer] chain ID
156
+ # @param verifying_contract [String] verifying contract address
157
+ def initialize(endpoint:, signer:, chain_id:, verifying_contract: "")
158
+ parsed = URI(endpoint)
159
+ @base_url = "#{parsed.scheme}://#{parsed.host}#{parsed.port == parsed.default_port ? "" : ":#{parsed.port}"}"
160
+ @path = parsed.path.empty? ? "/a2a" : parsed.path
161
+ @transport = HttpTransport.new(
162
+ base_url: @base_url,
163
+ signer: signer,
164
+ chain_id: chain_id,
165
+ router_address: verifying_contract
166
+ )
167
+ end
168
+
169
+ # Convenience constructor from an AgentCard and a signer.
170
+ # @param card [AgentCard]
171
+ # @param signer [#sign, #address]
172
+ # @param chain [String] chain name (default: "base")
173
+ # @param verifying_contract [String]
174
+ # @return [A2AClient]
175
+ def self.from_card(card, signer, chain: "base", verifying_contract: "")
176
+ chain_id = CHAIN_IDS[chain] || CHAIN_IDS["base"]
177
+ new(
178
+ endpoint: card.url,
179
+ signer: signer,
180
+ chain_id: chain_id,
181
+ verifying_contract: verifying_contract
182
+ )
183
+ end
184
+
185
+ # Send a direct USDC payment via message/send.
186
+ # @param to [String] recipient 0x address
187
+ # @param amount [Numeric] amount in USDC
188
+ # @param memo [String] optional memo
189
+ # @param mandate [IntentMandate, nil] optional intent mandate
190
+ # @return [A2ATask]
191
+ def send(to:, amount:, memo: "", mandate: nil)
192
+ nonce = SecureRandom.hex(16)
193
+ message_id = SecureRandom.hex(16)
194
+
195
+ message = {
196
+ messageId: message_id,
197
+ role: "user",
198
+ parts: [
199
+ {
200
+ kind: "data",
201
+ data: {
202
+ model: "direct",
203
+ to: to,
204
+ amount: format("%.2f", amount),
205
+ memo: memo,
206
+ nonce: nonce,
207
+ },
208
+ },
209
+ ],
210
+ }
211
+
212
+ message[:metadata] = { mandate: mandate.to_h } if mandate
213
+
214
+ rpc("message/send", { message: message }, message_id)
215
+ end
216
+
217
+ # Fetch the current state of an A2A task by ID.
218
+ # @param task_id [String]
219
+ # @return [A2ATask]
220
+ def get_task(task_id)
221
+ rpc("tasks/get", { id: task_id }, task_id[0, 16])
222
+ end
223
+
224
+ # Cancel an in-progress A2A task.
225
+ # @param task_id [String]
226
+ # @return [A2ATask]
227
+ def cancel_task(task_id)
228
+ rpc("tasks/cancel", { id: task_id }, task_id[0, 16])
229
+ end
230
+
231
+ private
232
+
233
+ def rpc(method, params, call_id)
234
+ body = { jsonrpc: "2.0", id: call_id, method: method, params: params }
235
+ data = @transport.post(@path, body)
236
+ if data.is_a?(Hash) && data["error"]
237
+ err_msg = data["error"]["message"] || JSON.generate(data["error"])
238
+ raise RemitError.new("SERVER_ERROR", "A2A error: #{err_msg}")
239
+ end
240
+ result = data.is_a?(Hash) ? (data["result"] || data) : data
241
+ A2ATask.new(result)
242
+ end
243
+ end
58
244
  end
@@ -15,29 +15,64 @@ module Remitmd
15
15
  # puts e.doc_url # => "https://remit.md/docs/api-reference/error-codes#invalid_address"
16
16
  # end
17
17
  class RemitError < StandardError
18
- # Error code constants
19
- INVALID_ADDRESS = "INVALID_ADDRESS"
20
- INVALID_AMOUNT = "INVALID_AMOUNT"
21
- INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS"
18
+ # Error code constants — matches TS SDK (28 codes)
19
+ # Auth errors
20
+ INVALID_SIGNATURE = "INVALID_SIGNATURE"
21
+ NONCE_REUSED = "NONCE_REUSED"
22
+ TIMESTAMP_EXPIRED = "TIMESTAMP_EXPIRED"
23
+ UNAUTHORIZED = "UNAUTHORIZED"
24
+
25
+ # Balance / funds
26
+ INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"
27
+ BELOW_MINIMUM = "BELOW_MINIMUM"
28
+
29
+ # Escrow errors
22
30
  ESCROW_NOT_FOUND = "ESCROW_NOT_FOUND"
31
+ ESCROW_ALREADY_FUNDED = "ESCROW_ALREADY_FUNDED"
32
+ ESCROW_EXPIRED = "ESCROW_EXPIRED"
33
+
34
+ # Invoice errors
35
+ INVALID_INVOICE = "INVALID_INVOICE"
36
+ DUPLICATE_INVOICE = "DUPLICATE_INVOICE"
37
+ SELF_PAYMENT = "SELF_PAYMENT"
38
+ INVALID_PAYMENT_TYPE = "INVALID_PAYMENT_TYPE"
39
+
40
+ # Tab errors
41
+ TAB_DEPLETED = "TAB_DEPLETED"
42
+ TAB_EXPIRED = "TAB_EXPIRED"
23
43
  TAB_NOT_FOUND = "TAB_NOT_FOUND"
44
+
45
+ # Stream errors
24
46
  STREAM_NOT_FOUND = "STREAM_NOT_FOUND"
47
+ RATE_EXCEEDS_CAP = "RATE_EXCEEDS_CAP"
48
+
49
+ # Bounty errors
50
+ BOUNTY_EXPIRED = "BOUNTY_EXPIRED"
51
+ BOUNTY_CLAIMED = "BOUNTY_CLAIMED"
52
+ BOUNTY_MAX_ATTEMPTS = "BOUNTY_MAX_ATTEMPTS"
25
53
  BOUNTY_NOT_FOUND = "BOUNTY_NOT_FOUND"
26
- DEPOSIT_NOT_FOUND = "DEPOSIT_NOT_FOUND"
27
- UNAUTHORIZED = "UNAUTHORIZED"
28
- RATE_LIMITED = "RATE_LIMITED"
54
+
55
+ # Chain errors
56
+ CHAIN_MISMATCH = "CHAIN_MISMATCH"
57
+ CHAIN_UNSUPPORTED = "CHAIN_UNSUPPORTED"
58
+
59
+ # Rate limiting
60
+ RATE_LIMITED = "RATE_LIMITED"
61
+
62
+ # Cancellation errors
63
+ CANCEL_BLOCKED_CLAIM_START = "CANCEL_BLOCKED_CLAIM_START"
64
+ CANCEL_BLOCKED_EVIDENCE = "CANCEL_BLOCKED_EVIDENCE"
65
+
66
+ # Protocol errors
67
+ VERSION_MISMATCH = "VERSION_MISMATCH"
29
68
  NETWORK_ERROR = "NETWORK_ERROR"
69
+
70
+ # Legacy aliases (kept for backward compat within the SDK)
71
+ INVALID_ADDRESS = "INVALID_ADDRESS"
72
+ INVALID_AMOUNT = "INVALID_AMOUNT"
73
+ INSUFFICIENT_FUNDS = INSUFFICIENT_BALANCE
30
74
  SERVER_ERROR = "SERVER_ERROR"
31
- NONCE_REUSED = "NONCE_REUSED"
32
- SIGNATURE_INVALID = "SIGNATURE_INVALID"
33
- ESCROW_ALREADY_RELEASED = "ESCROW_ALREADY_RELEASED"
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_SETTLED = "DEPOSIT_ALREADY_SETTLED"
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
@@ -8,9 +8,11 @@ require "openssl"
8
8
 
9
9
  module Remitmd
10
10
  # Chain configuration: maps chain names to (api_url, chain_id) pairs.
11
+ # Canonical keys use hyphens; underscored variants are accepted as aliases.
11
12
  CHAIN_CONFIG = {
12
- "base" => { url: "https://remit.md/api/v1", chain_id: 8453 },
13
- "base_sepolia" => { url: "https://testnet.remit.md/api/v1", chain_id: 84532 },
13
+ "base" => { url: "https://remit.md/api/v1", chain_id: 8453 },
14
+ "base-sepolia" => { url: "https://testnet.remit.md/api/v1", chain_id: 84532 },
15
+ "base_sepolia" => { url: "https://testnet.remit.md/api/v1", chain_id: 84532 },
14
16
  }.freeze
15
17
 
16
18
  # HTTP transport layer. Signs each request with EIP-712 auth headers and
@@ -40,9 +42,11 @@ module Remitmd
40
42
 
41
43
  def request(method, path, body)
42
44
  attempt = 0
45
+ # Generate idempotency key once per request (stable across retries).
46
+ idempotency_key = %i[post put patch].include?(method) ? SecureRandom.uuid : nil
43
47
  begin
44
48
  attempt += 1
45
- req = build_request(method, path, body)
49
+ req = build_request(method, path, body, idempotency_key)
46
50
  resp = @http.request(req)
47
51
  handle_response(resp, path)
48
52
  rescue RemitError => e
@@ -58,7 +62,7 @@ module Remitmd
58
62
  end
59
63
  end
60
64
 
61
- def build_request(method, path, body)
65
+ def build_request(method, path, body, idempotency_key = nil)
62
66
  full_path = "#{@uri.path}#{path}"
63
67
  req = case method
64
68
  when :get then Net::HTTP::Get.new(full_path)
@@ -70,9 +74,10 @@ module Remitmd
70
74
  nonce_hex = "0x#{nonce_bytes.unpack1("H*")}"
71
75
  timestamp = Time.now.to_i
72
76
 
73
- # Compute EIP-712 hash and sign it.
77
+ # Strip query string before signing — only the path is included in EIP-712.
78
+ sign_path = full_path.split("?").first
74
79
  http_method = method.to_s.upcase
75
- digest = eip712_hash(http_method, full_path, timestamp, nonce_bytes)
80
+ digest = eip712_hash(http_method, sign_path, timestamp, nonce_bytes)
76
81
  signature = @signer.sign(digest)
77
82
 
78
83
  req["Content-Type"] = "application/json"
@@ -81,6 +86,7 @@ module Remitmd
81
86
  req["X-Remit-Nonce"] = nonce_hex
82
87
  req["X-Remit-Timestamp"] = timestamp.to_s
83
88
  req["X-Remit-Signature"] = signature
89
+ req["X-Idempotency-Key"] = idempotency_key if idempotency_key
84
90
 
85
91
  if body
86
92
  req.body = body.to_json
@@ -150,8 +156,10 @@ module Remitmd
150
156
  when 200..299
151
157
  parsed
152
158
  when 400
153
- code = parsed["code"] || RemitError::SERVER_ERROR
154
- raise RemitError.new(code, parsed["message"] || "Bad request", context: parsed)
159
+ # Support nested error format: { "error": { "code": "...", "message": "..." } }
160
+ err = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : parsed
161
+ code = err["code"] || RemitError::SERVER_ERROR
162
+ raise RemitError.new(code, err["message"] || "Bad request", context: parsed)
155
163
  when 401
156
164
  raise RemitError.new(RemitError::UNAUTHORIZED,
157
165
  "Authentication failed — check your private key and chain ID")
@@ -77,7 +77,7 @@ module Remitmd
77
77
  RC.each { |rc| keccak_round!(state, rc) }
78
78
  end
79
79
 
80
- def keccak_round!(state, rc) # rubocop:disable Metrics/MethodLength
80
+ def keccak_round!(state, rc)
81
81
  # Theta
82
82
  c = Array.new(5) do |x|
83
83
  state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20]
data/lib/remitmd/mock.rb CHANGED
@@ -148,7 +148,6 @@ module Remitmd
148
148
  "deposit" => "0xMockDeposit00000000000000000000000001",
149
149
  "fee_calculator" => "0xMockFeeCalc0000000000000000000000001",
150
150
  "key_registry" => "0xMockKeyReg000000000000000000000000001",
151
- "arbitration" => "0xMockArbitr000000000000000000000000001",
152
151
  }
153
152
 
154
153
  # Balance
@@ -181,6 +180,7 @@ module Remitmd
181
180
  invoice_id = fetch!(b, :invoice_id)
182
181
  inv = @state[:pending_invoices].delete(invoice_id)
183
182
  raise not_found(RemitError::ESCROW_NOT_FOUND, invoice_id) unless inv
183
+
184
184
  payee = (inv[:to_agent] || inv["to_agent"]).to_s
185
185
  amount = decimal!(inv, :amount)
186
186
  memo = (inv[:task] || inv["task"]).to_s
@@ -200,7 +200,7 @@ module Remitmd
200
200
  in ["POST", path] if path.end_with?("/release") && path.include?("/escrows/")
201
201
  id = extract_id(path, "/escrows/", "/release")
202
202
  esc = @state[:escrows].fetch(id) { raise not_found(RemitError::ESCROW_NOT_FOUND, id) }
203
- new_esc = update_escrow(esc, status: EscrowStatus::RELEASED)
203
+ new_esc = update_escrow(esc, status: EscrowStatus::COMPLETED)
204
204
  @state[:escrows][id] = new_esc
205
205
  tx = make_tx(from: esc.payer, to: esc.payee, amount: esc.amount)
206
206
  @state[:transactions] << tx
@@ -269,7 +269,7 @@ module Remitmd
269
269
  in ["POST", path] if path.end_with?("/close") && path.include?("/tabs/")
270
270
  id = extract_id(path, "/tabs/", "/close")
271
271
  tab = @state[:tabs].fetch(id) { raise not_found(RemitError::TAB_NOT_FOUND, id) }
272
- new_tab = update_tab(tab, status: TabStatus::SETTLED)
272
+ new_tab = update_tab(tab, status: TabStatus::CLOSED)
273
273
  @state[:tabs][id] = new_tab
274
274
  tab_hash(new_tab)
275
275
 
@@ -311,7 +311,7 @@ module Remitmd
311
311
  # Bounty submit
312
312
  in ["POST", path] if path.end_with?("/submit") && path.include?("/bounties/")
313
313
  id = extract_id(path, "/bounties/", "/submit")
314
- bnt = @state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
314
+ @state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
315
315
  {
316
316
  "id" => 1,
317
317
  "bounty_id" => id,
@@ -323,9 +323,9 @@ module Remitmd
323
323
 
324
324
  # Bounty award
325
325
  in ["POST", path] if path.end_with?("/award") && path.include?("/bounties/")
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) }
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) }
329
329
  new_bnt = update_bounty(bnt, status: BountyStatus::AWARDED)
330
330
  @state[:bounties][id] = new_bnt
331
331
  bounty_hash(new_bnt)
@@ -621,6 +621,7 @@ module Remitmd
621
621
  def check_balance!(amount)
622
622
  bal = @state[:balance]
623
623
  return if bal >= amount
624
+
624
625
  raise RemitError.new(
625
626
  RemitError::INSUFFICIENT_FUNDS,
626
627
  "Insufficient balance: have #{bal.to_s("F")} USDC, need #{amount.to_s("F")} USDC",