@recoilpay/intent-core 0.2.0 → 0.4.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,48 @@ __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
+ fiatClaimMessage: () => fiatClaimMessage,
51
+ fiatConfirmMessage: () => fiatConfirmMessage,
52
+ fiatDisputeMessage: () => fiatDisputeMessage,
53
+ fiatPaymentSentMessage: () => fiatPaymentSentMessage,
54
+ fiatRevealMessage: () => fiatRevealMessage,
55
+ fiatStatementMessage: () => fiatStatementMessage,
43
56
  findAsset: () => findAsset,
57
+ formatFiat: () => formatFiat,
44
58
  getPermit2Allowance: () => getPermit2Allowance,
45
59
  hasPermit2Allowance: () => hasPermit2Allowance,
46
60
  interopAddress: () => interopAddress,
47
61
  isTerminalStatus: () => isTerminalStatus,
62
+ looksLikeBankDetails: () => looksLikeBankDetails,
48
63
  normalizeChain: () => normalizeChain,
64
+ normalizeFiatCurrency: () => normalizeFiatCurrency,
49
65
  normalizeToken: () => normalizeToken,
66
+ openPayee: () => openPayee,
50
67
  parseIntent: () => parseIntent,
68
+ payeeCommitment: () => payeeCommitment,
69
+ payeeIdentity: () => payeeIdentity,
51
70
  resolveIntent: () => resolveIntent,
71
+ resolveOfframp: () => resolveOfframp,
52
72
  rpcReaders: () => rpcReaders,
73
+ sealPayee: () => sealPayee,
53
74
  signQuote: () => signQuote,
54
75
  stripDomainType: () => stripDomainType,
55
76
  validateIntent: () => validateIntent,
77
+ validateOfframp: () => validateOfframp,
56
78
  viemWallet: () => viemWallet
57
79
  });
58
80
  module.exports = __toCommonJS(index_exports);
59
81
 
60
82
  // src/session.ts
61
- var import_viem4 = require("viem");
83
+ var import_viem5 = require("viem");
62
84
 
63
85
  // src/api.ts
64
86
  var DEFAULT_API_URL = "https://api.recoilpay.com";
@@ -117,12 +139,29 @@ function createApiClient(options = {}) {
117
139
  },
118
140
  getQuotes: (req) => post("/quotes", req),
119
141
  submitOrder: (req) => post("/orders", req),
120
- getOrder: (id) => request(`/orders/${encodeURIComponent(id)}`)
142
+ getOrder: (id) => request(`/orders/${encodeURIComponent(id)}`),
143
+ // ── Fiat ramps (design §12.1) ──
144
+ rankFiatQuotes: (req) => post("/fiat/quotes", req),
145
+ openFiatTrade: (req) => post("/fiat/trades", req),
146
+ getFiatTrade: (id) => request(`/fiat/trades/${encodeURIComponent(id)}`),
147
+ /** Hand the signed lock to the solver's relay, which submits it and pays the gas. */
148
+ submitFiatLock: (id, signature) => post(`/fiat/trades/${encodeURIComponent(id)}/lock`, { signature }),
149
+ /** Report a lock the user submitted themselves; the aggregator reads it from the chain. */
150
+ reportFiatFunded: (id, txHash) => post(`/fiat/trades/${encodeURIComponent(id)}/funded`, txHash ? { txHash } : {}),
151
+ /** `signature` is over `fiatConfirmMessage(id)`. */
152
+ confirmFiatTrade: (id, signature) => post(`/fiat/trades/${encodeURIComponent(id)}/confirm`, { signature }),
153
+ /** `signature` is over `fiatDisputeMessage(id, reason)`. */
154
+ disputeFiatTrade: (id, reason, signature) => post(`/fiat/trades/${encodeURIComponent(id)}/dispute`, { reason, signature })
121
155
  };
122
156
  }
123
157
 
124
158
  // 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>";
159
+ 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)";
160
+ 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";
161
+ function looksLikeBankDetails(text) {
162
+ const rest = text.replace(/0x[0-9a-fA-F]+/g, " ").replace(/[.,]\d+/g, "");
163
+ return /(\d[\s-]?){10,}/.test(rest);
164
+ }
126
165
  var HF_ROUTER_URL = "https://router.huggingface.co/v1/chat/completions";
127
166
  var DEFAULT_INTENT_MODEL = "Qwen/Qwen2.5-Coder-32B-Instruct";
