@recoilpay/intent-core 0.1.0 → 0.3.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.
package/dist/index.cjs CHANGED
@@ -21,12 +21,16 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ApiError: () => ApiError,
24
+ BANK_DETAILS_HINT: () => BANK_DETAILS_HINT,
24
25
  CHAIN_ALIASES: () => CHAIN_ALIASES,
25
26
  DEFAULT_API_URL: () => DEFAULT_API_URL,
26
27
  DEFAULT_INTENT_MODEL: () => DEFAULT_INTENT_MODEL,
27
28
  EMPTY_SUPPORTED: () => EMPTY_SUPPORTED,
29
+ FIAT_CORRIDORS: () => FIAT_CORRIDORS,
30
+ FIAT_CURRENCY_ALIASES: () => FIAT_CURRENCY_ALIASES,
28
31
  HF_ROUTER_URL: () => HF_ROUTER_URL,
29
32
  MAX_UINT256: () => MAX_UINT256,
33
+ NG_BANKS: () => NG_BANKS,
30
34
  PERMIT2_ADDRESS: () => PERMIT2_ADDRESS,
31
35
  SOLANA_CHAIN_IDS: () => SOLANA_CHAIN_IDS,
32
36
  TEMPLATE_HINT: () => TEMPLATE_HINT,
@@ -35,30 +39,45 @@ __export(index_exports, {
35
39
  buildPermit2ApproveRequest: () => buildPermit2ApproveRequest,
36
40
  buildQuoteRequest: () => buildQuoteRequest,
37
41
  buildSupportedSet: () => buildSupportedSet,
42
+ canonicalPayeeIdentity: () => canonicalPayeeIdentity,
38
43
  chainName: () => chainName,
44
+ checkLock: () => checkLock,
39
45
  createApiClient: () => createApiClient,
40
46
  createIntentSession: () => createIntentSession,
47
+ createOfframpSession: () => createOfframpSession,
41
48
  explorerUrl: () => explorerUrl,
42
49
  extractIntents: () => extractIntents,
50
+ fiatConfirmMessage: () => fiatConfirmMessage,
51
+ fiatDisputeMessage: () => fiatDisputeMessage,
52
+ fiatPaymentSentMessage: () => fiatPaymentSentMessage,
43
53
  findAsset: () => findAsset,
54
+ formatFiat: () => formatFiat,
44
55
  getPermit2Allowance: () => getPermit2Allowance,
45
56
  hasPermit2Allowance: () => hasPermit2Allowance,
46
57
  interopAddress: () => interopAddress,
47
58
  isTerminalStatus: () => isTerminalStatus,
59
+ looksLikeBankDetails: () => looksLikeBankDetails,
48
60
  normalizeChain: () => normalizeChain,
61
+ normalizeFiatCurrency: () => normalizeFiatCurrency,
49
62
  normalizeToken: () => normalizeToken,
63
+ openPayee: () => openPayee,
50
64
  parseIntent: () => parseIntent,
65
+ payeeCommitment: () => payeeCommitment,
66
+ payeeIdentity: () => payeeIdentity,
51
67
  resolveIntent: () => resolveIntent,
68
+ resolveOfframp: () => resolveOfframp,
52
69
  rpcReaders: () => rpcReaders,
70
+ sealPayee: () => sealPayee,
53
71
  signQuote: () => signQuote,
54
72
  stripDomainType: () => stripDomainType,
55
73
  validateIntent: () => validateIntent,
74
+ validateOfframp: () => validateOfframp,
56
75
  viemWallet: () => viemWallet
57
76
  });
58
77
  module.exports = __toCommonJS(index_exports);
59
78
 
60
79
  // src/session.ts
61
- var import_viem4 = require("viem");
80
+ var import_viem5 = require("viem");
62
81
 
63
82
  // src/api.ts
64
83
  var DEFAULT_API_URL = "https://api.recoilpay.com";
@@ -117,12 +136,29 @@ function createApiClient(options = {}) {
117
136
  },
118
137
  getQuotes: (req) => post("/quotes", req),
119
138
  submitOrder: (req) => post("/orders", req),
120
- getOrder: (id) => request(`/orders/${encodeURIComponent(id)}`)
139
+ getOrder: (id) => request(`/orders/${encodeURIComponent(id)}`),
140
+ // ── Fiat ramps (design §12.1) ──
141
+ rankFiatQuotes: (req) => post("/fiat/quotes", req),
142
+ openFiatTrade: (req) => post("/fiat/trades", req),
143
+ getFiatTrade: (id) => request(`/fiat/trades/${encodeURIComponent(id)}`),
144
+ /** Hand the signed lock to the solver's relay, which submits it and pays the gas. */
145
+ submitFiatLock: (id, signature) => post(`/fiat/trades/${encodeURIComponent(id)}/lock`, { signature }),
146
+ /** Report a lock the user submitted themselves; the aggregator reads it from the chain. */
147
+ reportFiatFunded: (id, txHash) => post(`/fiat/trades/${encodeURIComponent(id)}/funded`, txHash ? { txHash } : {}),
148
+ /** `signature` is over `fiatConfirmMessage(id)`. */
149
+ confirmFiatTrade: (id, signature) => post(`/fiat/trades/${encodeURIComponent(id)}/confirm`, { signature }),
150
+ /** `signature` is over `fiatDisputeMessage(id, reason)`. */
151
+ disputeFiatTrade: (id, reason, signature) => post(`/fiat/trades/${encodeURIComponent(id)}/dispute`, { reason, signature })
121
152
  };
122
153
  }
123
154
 
124
155
  // src/parse.ts
