@recoilpay/intent-core 0.2.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));
@@ -437,7 +481,8 @@ var LABEL = {
437
481
  chainIn: "Source chain",
438
482
  tokenOut: "Destination token",
439
483
  chainOut: "Destination chain",
440
- recipient: "Recipient"
484
+ recipient: "Recipient",
485
+ fiatCurrency: "Currency"
441
486
  };
442
487
  var EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
443
488
  var SOLANA_ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
@@ -706,6 +751,23 @@ var PERMIT2_TYPES = {
706
751
  { name: "amount", type: "uint256" }
707
752
  ]
708
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
+ };
709
771
  var EIP3009_TYPES = {
710
772
  TransferWithAuthorization: [
711
773
  { name: "from", type: "address" },
@@ -742,8 +804,11 @@ function extractDomain(payload) {
742
804
  function getTypesForPayload(payload) {
743
805
  switch (payload.primaryType) {
744
806
  case "PermitBatchWitnessTransferFrom":
745
- case "PermitWitnessTransferFrom":
746
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;
747
812
  case "TransferWithAuthorization":
748
813
  case "ReceiveWithAuthorization":
749
814
  case "CancelAuthorization":
@@ -811,6 +876,105 @@ async function signQuote(quote, signTypedDataAsync) {
811
876
  }
812
877
  }
813
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
+
814
978
  // src/session.ts
815
979
  var ERC20_TRANSFER_ABI = [
816
980
  {
@@ -827,7 +991,7 @@ var ERC20_TRANSFER_ABI = [
827
991
  var errMessage = (e) => e instanceof Error ? e.message : typeof e === "string" ? e : "Something went wrong.";
828
992
  function fmtUnits(amount, decimals) {
829
993
  if (amount === void 0) return "";
830
- 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);
831
995
  const n = Number(human);
832
996
  return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 6 }) : human;
833
997
  }
@@ -862,7 +1026,8 @@ var INITIAL = {
862
1026
  error: null,
863
1027
  isConnected: false,
864
1028
  queueIndex: 0,
865
- queueTotal: 0
1029
+ queueTotal: 0,
1030
+ offramp: null
866
1031
  };
867
1032
  function createIntentSession(options = {}) {
868
1033
  const api = options.api ?? createApiClient(options);
@@ -907,6 +1072,7 @@ function createIntentSession(options = {}) {
907
1072
  }
908
1073
  }
909
1074
  async function quoteFor(raw, gen) {
1075
+ if (raw.action === "offramp") return;
910
1076
  if (!wallet) return set({ phase: "needsWallet" });
911
1077
  try {
912
1078
  set({ phase: "quoting", error: null });
@@ -1057,6 +1223,12 @@ function createIntentSession(options = {}) {
1057
1223
  const parsed = await parseIntent(text, { hfAccessToken, model: options.hfModel, fetch: api.fetch });
1058
1224
  if (gen !== generation) return;
1059
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
+ }
1060
1232
  for (const intent of parsed.intents) {
1061
1233
  const issues = validateIntent(intent, supported);
1062
1234
  if (issues.length) return set({ phase: "invalid", issues });
@@ -1126,7 +1298,8 @@ function viemWallet(client) {
1126
1298
  functionName: req.functionName,
1127
1299
  args: req.args
1128
1300
  }),
1129
- 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 })
1130
1303
  };
1131
1304
  }
1132
1305
  function stripDomainType(types) {
@@ -1134,15 +1307,400 @@ function stripDomainType(types) {
1134
1307
  for (const [name, fields] of Object.entries(types)) if (name !== "EIP712Domain") out[name] = fields;
1135
1308
  return out;
1136
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
+ }
1137
1691
  // Annotate the CommonJS export names for ESM import in node:
1138
1692
  0 && (module.exports = {
1139
1693
  ApiError,
1694
+ BANK_DETAILS_HINT,
1140
1695
  CHAIN_ALIASES,
1141
1696
  DEFAULT_API_URL,
1142
1697
  DEFAULT_INTENT_MODEL,
1143
1698
  EMPTY_SUPPORTED,
1699
+ FIAT_CORRIDORS,
1700
+ FIAT_CURRENCY_ALIASES,
1144
1701
  HF_ROUTER_URL,
1145
1702
  MAX_UINT256,
1703
+ NG_BANKS,
1146
1704
  PERMIT2_ADDRESS,
1147
1705
  SOLANA_CHAIN_IDS,
1148
1706
  TEMPLATE_HINT,
@@ -1151,23 +1709,38 @@ function stripDomainType(types) {
1151
1709
  buildPermit2ApproveRequest,
1152
1710
  buildQuoteRequest,
1153
1711
  buildSupportedSet,
1712
+ canonicalPayeeIdentity,
1154
1713
  chainName,
1714
+ checkLock,
1155
1715
  createApiClient,
1156
1716
  createIntentSession,
1717
+ createOfframpSession,
1157
1718
  explorerUrl,
1158
1719
  extractIntents,
1720
+ fiatConfirmMessage,
1721
+ fiatDisputeMessage,
1722
+ fiatPaymentSentMessage,
1159
1723
  findAsset,
1724
+ formatFiat,
1160
1725
  getPermit2Allowance,
1161
1726
  hasPermit2Allowance,
1162
1727
  interopAddress,
1163
1728
  isTerminalStatus,
1729
+ looksLikeBankDetails,
1164
1730
  normalizeChain,
1731
+ normalizeFiatCurrency,
1165
1732
  normalizeToken,
1733
+ openPayee,
1166
1734
  parseIntent,
1735
+ payeeCommitment,
1736
+ payeeIdentity,
1167
1737
  resolveIntent,
1738
+ resolveOfframp,
1168
1739
  rpcReaders,
1740
+ sealPayee,
1169
1741
  signQuote,
1170
1742
  stripDomainType,
1171
1743
  validateIntent,
1744
+ validateOfframp,
1172
1745
  viemWallet
1173
1746
  });