128
167
  var SYSTEM_PROMPT = `You are an intent extraction engine for a cross-chain swap protocol.
@@ -131,7 +170,7 @@ If the user specifies multiple intents (e.g. "swap X and then send Y"), return m
131
170
  The JSON objects must perfectly match the following TypeScript interface:
132
171
 
133
172
  type AmountKind = "token" | "usd";
134
- type IntentAction = "swap" | "send";
173
+ type IntentAction = "swap" | "send" | "offramp";
135
174
 
136
175
  interface RawIntent {
137
176
  action: IntentAction;
@@ -142,14 +181,19 @@ interface RawIntent {
142
181
  tokenOut: string | null; // target token (optional for send)
143
182
  chainOut: string | null; // target chain (optional for send)
144
183
  recipient: string | null; // address/ens (required for 'send', optional for 'swap')
184
+ fiatCurrency?: string | null; // 'offramp' only: the local currency to receive, e.g. "NGN" or "naira"
145
185
  }
146
186
 
187
+ Use "offramp" when the user wants to cash out, sell, or withdraw crypto to a bank account or local currency.
188
+ For "offramp", tokenIn/chainIn/amount describe the crypto they give; tokenOut, chainOut and recipient are null.
189
+ Never put bank names, account numbers or personal names in any field.
190
+
147
191
  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
192
  var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
149
193
  var readString = (v) => typeof v === "string" && v.trim().length > 0 ? v : null;
150
194
  var readAmount = (v) => v === null || v === void 0 ? null : String(v);
151
195
  var readAmountKind = (v) => v === "usd" ? "usd" : "token";
152
- var readAction = (v) => v === "swap" || v === "send" ? v : null;
196
+ var readAction = (v) => v === "swap" || v === "send" || v === "offramp" ? v : null;
153
197
  var offTemplate = (message = TEMPLATE_HINT) => ({ ok: false, offTemplate: true, message });
154
198
  function extractIntents(content) {
155
199
  let json = content.trim();
@@ -174,7 +218,7 @@ function extractIntents(content) {
174
218
  for (const item of items) {
175
219
  const action = readAction(item.action);
176
220
  if (!action) return offTemplate();
177
- intents.push({
221
+ const intent = {
178
222
  action,
179
223
  amount: readAmount(item.amount),
180
224
  amountKind: readAmountKind(item.amountKind),
@@ -183,13 +227,16 @@ function extractIntents(content) {
183
227
  tokenOut: readString(item.tokenOut),
184
228
  chainOut: readString(item.chainOut),
185
229
  recipient: readString(item.recipient)
186
- });
230
+ };
231
+ if (action === "offramp") intent.fiatCurrency = readString(item.fiatCurrency);
232
+ intents.push(intent);
187
233
  }
188
234
  return { ok: true, intents };
189
235
  }
190
236
  async function parseIntent(text, options = {}) {
191
237
  const norm = text.trim();
192
238
  if (!norm) return offTemplate();
239
+ if (looksLikeBankDetails(norm)) return offTemplate(BANK_DETAILS_HINT);
193
240
  const token = options.hfAccessToken?.trim();
194
241
  if (!token) return offTemplate(`Natural-language parsing isn't set up here. ${TEMPLATE_HINT}`);
195
242
  const doFetch = options.fetch ?? ((...args) => fetch(...args));
@@ -437,7 +484,8 @@ var LABEL = {
437
484
  chainIn: "Source chain",
438
485
  tokenOut: "Destination token",
439
486
  chainOut: "Destination chain",
440
- recipient: "Recipient"
487
+ recipient: "Recipient",
488
+ fiatCurrency: "Currency"
441
489
  };
442
490
  var EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
443
491
  var SOLANA_ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
@@ -706,6 +754,23 @@ var PERMIT2_TYPES = {
706
754
  { name: "amount", type: "uint256" }
707
755
  ]
708
756
  };