125
- var TEMPLATE_HINT = "Try: swap <amount> <token> on <chain> for <token> on <chain> [to <address>] (e.g. swap 10 USDC on Base for ETH on Arbitrum) OR send <amount> <token> on <chain> to <address>";
156
+ var TEMPLATE_HINT = "Try: swap <amount> <token> on <chain> for <token> on <chain> [to <address>] (e.g. swap 10 USDC on Base for ETH on Arbitrum) OR send <amount> <token> on <chain> to <address> OR cash out <amount> <token> on <chain> to <currency> (e.g. cash out 50 USDC on Base to NGN)";
157
+ var BANK_DETAILS_HINT = "Leave bank details out of the sentence \u2014 you'll enter them privately on the next step. Try: cash out 50 USDC on Base to NGN";
158
+ function looksLikeBankDetails(text) {
159
+ const rest = text.replace(/0x[0-9a-fA-F]+/g, " ").replace(/[.,]\d+/g, "");
160
+ return /(\d[\s-]?){10,}/.test(rest);
161
+ }
126
162
  var HF_ROUTER_URL = "https://router.huggingface.co/v1/chat/completions";
127
163
  var DEFAULT_INTENT_MODEL = "Qwen/Qwen2.5-Coder-32B-Instruct";
128
164
  var SYSTEM_PROMPT = `You are an intent extraction engine for a cross-chain swap protocol.
@@ -131,7 +167,7 @@ If the user specifies multiple intents (e.g. "swap X and then send Y"), return m
131
167
  The JSON objects must perfectly match the following TypeScript interface:
132
168
 
133
169
  type AmountKind = "token" | "usd";
134
- type IntentAction = "swap" | "send";
170
+ type IntentAction = "swap" | "send" | "offramp";
135
171
 
136
172
  interface RawIntent {
137
173
  action: IntentAction;
@@ -142,14 +178,19 @@ interface RawIntent {
142
178
  tokenOut: string | null; // target token (optional for send)
143
179
  chainOut: string | null; // target chain (optional for send)
144
180
  recipient: string | null; // address/ens (required for 'send', optional for 'swap')
181
+ fiatCurrency?: string | null; // 'offramp' only: the local currency to receive, e.g. "NGN" or "naira"
145
182
  }
146
183
 
184
+ Use "offramp" when the user wants to cash out, sell, or withdraw crypto to a bank account or local currency.
185
+ For "offramp", tokenIn/chainIn/amount describe the crypto they give; tokenOut, chainOut and recipient are null.
186
+ Never put bank names, account numbers or personal names in any field.
187
+
147
188
  Return ONLY the JSON array, nothing else. If you absolutely cannot determine any intents, return {"error": "unrecognized"}. Do not make up information that is missing; use null.`;
148
189
  var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
149
190
  var readString = (v) => typeof v === "string" && v.trim().length > 0 ? v : null;
150
191
  var readAmount = (v) => v === null || v === void 0 ? null : String(v);
151
192
  var readAmountKind = (v) => v === "usd" ? "usd" : "token";
152
- var readAction = (v) => v === "swap" || v === "send" ? v : null;
193
+ var readAction = (v) => v === "swap" || v === "send" || v === "offramp" ? v : null;
153
194
  var offTemplate = (message = TEMPLATE_HINT) => ({ ok: false, offTemplate: true, message });
154
195
  function extractIntents(content) {
155
196
  let json = content.trim();
@@ -174,7 +215,7 @@ function extractIntents(content) {
174
215
  for (const item of items) {
175
216
  const action = readAction(item.action);
176
217
  if (!action) return offTemplate();
177
- intents.push({
218
+ const intent = {
178
219
  action,
179
220
  amount: readAmount(item.amount),
180
221
  amountKind: readAmountKind(item.amountKind),
@@ -183,13 +224,16 @@ function extractIntents(content) {
183
224
  tokenOut: readString(item.tokenOut),
184
225
  chainOut: readString(item.chainOut),
185
226
  recipient: readString(item.recipient)
186
- });
227
+ };
228
+ if (action === "offramp") intent.fiatCurrency = readString(item.fiatCurrency);
229
+ intents.push(intent);
187
230
  }
188
231
  return { ok: true, intents };
189
232
  }
190
233
  async function parseIntent(text, options = {}) {
191
234
  const norm = text.trim();
192
235
  if (!norm) return offTemplate();
236
+ if (looksLikeBankDetails(norm)) return offTemplate(BANK_DETAILS_HINT);
193
237
  const token = options.hfAccessToken?.trim();
194
238
  if (!token) return offTemplate(`Natural-language parsing isn't set up here. ${TEMPLATE_HINT}`);
195
239
  const doFetch = options.fetch ?? ((...args) => fetch(...args));
@@ -229,12 +273,15 @@ var CHAIN_NAMES = {
229
273
  8453: "Base",
230
274
  42161: "Arbitrum",
231
275
  56: "BNB Chain",
276
+ 204: "opBNB",
232
277
  43114: "Avalanche",
233
278
  11155111: "Ethereum Sepolia",
234
279
  84532: "Base Sepolia",
235
280
  11155420: "OP Sepolia",
236
281
  421614: "Arbitrum Sepolia",
237
282
  80002: "Polygon Amoy",
283
+ 97: "BNB Chain Testnet",
284
+ 5611: "opBNB Testnet",
238
285
  9000000001: "Solana",
239
286
  9000000002: "Solana Devnet"
240
287
  };
@@ -245,12 +292,15 @@ var EXPLORERS = {
245
292
  8453: "https://basescan.org",
246
293
  42161: "https://arbiscan.io",
247
294
  56: "https://bscscan.com",
295
+ 204: "https://opbnb.bscscan.com",
248
296
  43114: "https://snowtrace.io",
249
297
  11155111: "https://sepolia.etherscan.io",
250
298
  84532: "https://sepolia.basescan.org",
251
299
  11155420: "https://sepolia-optimism.etherscan.io",
252
300
  421614: "https://sepolia.arbiscan.io",
253
- 80002: "https://amoy.polygonscan.com"
301
+ 80002: "https://amoy.polygonscan.com",
302
+ 97: "https://testnet.bscscan.com",
303
+ 5611: "https://testnet.opbnbscan.com"
254
304
  };
255
305
  var SOLANA_CHAIN_IDS = /* @__PURE__ */ new Set([9000000001, 9000000002]);
256
306
  function chainName(chainId, info) {
@@ -310,9 +360,14 @@ var CHAIN_ALIASES = {
310
360
  op: { id: 10, name: "Optimism" },
311
361
  polygon: { id: 137, name: "Polygon" },
312
362
  matic: { id: 137, name: "Polygon" },
363
+ bsc: { id: 56, name: "BNB Chain" },
364
+ bnb: { id: 56, name: "BNB Chain" },
365
+ "bnb chain": { id: 56, name: "BNB Chain" },
366
+ "binance smart chain": { id: 56, name: "BNB Chain" },
367
+ opbnb: { id: 204, name: "opBNB" },
313
368
  solana: { id: 9000000002, name: "Solana Devnet" },
314
369
  "sol network": { id: 9000000002, name: "Solana Devnet" },
315
- // testnets — the solver settles across these five
370
+ // testnets — the solver settles across these
316
371
  "base sepolia": { id: 84532, name: "Base Sepolia" },
317
372
  "op sepolia": { id: 11155420, name: "OP Sepolia" },
318
373
  "optimism sepolia": { id: 11155420, name: "OP Sepolia" },
@@ -322,7 +377,9 @@ var CHAIN_ALIASES = {
322
377
  "eth sepolia": { id: 11155111, name: "Ethereum Sepolia" },
323
378
  sepolia: { id: 11155111, name: "Ethereum Sepolia" },
324
379
  "polygon amoy": { id: 80002, name: "Polygon Amoy" },
325
- amoy: { id: 80002, name: "Polygon Amoy" }
380
+ amoy: { id: 80002, name: "Polygon Amoy" },
381
+ "bsc testnet": { id: 97, name: "BNB Chain Testnet" },
382
+ "bnb testnet": { id: 97, name: "BNB Chain Testnet" }
326
383
  };
327
384
  function normalizeToken(raw) {
328
385
  if (!raw) return null;
@@ -424,7 +481,8 @@ var LABEL = {
424
481
  chainIn: "Source chain",
425
482
  tokenOut: "Destination token",
426
483
  chainOut: "Destination chain",
427
- recipient: "Recipient"
484
+ recipient: "Recipient",
485
+ fiatCurrency: "Currency"
428
486
  };
429
487
  var EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
430
488
  var SOLANA_ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
@@ -693,6 +751,23 @@ var PERMIT2_TYPES = {
693
751
  { name: "amount", type: "uint256" }
694
752
  ]
695
753
  };
754
+ var PERMIT2_ESCROW_LOCK_TYPES = {
755
+ PermitWitnessTransferFrom: [
756
+ { name: "permitted", type: "TokenPermissions" },
757
+ { name: "spender", type: "address" },
758
+ { name: "nonce", type: "uint256" },
759
+ { name: "deadline", type: "uint256" },
760
+ { name: "witness", type: "EscrowLock" }
761
+ ],
762
+ EscrowLock: [
763
+ { name: "tradeId", type: "bytes32" },
764
+ { name: "counterparty", type: "address" }
765
+ ],
766
+ TokenPermissions: [
767
+ { name: "token", type: "address" },
768
+ { name: "amount", type: "uint256" }
769
+ ]
770
+ };
696
771
  var EIP3009_TYPES = {
697
772
  TransferWithAuthorization: [
698
773
  { name: "from", type: "address" },
@@ -729,8 +804,11 @@ function extractDomain(payload) {
729
804
  function getTypesForPayload(payload) {
730
805
  switch (payload.primaryType) {
731
806
  case "PermitBatchWitnessTransferFrom":
732
- case "PermitWitnessTransferFrom":
733
807
  return PERMIT2_TYPES;
808
+ // The batch types do not define this primary type at all, so mapping it
809
+ // to them made every single-token signature fail.
810
+ case "PermitWitnessTransferFrom":
811
+ return PERMIT2_ESCROW_LOCK_TYPES;
734
812
  case "TransferWithAuthorization":
735
813
  case "ReceiveWithAuthorization":
736
814
  case "CancelAuthorization":
@@ -798,6 +876,105 @@ async function signQuote(quote, signTypedDataAsync) {
798
876
  }
799
877
  }
800
878
 
879
+ // src/fiat/intent.ts
880
+ var import_viem4 = require("viem");
881
+
882
+ // src/fiat/corridors.ts
883
+ var NG_BANKS = [
884
+ { code: "044", name: "Access Bank" },
885
+ { code: "023", name: "Citibank Nigeria" },
886
+ { code: "050", name: "Ecobank Nigeria" },
887
+ { code: "070", name: "Fidelity Bank" },
888
+ { code: "011", name: "First Bank of Nigeria" },
889
+ { code: "214", name: "First City Monument Bank" },
890
+ { code: "058", name: "Guaranty Trust Bank" },
891
+ { code: "082", name: "Keystone Bank" },
892
+ { code: "50211", name: "Kuda Bank" },
893
+ { code: "50515", name: "Moniepoint MFB" },
894
+ { code: "999992", name: "OPay" },
895
+ { code: "999991", name: "PalmPay" },
896
+ { code: "076", name: "Polaris Bank" },
897
+ { code: "101", name: "Providus Bank" },
898
+ { code: "221", name: "Stanbic IBTC Bank" },
899
+ { code: "068", name: "Standard Chartered Bank" },
900
+ { code: "232", name: "Sterling Bank" },
901
+ { code: "032", name: "Union Bank of Nigeria" },
902
+ { code: "033", name: "United Bank For Africa" },
903
+ { code: "215", name: "Unity Bank" },
904
+ { code: "035", name: "Wema Bank" },
905
+ { code: "057", name: "Zenith Bank" }
906
+ ];
907
+ var FIAT_CORRIDORS = {
908
+ NGN: {
909
+ currency: "NGN",
910
+ rail: "ng.nip",
911
+ decimals: 2,
912
+ symbol: "\u20A6",
913
+ accountLabel: "Account number (NUBAN)",
914
+ accountPattern: /^\d{10}$/,
915
+ banks: NG_BANKS
916
+ }
917
+ };
918
+ var FIAT_CURRENCY_ALIASES = {
919
+ ngn: "NGN",
920
+ naira: "NGN",
921
+ "\u20A6": "NGN",
922
+ nigeria: "NGN",
923
+ nigerian: "NGN"
924
+ };
925
+ function normalizeFiatCurrency(raw) {
926
+ if (!raw) return null;
927
+ const k = raw.trim().toLowerCase();
928
+ return FIAT_CURRENCY_ALIASES[k] ?? (FIAT_CORRIDORS[k.toUpperCase()] ? k.toUpperCase() : null);
929
+ }
930
+ function formatFiat(minor, corridor) {
931
+ const n = typeof minor === "bigint" ? minor : BigInt(minor);
932
+ const scale = 10n ** BigInt(corridor.decimals);
933
+ const whole = (n / scale).toLocaleString("en-US");
934
+ if (corridor.decimals === 0) return whole;
935
+ return `${whole}.${(n % scale).toString().padStart(corridor.decimals, "0")}`;
936
+ }
937
+
938
+ // src/fiat/intent.ts
939
+ function validateOfframp(raw, supported) {
940
+ const issues = [];
941
+ if (!raw.amount) issues.push({ field: "amount", kind: "missing", message: "Amount is not stated." });
942
+ else if (!(Number(raw.amount) > 0)) issues.push({ field: "amount", kind: "unknown", message: "Amount must be a positive number." });
943
+ const chain = normalizeChain(raw.chainIn);
944
+ if (!raw.chainIn) issues.push({ field: "chainIn", kind: "missing", message: "Source chain is not stated." });
945
+ else if (!chain) issues.push({ field: "chainIn", kind: "unknown", message: `Chain '${raw.chainIn}' isn't recognized.` });
946
+ const sym = normalizeToken(raw.tokenIn);
947
+ if (!raw.tokenIn) issues.push({ field: "tokenIn", kind: "missing", message: "Source token is not stated." });
948
+ else if (!sym) issues.push({ field: "tokenIn", kind: "unknown", message: `Token '${raw.tokenIn}' isn't recognized.` });
949
+ else if (chain && !findAsset(supported, chain.id, sym)) {
950
+ issues.push({ field: "tokenIn", kind: "unsupported", message: `${sym} isn't supported on ${chain.name} yet.` });
951
+ }
952
+ const currency = normalizeFiatCurrency(raw.fiatCurrency);
953
+ if (!raw.fiatCurrency) {
954
+ issues.push({ field: "fiatCurrency", kind: "missing", message: "Say which currency to receive, e.g. NGN." });
955
+ } else if (!currency) {
956
+ const known = Object.keys(FIAT_CORRIDORS).join(", ");
957
+ issues.push({ field: "fiatCurrency", kind: "unsupported", message: `Cash out to ${raw.fiatCurrency} isn't available yet (only ${known}).` });
958
+ }
959
+ return issues;
960
+ }
961
+ function resolveOfframp(raw, supported) {
962
+ const chain = normalizeChain(raw.chainIn);
963
+ const sym = normalizeToken(raw.tokenIn);
964
+ const currency = normalizeFiatCurrency(raw.fiatCurrency);
965
+ const asset = chain && sym ? findAsset(supported, chain.id, sym) : void 0;
966
+ if (!chain || !asset || !currency || !raw.amount) throw new Error("resolveOfframp called on an invalid intent");
967
+ return {
968
+ chainId: chain.id,
969
+ chainName: chain.name,
970
+ asset: asset.symbol.toUpperCase(),
971
+ token: (0, import_viem4.getAddress)(asset.address),
972
+ decimals: asset.decimals,
973
+ amount: (0, import_viem4.parseUnits)(raw.amount, asset.decimals),
974
+ corridor: FIAT_CORRIDORS[currency]
975
+ };
976
+ }
977
+
801
978
  // src/session.ts
802
979
  var ERC20_TRANSFER_ABI = [
803
980
  {
@@ -814,7 +991,7 @@ var ERC20_TRANSFER_ABI = [
814
991
  var errMessage = (e) => e instanceof Error ? e.message : typeof e === "string" ? e : "Something went wrong.";
815
992
  function fmtUnits(amount, decimals) {
816
993
  if (amount === void 0) return "";
817
- const human = (0, import_viem4.formatUnits)(typeof amount === "bigint" ? amount : BigInt(amount), decimals);
994
+ const human = (0, import_viem5.formatUnits)(typeof amount === "bigint" ? amount : BigInt(amount), decimals);
818
995
  const n = Number(human);
819
996
  return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 6 }) : human;
820
997
  }
@@ -849,7 +1026,8 @@ var INITIAL = {
849
1026
  error: null,
850
1027
  isConnected: false,
851
1028
  queueIndex: 0,
852
- queueTotal: 0
1029
+ queueTotal: 0,
1030
+ offramp: null
853
1031
  };
854
1032
  function createIntentSession(options = {}) {
855
1033
  const api = options.api ?? createApiClient(options);
@@ -894,6 +1072,7 @@ function createIntentSession(options = {}) {
894
1072
  }
895
1073
  }
896
1074
  async function quoteFor(raw, gen) {
1075
+ if (raw.action === "offramp") return;
897
1076
  if (!wallet) return set({ phase: "needsWallet" });
898
1077
  try {
899
1078
  set({ phase: "quoting", error: null });
@@ -1044,6 +1223,12 @@ function createIntentSession(options = {}) {
1044
1223
  const parsed = await parseIntent(text, { hfAccessToken, model: options.hfModel, fetch: api.fetch });
1045
1224
  if (gen !== generation) return;
1046
1225
  if (!parsed.ok) return set({ phase: "offTemplate", hint: parsed.message });
1226
+ const offramp = parsed.intents.find((i) => i.action === "offramp");
1227
+ if (offramp) {
1228
+ const issues = parsed.intents.length > 1 ? [{ field: "fiatCurrency", kind: "unsupported", message: "Cash out on its own, not in the same sentence as other actions." }] : validateOfframp(offramp, supported);
1229
+ if (issues.length) return set({ phase: "invalid", issues });
1230
+ return set({ phase: "offramp", offramp: resolveOfframp(offramp, supported) });
1231
+ }
1047
1232
  for (const intent of parsed.intents) {
1048
1233
  const issues = validateIntent(intent, supported);
1049
1234
  if (issues.length) return set({ phase: "invalid", issues });
@@ -1113,7 +1298,8 @@ function viemWallet(client) {
1113
1298
  functionName: req.functionName,
1114
1299
  args: req.args
1115
1300
  }),
1116
- sendTransaction: (req) => client.sendTransaction({ account, chain: null, to: req.to, value: req.value })
1301
+ sendTransaction: (req) => client.sendTransaction({ account, chain: null, to: req.to, value: req.value }),
1302
+ signMessage: (message) => client.signMessage({ account, message })
1117
1303
  };
1118
1304
  }
1119
1305
  function stripDomainType(types) {
@@ -1121,15 +1307,400 @@ function stripDomainType(types) {
1121
1307
  for (const [name, fields] of Object.entries(types)) if (name !== "EIP712Domain") out[name] = fields;
1122
1308
  return out;
1123
1309
  }
1310
+
1311
+ // src/fiat/payee.ts
1312
+ var import_aes = require("@noble/ciphers/aes");
1313
+ var import_secp256k1 = require("@noble/curves/secp256k1");
1314
+ var import_hkdf = require("@noble/hashes/hkdf");
1315
+ var import_sha256 = require("@noble/hashes/sha256");
1316
+ var import_viem6 = require("viem");
1317
+ var ALG = "ECIES-secp256k1-AES256GCM";
1318
+ var ENVELOPE_VERSION = 1;
1319
+ var PAYLOAD_VERSION = 1;
1320
+ var GCM_TAG_BYTES = 16;
1321
+ var SALT_BYTES = 32;
1322
+ var MAX_NAME_LENGTH = 140;
1323
+ var HKDF_INFO = (0, import_viem6.stringToBytes)("recoilpay/fiat-payee/v1");
1324
+ var RAIL = /^[A-Za-z]{2}\.[A-Za-z0-9_]{1,30}$/;
1325
+ var BANK_CODE = /^[A-Za-z0-9]{0,16}$/;
1326
+ var ACCOUNT = /^\+?[A-Za-z0-9]{1,34}$/;
1327
+ var ASCII_SPACE = /[ \t\r\n]/g;
1328
+ function payeeIdentity(payee) {
1329
+ const rail = payee.rail.replace(ASCII_SPACE, "");
1330
+ const bankCode = payee.bankCode.replace(ASCII_SPACE, "");
1331
+ const account = payee.account.replace(ASCII_SPACE, "").replace(/-/g, "");
1332
+ if (!RAIL.test(rail)) throw new Error(`invalid rail: ${JSON.stringify(payee.rail)}`);
1333
+ if (!BANK_CODE.test(bankCode)) throw new Error("invalid bank code");
1334
+ if (!ACCOUNT.test(account)) throw new Error("invalid account number");
1335
+ return {
1336
+ rail: rail.toLowerCase(),
1337
+ bank_code: bankCode.toUpperCase(),
1338
+ account: account.toUpperCase()
1339
+ };
1340
+ }
1341
+ function canonicalPayeeIdentity(identity) {
1342
+ const { account, bank_code, rail } = payeeIdentity({
1343
+ rail: identity.rail,
1344
+ bankCode: identity.bank_code,
1345
+ account: identity.account
1346
+ });
1347
+ return JSON.stringify({ account, bank_code, rail });
1348
+ }
1349
+ function payeeCommitment(identity, salt) {
1350
+ const saltBytes = (0, import_viem6.hexToBytes)(salt);
1351
+ if (saltBytes.length !== SALT_BYTES) throw new Error(`salt must be ${SALT_BYTES} bytes`);
1352
+ return (0, import_viem6.keccak256)((0, import_viem6.concat)([(0, import_viem6.stringToBytes)(canonicalPayeeIdentity(identity)), saltBytes]));
1353
+ }
1354
+ function normaliseName(name) {
1355
+ const n = name.normalize("NFC").trim().replace(/\s+/g, " ");
1356
+ if (n.length === 0) throw new Error("the account holder name is required");
1357
+ if (n.length > MAX_NAME_LENGTH) throw new Error("the account holder name is too long");
1358
+ return n;
1359
+ }
1360
+ function deriveKey(sharedX, ephemeralPub) {
1361
+ return (0, import_hkdf.hkdf)(import_sha256.sha256, sharedX, ephemeralPub, HKDF_INFO, 32);
1362
+ }
1363
+ function sealPayee(payee, solverPubkey, salt) {
1364
+ const identity = payeeIdentity(payee);
1365
+ const name = normaliseName(payee.name);
1366
+ const saltHex = salt ?? (0, import_viem6.bytesToHex)(crypto.getRandomValues(new Uint8Array(SALT_BYTES)));
1367
+ const commitment = payeeCommitment(identity, saltHex);
1368
+ const recipient = (0, import_viem6.hexToBytes)(solverPubkey);
1369
+ if (recipient.length !== 65 || recipient[0] !== 4) {
1370
+ throw new Error("solver public key must be 65 bytes uncompressed (0x04\u2026)");
1371
+ }
1372
+ const plaintext = (0, import_viem6.stringToBytes)(
1373
+ JSON.stringify({ v: PAYLOAD_VERSION, ...identity, name, salt: saltHex })
1374
+ );
1375
+ const ephemeralPriv = import_secp256k1.secp256k1.utils.randomPrivateKey();
1376
+ const ephemeralPub = import_secp256k1.secp256k1.getPublicKey(ephemeralPriv, false);
1377
+ const shared = import_secp256k1.secp256k1.getSharedSecret(ephemeralPriv, recipient, true).slice(1);
1378
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1379
+ const out = (0, import_aes.gcm)(deriveKey(shared, ephemeralPub), iv).encrypt(plaintext);
1380
+ return {
1381
+ sealed: {
1382
+ v: ENVELOPE_VERSION,
1383
+ alg: ALG,
1384
+ epk: (0, import_viem6.bytesToHex)(ephemeralPub),
1385
+ iv: (0, import_viem6.bytesToHex)(iv),
1386
+ // @noble appends the tag; split so any standard GCM can read it.
1387
+ ct: (0, import_viem6.bytesToHex)(out.slice(0, out.length - GCM_TAG_BYTES)),
1388
+ tag: (0, import_viem6.bytesToHex)(out.slice(out.length - GCM_TAG_BYTES))
1389
+ },
1390
+ commitment,
1391
+ salt: saltHex
1392
+ };
1393
+ }
1394
+ function openPayee(sealed, solverPrivkey, expectedCommitment) {
1395
+ if (sealed.alg !== ALG) throw new Error(`unsupported envelope algorithm: ${sealed.alg}`);
1396
+ if (sealed.v !== ENVELOPE_VERSION) throw new Error(`unsupported envelope version: ${sealed.v}`);
1397
+ const epk = (0, import_viem6.hexToBytes)(sealed.epk);
1398
+ const shared = import_secp256k1.secp256k1.getSharedSecret((0, import_viem6.hexToBytes)(solverPrivkey), epk, true).slice(1);
1399
+ const plaintext = (0, import_aes.gcm)(deriveKey(shared, epk), (0, import_viem6.hexToBytes)(sealed.iv)).decrypt(
1400
+ (0, import_viem6.concat)([(0, import_viem6.hexToBytes)(sealed.ct), (0, import_viem6.hexToBytes)(sealed.tag)])
1401
+ );
1402
+ const body = JSON.parse(new TextDecoder().decode(plaintext));
1403
+ if (body.v !== PAYLOAD_VERSION) throw new Error(`unsupported payee payload version: ${String(body.v)}`);
1404
+ const identity = payeeIdentity({
1405
+ rail: String(body.rail ?? ""),
1406
+ bankCode: String(body.bank_code ?? ""),
1407
+ account: String(body.account ?? "")
1408
+ });
1409
+ const salt = String(body.salt ?? "");
1410
+ if (payeeCommitment(identity, salt).toLowerCase() !== expectedCommitment.toLowerCase()) {
1411
+ throw new Error("sealed payee details do not match the trade commitment");
1412
+ }
1413
+ return { identity, name: normaliseName(String(body.name ?? "")), salt };
1414
+ }
1415
+
1416
+ // src/fiat/offramp.ts
1417
+ var import_viem7 = require("viem");
1418
+
1419
+ // src/fiat/actions.ts
1420
+ function fiatConfirmMessage(tradeId) {
1421
+ return `RecoilPay: I confirm I received the fiat payment for trade ${tradeId}.`;
1422
+ }
1423
+ function fiatDisputeMessage(tradeId, reason) {
1424
+ return `RecoilPay: I dispute trade ${tradeId}. Reason: ${reason.trim()}`;
1425
+ }
1426
+ function fiatPaymentSentMessage(tradeId, reference) {
1427
+ return `RecoilPay: I have sent the fiat payment for trade ${tradeId}. Reference: ${reference.trim()}`;
1428
+ }
1429
+
1430
+ // src/fiat/offramp.ts
1431
+ var TERMINAL = ["settled_to_fiat_payer", "refunded_to_funder", "failed"];
1432
+ var PHASE_OF = {
1433
+ quoted: "locking",
1434
+ awaiting_fiat: "awaitingFiat",
1435
+ awaiting_confirmation: "awaitingConfirmation",
1436
+ disputed: "disputed",
1437
+ settled_to_fiat_payer: "settled",
1438
+ refunded_to_funder: "refunded",
1439
+ failed: "failed"
1440
+ };
1441
+ var PLAIN_TEXT = /^[\x20-\x7e]+$/;
1442
+ var MAX_REASON = 500;
1443
+ var errMessage2 = (e) => e instanceof Error ? e.message : typeof e === "string" ? e : "Something went wrong.";
1444
+ var same = (a, b) => typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase();
1445
+ function fmtAsset(amount, decimals) {
1446
+ const n = Number((0, import_viem7.formatUnits)(amount, decimals));
1447
+ return n.toLocaleString("en-US", { maximumFractionDigits: 6 });
1448
+ }
1449
+ function checkLock(lock, trade, request, quote, user) {
1450
+ const m = lock.typedData.message;
1451
+ const d = lock.typedData.domain;
1452
+ const problems = [
1453
+ lock.typedData.primaryType !== "PermitWitnessTransferFrom" && "not a Permit2 witness transfer",
1454
+ !same(d.verifyingContract, PERMIT2_ADDRESS) && "not signed for Permit2",
1455
+ Number(d.chainId) !== request.chainId && "the wrong chain",
1456
+ !same(m.permitted?.token, request.token) && "the wrong token",
1457
+ m.permitted?.amount !== request.amount.toString() && "the wrong amount",
1458
+ !same(m.spender, lock.escrow) && "a spender other than the escrow",
1459
+ !same(m.witness?.tradeId, lock.tradeId) && "another trade",
1460
+ !same(m.witness?.counterparty, trade.solver_address) && "a counterparty other than the solver",
1461
+ trade.quote_id !== quote.quoteId && "another quote",
1462
+ trade.fiat_minor_units !== quote.fiatNet && "a payout other than the one quoted",
1463
+ !same(trade.user_address, user) && "another wallet"
1464
+ ].filter(Boolean);
1465
+ if (problems.length) throw new Error(`Refusing to sign: the lock names ${problems.join(", ")}.`);
1466
+ }
1467
+ function fmtRate(rate) {
1468
+ const n = Number(rate);
1469
+ return Number.isFinite(n) ? n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 }) : rate;
1470
+ }
1471
+ var INITIAL2 = {
1472
+ phase: "idle",
1473
+ request: null,
1474
+ quote: null,
1475
+ preview: null,
1476
+ needsApproval: false,
1477
+ trade: null,
1478
+ payeeSalt: null,
1479
+ error: null
1480
+ };
1481
+ function createOfframpSession(options = {}) {
1482
+ const api = options.api ?? createApiClient(options);
1483
+ const pollMs = options.pollIntervalMs ?? 3e3;
1484
+ let chainsPromise = null;
1485
+ const chains = () => chainsPromise ??= api.getChains().catch((e) => (chainsPromise = null, Promise.reject(e)));
1486
+ const reader = options.chainReader ?? rpcReaders(chains);
1487
+ let wallet = options.wallet ?? null;
1488
+ let pollTimer;
1489
+ let destroyed = false;
1490
+ let generation = 0;
1491
+ let state = { ...INITIAL2 };
1492
+ const listeners = /* @__PURE__ */ new Set();
1493
+ function set(patch) {
1494
+ if (destroyed) return;
1495
+ state = { ...state, ...patch };
1496
+ for (const l of listeners) l(state);
1497
+ }
1498
+ const stopPolling = () => {
1499
+ if (pollTimer) clearTimeout(pollTimer);
1500
+ pollTimer = void 0;
1501
+ };
1502
+ function showTrade(trade, patch = {}) {
1503
+ set({ trade, phase: PHASE_OF[trade.state], ...patch });
1504
+ }
1505
+ function track(tradeId, gen) {
1506
+ stopPolling();
1507
+ const poll = async () => {
1508
+ try {
1509
+ const trade = await api.getFiatTrade(tradeId);
1510
+ if (gen !== generation) return;
1511
+ if (state.phase === "confirming" || state.phase === "disputing") set({ trade });
1512
+ else showTrade(trade);
1513
+ if (TERMINAL.includes(trade.state)) return;
1514
+ } catch {
1515
+ }
1516
+ if (gen !== generation || destroyed) return;
1517
+ const wait = state.trade?.state === "disputed" ? pollMs * 10 : pollMs;
1518
+ pollTimer = setTimeout(poll, wait);
1519
+ };
1520
+ void poll();
1521
+ }
1522
+ async function quote(request) {
1523
+ const gen = ++generation;
1524
+ stopPolling();
1525
+ set({ ...INITIAL2, request, phase: wallet ? "quoting" : "needsWallet" });
1526
+ if (!wallet) return;
1527
+ try {
1528
+ const res = await api.rankFiatQuotes({
1529
+ direction: "offramp",
1530
+ chain: `eip155:${request.chainId}`,
1531
+ asset: request.asset,
1532
+ fiatCurrency: request.corridor.currency,
1533
+ rail: request.corridor.rail,
1534
+ assetAmount: request.amount.toString()
1535
+ });
1536
+ const best = res.quotes?.[0];
1537
+ if (!best) throw new Error(`No solver is cashing out ${request.asset} on ${request.chainName} to ${request.corridor.currency} for this amount right now.`);
1538
+ const pc = await reader(request.chainId).catch(() => null);
1539
+ const needsApproval = pc ? !await hasPermit2Allowance(pc, request.token, wallet.address, request.amount).catch(() => false) : true;
1540
+ if (gen !== generation) return;
1541
+ const c = request.corridor;
1542
+ set({
1543
+ phase: "quoted",
1544
+ quote: best,
1545
+ needsApproval,
1546
+ preview: {
1547
+ payAmount: fmtAsset(request.amount, request.decimals),
1548
+ paySymbol: request.asset,
1549
+ chainName: request.chainName,
1550
+ receiveAmount: formatFiat(best.fiatNet, c),
1551
+ currency: c.currency,
1552
+ currencySymbol: c.symbol,
1553
+ fee: formatFiat(best.fiatFee, c),
1554
+ rate: fmtRate(best.rate),
1555
+ paymentWindowSecs: best.paymentWindowSecs,
1556
+ quotesConsidered: res.totalConsidered
1557
+ }
1558
+ });
1559
+ } catch (e) {
1560
+ if (gen === generation) set({ phase: "error", error: errMessage2(e) });
1561
+ }
1562
+ }
1563
+ async function confirm(payee) {
1564
+ const { request, quote: chosen } = state;
1565
+ const w = wallet;
1566
+ if (!request || !chosen || !w || state.phase !== "quoted") return;
1567
+ const gen = generation;
1568
+ try {
1569
+ set({ error: null });
1570
+ const details = { ...payee, rail: request.corridor.rail };
1571
+ payeeIdentity(details);
1572
+ if (!request.corridor.accountPattern.test(details.account.replace(/[\s-]/g, ""))) {
1573
+ throw new Error(`That doesn't look like a valid ${request.corridor.accountLabel.toLowerCase()}.`);
1574
+ }
1575
+ if (!details.name.trim()) throw new Error("Enter the account holder's name.");
1576
+ if (await w.getChainId() !== request.chainId) {
1577
+ set({ phase: "switchingChain" });
1578
+ await w.switchChain(request.chainId);
1579
+ }
1580
+ const pc = await reader(request.chainId).catch(() => null);
1581
+ const needsApproval = pc ? !await hasPermit2Allowance(pc, request.token, w.address, request.amount) : state.needsApproval;
1582
+ if (needsApproval) {
1583
+ set({ phase: "approving" });
1584
+ const approve = buildPermit2ApproveRequest(request.token);
1585
+ const hash = await w.writeContract({ chainId: request.chainId, ...approve, abi: approve.abi });
1586
+ if (pc) await pc.waitForTransactionReceipt({ hash });
1587
+ set({ needsApproval: false });
1588
+ }
1589
+ set({ phase: "opening" });
1590
+ const { sealed, commitment, salt } = sealPayee(details, chosen.payeeKey);
1591
+ const opened = await api.openFiatTrade({
1592
+ quoteId: chosen.quoteId,
1593
+ assetAmount: request.amount.toString(),
1594
+ userAddress: (0, import_viem7.getAddress)(w.address),
1595
+ payee: { sealed, commitment }
1596
+ });
1597
+ if (gen !== generation) return;
1598
+ set({ trade: opened.trade, payeeSalt: salt });
1599
+ checkLock(opened.lock, opened.trade, request, chosen, w.address);
1600
+ if (!same(opened.trade.payee_commitment, commitment)) throw new Error("Refusing to sign: the trade is not for the bank details you entered.");
1601
+ set({ phase: "signing" });
1602
+ const { domain, types, primaryType, message } = opened.lock.typedData;
1603
+ const signature = await w.signTypedData({ domain, types, primaryType, message });
1604
+ set({ phase: "locking" });
1605
+ const trade = await api.submitFiatLock(opened.trade.id, signature);
1606
+ if (gen !== generation) return;
1607
+ showTrade(trade);
1608
+ track(trade.id, gen);
1609
+ } catch (e) {
1610
+ if (gen !== generation) return;
1611
+ if (state.trade) {
1612
+ set({ error: errMessage2(e) });
1613
+ track(state.trade.id, gen);
1614
+ } else set({ phase: "error", error: errMessage2(e) });
1615
+ }
1616
+ }
1617
+ async function act(phase, message, send) {
1618
+ const trade = state.trade;
1619
+ const w = wallet;
1620
+ if (!trade || trade.state !== "awaiting_confirmation") return;
1621
+ const gen = generation;
1622
+ const before = state.phase;
1623
+ try {
1624
+ if (!w) throw new Error("Connect the wallet that opened this trade.");
1625
+ if (!same(w.address, trade.user_address)) throw new Error("Switch to the wallet that opened this trade.");
1626
+ if (!w.signMessage) throw new Error("This wallet can't sign messages, so it can't confirm or dispute.");
1627
+ set({ phase, error: null });
1628
+ const signature = await w.signMessage(message(trade.id));
1629
+ const next = await send(trade.id, signature);
1630
+ if (gen !== generation) return;
1631
+ showTrade(next);
1632
+ track(next.id, gen);
1633
+ } catch (e) {
1634
+ if (gen === generation) set({ phase: before, error: errMessage2(e) });
1635
+ }
1636
+ }
1637
+ async function dispute(reason) {
1638
+ const r = reason.trim();
1639
+ if (!r || r.length > MAX_REASON || !PLAIN_TEXT.test(r)) {
1640
+ set({ error: `Describe the problem in 1 to ${MAX_REASON} plain characters (letters, digits, punctuation).` });
1641
+ return;
1642
+ }
1643
+ await act("disputing", (id) => fiatDisputeMessage(id, r), (id, sig) => api.disputeFiatTrade(id, r, sig));
1644
+ }
1645
+ async function resume(tradeId) {
1646
+ const gen = ++generation;
1647
+ stopPolling();
1648
+ set({ ...INITIAL2, phase: "quoting" });
1649
+ try {
1650
+ const trade = await api.getFiatTrade(tradeId);
1651
+ if (gen !== generation) return;
1652
+ showTrade(trade);
1653
+ track(trade.id, gen);
1654
+ } catch (e) {
1655
+ if (gen === generation) set({ phase: "error", error: errMessage2(e) });
1656
+ }
1657
+ }
1658
+ function reset() {
1659
+ generation++;
1660
+ stopPolling();
1661
+ set({ ...INITIAL2 });
1662
+ }
1663
+ function setWallet(next) {
1664
+ const changed = wallet?.address !== next?.address;
1665
+ wallet = next;
1666
+ if (next && state.request && !state.trade && (state.phase === "needsWallet" || changed && state.phase === "quoted")) {
1667
+ void quote(state.request);
1668
+ }
1669
+ }
1670
+ return {
1671
+ getState: () => state,
1672
+ subscribe(listener) {
1673
+ listeners.add(listener);
1674
+ return () => listeners.delete(listener);
1675
+ },
1676
+ quote,
1677
+ confirm,
1678
+ confirmReceived: () => act("confirming", fiatConfirmMessage, (id, sig) => api.confirmFiatTrade(id, sig)),
1679
+ dispute,
1680
+ resume,
1681
+ reset,
1682
+ setWallet,
1683
+ destroy() {
1684
+ generation++;
1685
+ stopPolling();
1686
+ destroyed = true;
1687
+ listeners.clear();
1688
+ }
1689
+ };
1690
+ }
1124
1691
  // Annotate the CommonJS export names for ESM import in node:
1125
1692
  0 && (module.exports = {
1126
1693
  ApiError,
1694
+ BANK_DETAILS_HINT,
1127
1695
  CHAIN_ALIASES,
1128
1696
  DEFAULT_API_URL,
1129
1697
  DEFAULT_INTENT_MODEL,
1130
1698
  EMPTY_SUPPORTED,
1699
+ FIAT_CORRIDORS,
1700
+ FIAT_CURRENCY_ALIASES,
1131
1701
  HF_ROUTER_URL,
1132
1702
  MAX_UINT256,
1703
+ NG_BANKS,
1133
1704
  PERMIT2_ADDRESS,
1134
1705
  SOLANA_CHAIN_IDS,
1135
1706
  TEMPLATE_HINT,
@@ -1138,23 +1709,38 @@ function stripDomainType(types) {
1138
1709
  buildPermit2ApproveRequest,
1139
1710
  buildQuoteRequest,
1140
1711
  buildSupportedSet,
1712
+ canonicalPayeeIdentity,
1141
1713
  chainName,
1714
+ checkLock,
1142
1715
  createApiClient,
1143
1716
  createIntentSession,
1717
+ createOfframpSession,
1144
1718
  explorerUrl,
1145
1719
  extractIntents,
1720
+ fiatConfirmMessage,
1721
+ fiatDisputeMessage,
1722
+ fiatPaymentSentMessage,
1146
1723
  findAsset,
1724
+ formatFiat,
1147
1725
  getPermit2Allowance,
1148
1726
  hasPermit2Allowance,
1149
1727
  interopAddress,
1150
1728
  isTerminalStatus,
1729
+ looksLikeBankDetails,
1151
1730
  normalizeChain,
1731
+ normalizeFiatCurrency,
1152
1732
  normalizeToken,
1733
+ openPayee,
1153
1734
  parseIntent,
1735
+ payeeCommitment,
1736
+ payeeIdentity,
1154
1737
  resolveIntent,
1738
+ resolveOfframp,
1155
1739
  rpcReaders,
1740
+ sealPayee,
1156
1741
  signQuote,
1157
1742
  stripDomainType,
1158
1743
  validateIntent,
1744
+ validateOfframp,
1159
1745
  viemWallet
1160
1746
  });