757
+ var PERMIT2_ESCROW_LOCK_TYPES = {
758
+ PermitWitnessTransferFrom: [
759
+ { name: "permitted", type: "TokenPermissions" },
760
+ { name: "spender", type: "address" },
761
+ { name: "nonce", type: "uint256" },
762
+ { name: "deadline", type: "uint256" },
763
+ { name: "witness", type: "EscrowLock" }
764
+ ],
765
+ EscrowLock: [
766
+ { name: "tradeId", type: "bytes32" },
767
+ { name: "counterparty", type: "address" }
768
+ ],
769
+ TokenPermissions: [
770
+ { name: "token", type: "address" },
771
+ { name: "amount", type: "uint256" }
772
+ ]
773
+ };
709
774
  var EIP3009_TYPES = {
710
775
  TransferWithAuthorization: [
711
776
  { name: "from", type: "address" },
@@ -742,8 +807,11 @@ function extractDomain(payload) {
742
807
  function getTypesForPayload(payload) {
743
808
  switch (payload.primaryType) {
744
809
  case "PermitBatchWitnessTransferFrom":
745
- case "PermitWitnessTransferFrom":
746
810
  return PERMIT2_TYPES;
811
+ // The batch types do not define this primary type at all, so mapping it
812
+ // to them made every single-token signature fail.
813
+ case "PermitWitnessTransferFrom":
814
+ return PERMIT2_ESCROW_LOCK_TYPES;
747
815
  case "TransferWithAuthorization":
748
816
  case "ReceiveWithAuthorization":
749
817
  case "CancelAuthorization":
@@ -811,6 +879,105 @@ async function signQuote(quote, signTypedDataAsync) {
811
879
  }
812
880
  }
813
881
 
882
+ // src/fiat/intent.ts
883
+ var import_viem4 = require("viem");
884
+
885
+ // src/fiat/corridors.ts
886
+ var NG_BANKS = [
887
+ { code: "044", name: "Access Bank" },
888
+ { code: "023", name: "Citibank Nigeria" },
889
+ { code: "050", name: "Ecobank Nigeria" },
890
+ { code: "070", name: "Fidelity Bank" },
891
+ { code: "011", name: "First Bank of Nigeria" },
892
+ { code: "214", name: "First City Monument Bank" },
893
+ { code: "058", name: "Guaranty Trust Bank" },
894
+ { code: "082", name: "Keystone Bank" },
895
+ { code: "50211", name: "Kuda Bank" },
896
+ { code: "50515", name: "Moniepoint MFB" },
897
+ { code: "999992", name: "OPay" },
898
+ { code: "999991", name: "PalmPay" },
899
+ { code: "076", name: "Polaris Bank" },
900
+ { code: "101", name: "Providus Bank" },
901
+ { code: "221", name: "Stanbic IBTC Bank" },
902
+ { code: "068", name: "Standard Chartered Bank" },
903
+ { code: "232", name: "Sterling Bank" },
904
+ { code: "032", name: "Union Bank of Nigeria" },
905
+ { code: "033", name: "United Bank For Africa" },
906
+ { code: "215", name: "Unity Bank" },
907
+ { code: "035", name: "Wema Bank" },
908
+ { code: "057", name: "Zenith Bank" }
909
+ ];
910
+ var FIAT_CORRIDORS = {
911
+ NGN: {
912
+ currency: "NGN",
913
+ rail: "ng.nip",
914
+ decimals: 2,
915
+ symbol: "\u20A6",
916
+ accountLabel: "Account number (NUBAN)",
917
+ accountPattern: /^\d{10}$/,
918
+ banks: NG_BANKS
919
+ }
920
+ };
921
+ var FIAT_CURRENCY_ALIASES = {
922
+ ngn: "NGN",
923
+ naira: "NGN",
924
+ "\u20A6": "NGN",
925
+ nigeria: "NGN",
926
+ nigerian: "NGN"
927
+ };
928
+ function normalizeFiatCurrency(raw) {
929
+ if (!raw) return null;
930
+ const k = raw.trim().toLowerCase();
931
+ return FIAT_CURRENCY_ALIASES[k] ?? (FIAT_CORRIDORS[k.toUpperCase()] ? k.toUpperCase() : null);
932
+ }
933
+ function formatFiat(minor, corridor) {
934
+ const n = typeof minor === "bigint" ? minor : BigInt(minor);
935
+ const scale = 10n ** BigInt(corridor.decimals);
936
+ const whole = (n / scale).toLocaleString("en-US");
937
+ if (corridor.decimals === 0) return whole;
938
+ return `${whole}.${(n % scale).toString().padStart(corridor.decimals, "0")}`;
939
+ }
940
+
941
+ // src/fiat/intent.ts
942
+ function validateOfframp(raw, supported) {
943
+ const issues = [];
944
+ if (!raw.amount) issues.push({ field: "amount", kind: "missing", message: "Amount is not stated." });
945
+ else if (!(Number(raw.amount) > 0)) issues.push({ field: "amount", kind: "unknown", message: "Amount must be a positive number." });
946
+ const chain = normalizeChain(raw.chainIn);
947
+ if (!raw.chainIn) issues.push({ field: "chainIn", kind: "missing", message: "Source chain is not stated." });
948
+ else if (!chain) issues.push({ field: "chainIn", kind: "unknown", message: `Chain '${raw.chainIn}' isn't recognized.` });
949
+ const sym = normalizeToken(raw.tokenIn);
950
+ if (!raw.tokenIn) issues.push({ field: "tokenIn", kind: "missing", message: "Source token is not stated." });
951
+ else if (!sym) issues.push({ field: "tokenIn", kind: "unknown", message: `Token '${raw.tokenIn}' isn't recognized.` });
952
+ else if (chain && !findAsset(supported, chain.id, sym)) {
953
+ issues.push({ field: "tokenIn", kind: "unsupported", message: `${sym} isn't supported on ${chain.name} yet.` });
954
+ }
955
+ const currency = normalizeFiatCurrency(raw.fiatCurrency);
956
+ if (!raw.fiatCurrency) {
957
+ issues.push({ field: "fiatCurrency", kind: "missing", message: "Say which currency to receive, e.g. NGN." });
958
+ } else if (!currency) {
959
+ const known = Object.keys(FIAT_CORRIDORS).join(", ");
960
+ issues.push({ field: "fiatCurrency", kind: "unsupported", message: `Cash out to ${raw.fiatCurrency} isn't available yet (only ${known}).` });
961
+ }
962
+ return issues;
963
+ }
964
+ function resolveOfframp(raw, supported) {
965
+ const chain = normalizeChain(raw.chainIn);
966
+ const sym = normalizeToken(raw.tokenIn);
967
+ const currency = normalizeFiatCurrency(raw.fiatCurrency);
968
+ const asset = chain && sym ? findAsset(supported, chain.id, sym) : void 0;
969
+ if (!chain || !asset || !currency || !raw.amount) throw new Error("resolveOfframp called on an invalid intent");
970
+ return {
971
+ chainId: chain.id,
972
+ chainName: chain.name,
973
+ asset: asset.symbol.toUpperCase(),
974
+ token: (0, import_viem4.getAddress)(asset.address),
975
+ decimals: asset.decimals,
976
+ amount: (0, import_viem4.parseUnits)(raw.amount, asset.decimals),
977
+ corridor: FIAT_CORRIDORS[currency]
978
+ };
979
+ }
980
+
814
981
  // src/session.ts
815
982
  var ERC20_TRANSFER_ABI = [
816
983
  {
@@ -827,7 +994,7 @@ var ERC20_TRANSFER_ABI = [
827
994
  var errMessage = (e) => e instanceof Error ? e.message : typeof e === "string" ? e : "Something went wrong.";
828
995
  function fmtUnits(amount, decimals) {
829
996
  if (amount === void 0) return "";
830
- const human = (0, import_viem4.formatUnits)(typeof amount === "bigint" ? amount : BigInt(amount), decimals);
997
+ const human = (0, import_viem5.formatUnits)(typeof amount === "bigint" ? amount : BigInt(amount), decimals);
831
998
  const n = Number(human);
832
999
  return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 6 }) : human;
833
1000
  }
@@ -862,7 +1029,8 @@ var INITIAL = {
862
1029
  error: null,
863
1030
  isConnected: false,
864
1031
  queueIndex: 0,
865
- queueTotal: 0
1032
+ queueTotal: 0,
1033
+ offramp: null
866
1034
  };
867
1035
  function createIntentSession(options = {}) {
868
1036
  const api = options.api ?? createApiClient(options);
@@ -907,6 +1075,7 @@ function createIntentSession(options = {}) {
907
1075
  }
908
1076
  }
909
1077
  async function quoteFor(raw, gen) {
1078
+ if (raw.action === "offramp") return;
910
1079
  if (!wallet) return set({ phase: "needsWallet" });
911
1080
  try {
912
1081
  set({ phase: "quoting", error: null });
@@ -1057,6 +1226,12 @@ function createIntentSession(options = {}) {
1057
1226
  const parsed = await parseIntent(text, { hfAccessToken, model: options.hfModel, fetch: api.fetch });
1058
1227
  if (gen !== generation) return;
1059
1228
  if (!parsed.ok) return set({ phase: "offTemplate", hint: parsed.message });
1229
+ const offramp = parsed.intents.find((i) => i.action === "offramp");
1230
+ if (offramp) {
1231
+ 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);
1232
+ if (issues.length) return set({ phase: "invalid", issues });
1233
+ return set({ phase: "offramp", offramp: resolveOfframp(offramp, supported) });
1234
+ }
1060
1235
  for (const intent of parsed.intents) {
1061
1236
  const issues = validateIntent(intent, supported);
1062
1237
  if (issues.length) return set({ phase: "invalid", issues });
@@ -1126,7 +1301,8 @@ function viemWallet(client) {
1126
1301
  functionName: req.functionName,
1127
1302
  args: req.args
1128
1303
  }),
1129
- sendTransaction: (req) => client.sendTransaction({ account, chain: null, to: req.to, value: req.value })
1304
+ sendTransaction: (req) => client.sendTransaction({ account, chain: null, to: req.to, value: req.value }),
1305
+ signMessage: (message) => client.signMessage({ account, message })
1130
1306
  };
1131
1307
  }
1132
1308
  function stripDomainType(types) {
@@ -1134,15 +1310,409 @@ function stripDomainType(types) {
1134
1310
  for (const [name, fields] of Object.entries(types)) if (name !== "EIP712Domain") out[name] = fields;
1135
1311
  return out;
1136
1312
  }
1313
+
1314
+ // src/fiat/payee.ts
1315
+ var import_aes = require("@noble/ciphers/aes");
1316
+ var import_secp256k1 = require("@noble/curves/secp256k1");
1317
+ var import_hkdf = require("@noble/hashes/hkdf");
1318
+ var import_sha256 = require("@noble/hashes/sha256");
1319
+ var import_viem6 = require("viem");
1320
+ var ALG = "ECIES-secp256k1-AES256GCM";
1321
+ var ENVELOPE_VERSION = 1;
1322
+ var PAYLOAD_VERSION = 1;
1323
+ var GCM_TAG_BYTES = 16;
1324
+ var SALT_BYTES = 32;
1325
+ var MAX_NAME_LENGTH = 140;
1326
+ var HKDF_INFO = (0, import_viem6.stringToBytes)("recoilpay/fiat-payee/v1");
1327
+ var RAIL = /^[A-Za-z]{2}\.[A-Za-z0-9_]{1,30}$/;
1328
+ var BANK_CODE = /^[A-Za-z0-9]{0,16}$/;
1329
+ var ACCOUNT = /^\+?[A-Za-z0-9]{1,34}$/;
1330
+ var ASCII_SPACE = /[ \t\r\n]/g;
1331
+ function payeeIdentity(payee) {
1332
+ const rail = payee.rail.replace(ASCII_SPACE, "");
1333
+ const bankCode = payee.bankCode.replace(ASCII_SPACE, "");
1334
+ const account = payee.account.replace(ASCII_SPACE, "").replace(/-/g, "");
1335
+ if (!RAIL.test(rail)) throw new Error(`invalid rail: ${JSON.stringify(payee.rail)}`);
1336
+ if (!BANK_CODE.test(bankCode)) throw new Error("invalid bank code");
1337
+ if (!ACCOUNT.test(account)) throw new Error("invalid account number");
1338
+ return {
1339
+ rail: rail.toLowerCase(),
1340
+ bank_code: bankCode.toUpperCase(),
1341
+ account: account.toUpperCase()
1342
+ };
1343
+ }
1344
+ function canonicalPayeeIdentity(identity) {
1345
+ const { account, bank_code, rail } = payeeIdentity({
1346
+ rail: identity.rail,
1347
+ bankCode: identity.bank_code,
1348
+ account: identity.account
1349
+ });
1350
+ return JSON.stringify({ account, bank_code, rail });
1351
+ }
1352
+ function payeeCommitment(identity, salt) {
1353
+ const saltBytes = (0, import_viem6.hexToBytes)(salt);
1354
+ if (saltBytes.length !== SALT_BYTES) throw new Error(`salt must be ${SALT_BYTES} bytes`);
1355
+ return (0, import_viem6.keccak256)((0, import_viem6.concat)([(0, import_viem6.stringToBytes)(canonicalPayeeIdentity(identity)), saltBytes]));
1356
+ }
1357
+ function normaliseName(name) {
1358
+ const n = name.normalize("NFC").trim().replace(/\s+/g, " ");
1359
+ if (n.length === 0) throw new Error("the account holder name is required");
1360
+ if (n.length > MAX_NAME_LENGTH) throw new Error("the account holder name is too long");
1361
+ return n;
1362
+ }
1363
+ function deriveKey(sharedX, ephemeralPub) {
1364
+ return (0, import_hkdf.hkdf)(import_sha256.sha256, sharedX, ephemeralPub, HKDF_INFO, 32);
1365
+ }
1366
+ function sealPayee(payee, solverPubkey, salt) {
1367
+ const identity = payeeIdentity(payee);
1368
+ const name = normaliseName(payee.name);
1369
+ const saltHex = salt ?? (0, import_viem6.bytesToHex)(crypto.getRandomValues(new Uint8Array(SALT_BYTES)));
1370
+ const commitment = payeeCommitment(identity, saltHex);
1371
+ const recipient = (0, import_viem6.hexToBytes)(solverPubkey);
1372
+ if (recipient.length !== 65 || recipient[0] !== 4) {
1373
+ throw new Error("solver public key must be 65 bytes uncompressed (0x04\u2026)");
1374
+ }
1375
+ const plaintext = (0, import_viem6.stringToBytes)(
1376
+ JSON.stringify({ v: PAYLOAD_VERSION, ...identity, name, salt: saltHex })
1377
+ );
1378
+ const ephemeralPriv = import_secp256k1.secp256k1.utils.randomPrivateKey();
1379
+ const ephemeralPub = import_secp256k1.secp256k1.getPublicKey(ephemeralPriv, false);
1380
+ const shared = import_secp256k1.secp256k1.getSharedSecret(ephemeralPriv, recipient, true).slice(1);
1381
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1382
+ const out = (0, import_aes.gcm)(deriveKey(shared, ephemeralPub), iv).encrypt(plaintext);
1383
+ return {
1384
+ sealed: {
1385
+ v: ENVELOPE_VERSION,
1386
+ alg: ALG,
1387
+ epk: (0, import_viem6.bytesToHex)(ephemeralPub),
1388
+ iv: (0, import_viem6.bytesToHex)(iv),
1389
+ // @noble appends the tag; split so any standard GCM can read it.
1390
+ ct: (0, import_viem6.bytesToHex)(out.slice(0, out.length - GCM_TAG_BYTES)),
1391
+ tag: (0, import_viem6.bytesToHex)(out.slice(out.length - GCM_TAG_BYTES))
1392
+ },
1393
+ commitment,
1394
+ salt: saltHex
1395
+ };
1396
+ }
1397
+ function openPayee(sealed, solverPrivkey, expectedCommitment) {
1398
+ if (sealed.alg !== ALG) throw new Error(`unsupported envelope algorithm: ${sealed.alg}`);
1399
+ if (sealed.v !== ENVELOPE_VERSION) throw new Error(`unsupported envelope version: ${sealed.v}`);
1400
+ const epk = (0, import_viem6.hexToBytes)(sealed.epk);
1401
+ const shared = import_secp256k1.secp256k1.getSharedSecret((0, import_viem6.hexToBytes)(solverPrivkey), epk, true).slice(1);
1402
+ const plaintext = (0, import_aes.gcm)(deriveKey(shared, epk), (0, import_viem6.hexToBytes)(sealed.iv)).decrypt(
1403
+ (0, import_viem6.concat)([(0, import_viem6.hexToBytes)(sealed.ct), (0, import_viem6.hexToBytes)(sealed.tag)])
1404
+ );
1405
+ const body = JSON.parse(new TextDecoder().decode(plaintext));
1406
+ if (body.v !== PAYLOAD_VERSION) throw new Error(`unsupported payee payload version: ${String(body.v)}`);
1407
+ const identity = payeeIdentity({
1408
+ rail: String(body.rail ?? ""),
1409
+ bankCode: String(body.bank_code ?? ""),
1410
+ account: String(body.account ?? "")
1411
+ });
1412
+ const salt = String(body.salt ?? "");
1413
+ if (payeeCommitment(identity, salt).toLowerCase() !== expectedCommitment.toLowerCase()) {
1414
+ throw new Error("sealed payee details do not match the trade commitment");
1415
+ }
1416
+ return { identity, name: normaliseName(String(body.name ?? "")), salt };
1417
+ }
1418
+
1419
+ // src/fiat/offramp.ts
1420
+ var import_viem7 = require("viem");
1421
+
1422
+ // src/fiat/actions.ts
1423
+ function fiatConfirmMessage(tradeId) {
1424
+ return `RecoilPay: I confirm I received the fiat payment for trade ${tradeId}.`;
1425
+ }
1426
+ function fiatDisputeMessage(tradeId, reason) {
1427
+ return `RecoilPay: I dispute trade ${tradeId}. Reason: ${reason.trim()}`;
1428
+ }
1429
+ function fiatPaymentSentMessage(tradeId, reference) {
1430
+ return `RecoilPay: I have sent the fiat payment for trade ${tradeId}. Reference: ${reference.trim()}`;
1431
+ }
1432
+ function fiatClaimMessage(tradeId, reason) {
1433
+ return `RecoilPay: I claim the fiat payment for trade ${tradeId} never arrived. Reason: ${reason.trim()}`;
1434
+ }
1435
+ function fiatRevealMessage(tradeId) {
1436
+ return `RecoilPay: I reveal the bank details I gave for trade ${tradeId}.`;
1437
+ }
1438
+ function fiatStatementMessage(tradeId, text) {
1439
+ return `RecoilPay: My statement on trade ${tradeId}: ${text.trim()}`;
1440
+ }
1441
+
1442
+ // src/fiat/offramp.ts
1443
+ var TERMINAL = ["settled_to_fiat_payer", "refunded_to_funder", "failed"];
1444
+ var PHASE_OF = {
1445
+ quoted: "locking",
1446
+ awaiting_fiat: "awaitingFiat",
1447
+ awaiting_confirmation: "awaitingConfirmation",
1448
+ disputed: "disputed",
1449
+ settled_to_fiat_payer: "settled",
1450
+ refunded_to_funder: "refunded",
1451
+ failed: "failed"
1452
+ };
1453
+ var PLAIN_TEXT = /^[\x20-\x7e]+$/;
1454
+ var MAX_REASON = 500;
1455
+ var errMessage2 = (e) => e instanceof Error ? e.message : typeof e === "string" ? e : "Something went wrong.";
1456
+ var same = (a, b) => typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase();
1457
+ function fmtAsset(amount, decimals) {
1458
+ const n = Number((0, import_viem7.formatUnits)(amount, decimals));
1459
+ return n.toLocaleString("en-US", { maximumFractionDigits: 6 });
1460
+ }
1461
+ function checkLock(lock, trade, request, quote, user) {
1462
+ const m = lock.typedData.message;
1463
+ const d = lock.typedData.domain;
1464
+ const problems = [
1465
+ lock.typedData.primaryType !== "PermitWitnessTransferFrom" && "not a Permit2 witness transfer",
1466
+ !same(d.verifyingContract, PERMIT2_ADDRESS) && "not signed for Permit2",
1467
+ Number(d.chainId) !== request.chainId && "the wrong chain",
1468
+ !same(m.permitted?.token, request.token) && "the wrong token",
1469
+ m.permitted?.amount !== request.amount.toString() && "the wrong amount",
1470
+ !same(m.spender, lock.escrow) && "a spender other than the escrow",
1471
+ !same(m.witness?.tradeId, lock.tradeId) && "another trade",
1472
+ !same(m.witness?.counterparty, trade.solver_address) && "a counterparty other than the solver",
1473
+ trade.quote_id !== quote.quoteId && "another quote",
1474
+ trade.fiat_minor_units !== quote.fiatNet && "a payout other than the one quoted",
1475
+ !same(trade.user_address, user) && "another wallet"
1476
+ ].filter(Boolean);
1477
+ if (problems.length) throw new Error(`Refusing to sign: the lock names ${problems.join(", ")}.`);
1478
+ }
1479
+ function fmtRate(rate) {
1480
+ const n = Number(rate);
1481
+ return Number.isFinite(n) ? n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 }) : rate;
1482
+ }
1483
+ var INITIAL2 = {
1484
+ phase: "idle",
1485
+ request: null,
1486
+ quote: null,
1487
+ preview: null,
1488
+ needsApproval: false,
1489
+ trade: null,
1490
+ payeeSalt: null,
1491
+ error: null
1492
+ };
1493
+ function createOfframpSession(options = {}) {
1494
+ const api = options.api ?? createApiClient(options);
1495
+ const pollMs = options.pollIntervalMs ?? 3e3;
1496
+ let chainsPromise = null;
1497
+ const chains = () => chainsPromise ??= api.getChains().catch((e) => (chainsPromise = null, Promise.reject(e)));
1498
+ const reader = options.chainReader ?? rpcReaders(chains);
1499
+ let wallet = options.wallet ?? null;
1500
+ let pollTimer;
1501
+ let destroyed = false;
1502
+ let generation = 0;
1503
+ let state = { ...INITIAL2 };
1504
+ const listeners = /* @__PURE__ */ new Set();
1505
+ function set(patch) {
1506
+ if (destroyed) return;
1507
+ state = { ...state, ...patch };
1508
+ for (const l of listeners) l(state);
1509
+ }
1510
+ const stopPolling = () => {
1511
+ if (pollTimer) clearTimeout(pollTimer);
1512
+ pollTimer = void 0;
1513
+ };
1514
+ function showTrade(trade, patch = {}) {
1515
+ set({ trade, phase: PHASE_OF[trade.state], ...patch });
1516
+ }
1517
+ function track(tradeId, gen) {
1518
+ stopPolling();
1519
+ const poll = async () => {
1520
+ try {
1521
+ const trade = await api.getFiatTrade(tradeId);
1522
+ if (gen !== generation) return;
1523
+ if (state.phase === "confirming" || state.phase === "disputing") set({ trade });
1524
+ else showTrade(trade);
1525
+ if (TERMINAL.includes(trade.state)) return;
1526
+ } catch {
1527
+ }
1528
+ if (gen !== generation || destroyed) return;
1529
+ const wait = state.trade?.state === "disputed" ? pollMs * 10 : pollMs;
1530
+ pollTimer = setTimeout(poll, wait);
1531
+ };
1532
+ void poll();
1533
+ }
1534
+ async function quote(request) {
1535
+ const gen = ++generation;
1536
+ stopPolling();
1537
+ set({ ...INITIAL2, request, phase: wallet ? "quoting" : "needsWallet" });
1538
+ if (!wallet) return;
1539
+ try {
1540
+ const res = await api.rankFiatQuotes({
1541
+ direction: "offramp",
1542
+ chain: `eip155:${request.chainId}`,
1543
+ asset: request.asset,
1544
+ fiatCurrency: request.corridor.currency,
1545
+ rail: request.corridor.rail,
1546
+ assetAmount: request.amount.toString()
1547
+ });
1548
+ const best = res.quotes?.[0];
1549
+ if (!best) throw new Error(`No solver is cashing out ${request.asset} on ${request.chainName} to ${request.corridor.currency} for this amount right now.`);
1550
+ const pc = await reader(request.chainId).catch(() => null);
1551
+ const needsApproval = pc ? !await hasPermit2Allowance(pc, request.token, wallet.address, request.amount).catch(() => false) : true;
1552
+ if (gen !== generation) return;
1553
+ const c = request.corridor;
1554
+ set({
1555
+ phase: "quoted",
1556
+ quote: best,
1557
+ needsApproval,
1558
+ preview: {
1559
+ payAmount: fmtAsset(request.amount, request.decimals),
1560
+ paySymbol: request.asset,
1561
+ chainName: request.chainName,
1562
+ receiveAmount: formatFiat(best.fiatNet, c),
1563
+ currency: c.currency,
1564
+ currencySymbol: c.symbol,
1565
+ fee: formatFiat(best.fiatFee, c),
1566
+ rate: fmtRate(best.rate),
1567
+ paymentWindowSecs: best.paymentWindowSecs,
1568
+ quotesConsidered: res.totalConsidered
1569
+ }
1570
+ });
1571
+ } catch (e) {
1572
+ if (gen === generation) set({ phase: "error", error: errMessage2(e) });
1573
+ }
1574
+ }
1575
+ async function confirm(payee) {
1576
+ const { request, quote: chosen } = state;
1577
+ const w = wallet;
1578
+ if (!request || !chosen || !w || state.phase !== "quoted") return;
1579
+ const gen = generation;
1580
+ try {
1581
+ set({ error: null });
1582
+ const details = { ...payee, rail: request.corridor.rail };
1583
+ payeeIdentity(details);
1584
+ if (!request.corridor.accountPattern.test(details.account.replace(/[\s-]/g, ""))) {
1585
+ throw new Error(`That doesn't look like a valid ${request.corridor.accountLabel.toLowerCase()}.`);
1586
+ }
1587
+ if (!details.name.trim()) throw new Error("Enter the account holder's name.");
1588
+ if (await w.getChainId() !== request.chainId) {
1589
+ set({ phase: "switchingChain" });
1590
+ await w.switchChain(request.chainId);
1591
+ }
1592
+ const pc = await reader(request.chainId).catch(() => null);
1593
+ const needsApproval = pc ? !await hasPermit2Allowance(pc, request.token, w.address, request.amount) : state.needsApproval;
1594
+ if (needsApproval) {
1595
+ set({ phase: "approving" });
1596
+ const approve = buildPermit2ApproveRequest(request.token);
1597
+ const hash = await w.writeContract({ chainId: request.chainId, ...approve, abi: approve.abi });
1598
+ if (pc) await pc.waitForTransactionReceipt({ hash });
1599
+ set({ needsApproval: false });
1600
+ }
1601
+ set({ phase: "opening" });
1602
+ const { sealed, commitment, salt } = sealPayee(details, chosen.payeeKey);
1603
+ const opened = await api.openFiatTrade({
1604
+ quoteId: chosen.quoteId,
1605
+ assetAmount: request.amount.toString(),
1606
+ userAddress: (0, import_viem7.getAddress)(w.address),
1607
+ payee: { sealed, commitment }
1608
+ });
1609
+ if (gen !== generation) return;
1610
+ set({ trade: opened.trade, payeeSalt: salt });
1611
+ checkLock(opened.lock, opened.trade, request, chosen, w.address);
1612
+ if (!same(opened.trade.payee_commitment, commitment)) throw new Error("Refusing to sign: the trade is not for the bank details you entered.");
1613
+ set({ phase: "signing" });
1614
+ const { domain, types, primaryType, message } = opened.lock.typedData;
1615
+ const signature = await w.signTypedData({ domain, types, primaryType, message });
1616
+ set({ phase: "locking" });
1617
+ const trade = await api.submitFiatLock(opened.trade.id, signature);
1618
+ if (gen !== generation) return;
1619
+ showTrade(trade);
1620
+ track(trade.id, gen);
1621
+ } catch (e) {
1622
+ if (gen !== generation) return;
1623
+ if (state.trade) {
1624
+ set({ error: errMessage2(e) });
1625
+ track(state.trade.id, gen);
1626
+ } else set({ phase: "error", error: errMessage2(e) });
1627
+ }
1628
+ }
1629
+ async function act(phase, message, send) {
1630
+ const trade = state.trade;
1631
+ const w = wallet;
1632
+ if (!trade || trade.state !== "awaiting_confirmation") return;
1633
+ const gen = generation;
1634
+ const before = state.phase;
1635
+ try {
1636
+ if (!w) throw new Error("Connect the wallet that opened this trade.");
1637
+ if (!same(w.address, trade.user_address)) throw new Error("Switch to the wallet that opened this trade.");
1638
+ if (!w.signMessage) throw new Error("This wallet can't sign messages, so it can't confirm or dispute.");
1639
+ set({ phase, error: null });
1640
+ const signature = await w.signMessage(message(trade.id));
1641
+ const next = await send(trade.id, signature);
1642
+ if (gen !== generation) return;
1643
+ showTrade(next);
1644
+ track(next.id, gen);
1645
+ } catch (e) {
1646
+ if (gen === generation) set({ phase: before, error: errMessage2(e) });
1647
+ }
1648
+ }
1649
+ async function dispute(reason) {
1650
+ const r = reason.trim();
1651
+ if (!r || r.length > MAX_REASON || !PLAIN_TEXT.test(r)) {
1652
+ set({ error: `Describe the problem in 1 to ${MAX_REASON} plain characters (letters, digits, punctuation).` });
1653
+ return;
1654
+ }
1655
+ await act("disputing", (id) => fiatDisputeMessage(id, r), (id, sig) => api.disputeFiatTrade(id, r, sig));
1656
+ }
1657
+ async function resume(tradeId) {
1658
+ const gen = ++generation;
1659
+ stopPolling();
1660
+ set({ ...INITIAL2, phase: "quoting" });
1661
+ try {
1662
+ const trade = await api.getFiatTrade(tradeId);
1663
+ if (gen !== generation) return;
1664
+ showTrade(trade);
1665
+ track(trade.id, gen);
1666
+ } catch (e) {
1667
+ if (gen === generation) set({ phase: "error", error: errMessage2(e) });
1668
+ }
1669
+ }
1670
+ function reset() {
1671
+ generation++;
1672
+ stopPolling();
1673
+ set({ ...INITIAL2 });
1674
+ }
1675
+ function setWallet(next) {
1676
+ const changed = wallet?.address !== next?.address;
1677
+ wallet = next;
1678
+ if (next && state.request && !state.trade && (state.phase === "needsWallet" || changed && state.phase === "quoted")) {
1679
+ void quote(state.request);
1680
+ }
1681
+ }
1682
+ return {
1683
+ getState: () => state,
1684
+ subscribe(listener) {
1685
+ listeners.add(listener);
1686
+ return () => listeners.delete(listener);
1687
+ },
1688
+ quote,
1689
+ confirm,
1690
+ confirmReceived: () => act("confirming", fiatConfirmMessage, (id, sig) => api.confirmFiatTrade(id, sig)),
1691
+ dispute,
1692
+ resume,
1693
+ reset,
1694
+ setWallet,
1695
+ destroy() {
1696
+ generation++;
1697
+ stopPolling();
1698
+ destroyed = true;
1699
+ listeners.clear();
1700
+ }
1701
+ };
1702
+ }
1137
1703
  // Annotate the CommonJS export names for ESM import in node:
1138
1704
  0 && (module.exports = {
1139
1705
  ApiError,
1706
+ BANK_DETAILS_HINT,
1140
1707
  CHAIN_ALIASES,
1141
1708
  DEFAULT_API_URL,
1142
1709
  DEFAULT_INTENT_MODEL,
1143
1710
  EMPTY_SUPPORTED,
1711
+ FIAT_CORRIDORS,
1712
+ FIAT_CURRENCY_ALIASES,
1144
1713
  HF_ROUTER_URL,
1145
1714
  MAX_UINT256,
1715
+ NG_BANKS,
1146
1716
  PERMIT2_ADDRESS,
1147
1717
  SOLANA_CHAIN_IDS,
1148
1718
  TEMPLATE_HINT,
@@ -1151,23 +1721,41 @@ function stripDomainType(types) {
1151
1721
  buildPermit2ApproveRequest,
1152
1722
  buildQuoteRequest,
1153
1723
  buildSupportedSet,
1724
+ canonicalPayeeIdentity,
1154
1725
  chainName,
1726
+ checkLock,
1155
1727
  createApiClient,
1156
1728
  createIntentSession,
1729
+ createOfframpSession,
1157
1730
  explorerUrl,
1158
1731
  extractIntents,
1732
+ fiatClaimMessage,
1733
+ fiatConfirmMessage,
1734
+ fiatDisputeMessage,
1735
+ fiatPaymentSentMessage,
1736
+ fiatRevealMessage,
1737
+ fiatStatementMessage,
1159
1738
  findAsset,
1739
+ formatFiat,
1160
1740
  getPermit2Allowance,
1161
1741
  hasPermit2Allowance,
1162
1742
  interopAddress,
1163
1743
  isTerminalStatus,
1744
+ looksLikeBankDetails,
1164
1745
  normalizeChain,
1746
+ normalizeFiatCurrency,
1165
1747
  normalizeToken,
1748
+ openPayee,
1166
1749
  parseIntent,
1750
+ payeeCommitment,
1751
+ payeeIdentity,
1167
1752
  resolveIntent,
1753
+ resolveOfframp,
1168
1754
  rpcReaders,
1755
+ sealPayee,
1169
1756
  signQuote,
1170
1757
  stripDomainType,
1171
1758
  validateIntent,
1759
+ validateOfframp,
1172
1760
  viemWallet
1173
1761
  });