@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.js CHANGED
@@ -58,12 +58,29 @@ function createApiClient(options = {}) {
58
58
  },
59
59
  getQuotes: (req) => post("/quotes", req),
60
60
  submitOrder: (req) => post("/orders", req),
61
- getOrder: (id) => request(`/orders/${encodeURIComponent(id)}`)
61
+ getOrder: (id) => request(`/orders/${encodeURIComponent(id)}`),
62
+ // ── Fiat ramps (design §12.1) ──
63
+ rankFiatQuotes: (req) => post("/fiat/quotes", req),
64
+ openFiatTrade: (req) => post("/fiat/trades", req),
65
+ getFiatTrade: (id) => request(`/fiat/trades/${encodeURIComponent(id)}`),
66
+ /** Hand the signed lock to the solver's relay, which submits it and pays the gas. */
67
+ submitFiatLock: (id, signature) => post(`/fiat/trades/${encodeURIComponent(id)}/lock`, { signature }),
68
+ /** Report a lock the user submitted themselves; the aggregator reads it from the chain. */
69
+ reportFiatFunded: (id, txHash) => post(`/fiat/trades/${encodeURIComponent(id)}/funded`, txHash ? { txHash } : {}),
70
+ /** `signature` is over `fiatConfirmMessage(id)`. */
71
+ confirmFiatTrade: (id, signature) => post(`/fiat/trades/${encodeURIComponent(id)}/confirm`, { signature }),
72
+ /** `signature` is over `fiatDisputeMessage(id, reason)`. */
73
+ disputeFiatTrade: (id, reason, signature) => post(`/fiat/trades/${encodeURIComponent(id)}/dispute`, { reason, signature })
62
74
  };
63
75
  }
64
76
 
65
77
  // src/parse.ts
66
- 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>";
78
+ 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)";
79
+ 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";
80
+ function looksLikeBankDetails(text) {
81
+ const rest = text.replace(/0x[0-9a-fA-F]+/g, " ").replace(/[.,]\d+/g, "");
82
+ return /(\d[\s-]?){10,}/.test(rest);
83
+ }
67
84
  var HF_ROUTER_URL = "https://router.huggingface.co/v1/chat/completions";
68
85
  var DEFAULT_INTENT_MODEL = "Qwen/Qwen2.5-Coder-32B-Instruct";
69
86
  var SYSTEM_PROMPT = `You are an intent extraction engine for a cross-chain swap protocol.
@@ -72,7 +89,7 @@ If the user specifies multiple intents (e.g. "swap X and then send Y"), return m
72
89
  The JSON objects must perfectly match the following TypeScript interface:
73
90
 
74
91
  type AmountKind = "token" | "usd";
75
- type IntentAction = "swap" | "send";
92
+ type IntentAction = "swap" | "send" | "offramp";
76
93
 
77
94
  interface RawIntent {
78
95
  action: IntentAction;
@@ -83,14 +100,19 @@ interface RawIntent {
83
100
  tokenOut: string | null; // target token (optional for send)
84
101
  chainOut: string | null; // target chain (optional for send)
85
102
  recipient: string | null; // address/ens (required for 'send', optional for 'swap')
103
+ fiatCurrency?: string | null; // 'offramp' only: the local currency to receive, e.g. "NGN" or "naira"
86
104
  }
87
105
 
106
+ Use "offramp" when the user wants to cash out, sell, or withdraw crypto to a bank account or local currency.
107
+ For "offramp", tokenIn/chainIn/amount describe the crypto they give; tokenOut, chainOut and recipient are null.
108
+ Never put bank names, account numbers or personal names in any field.
109
+
88
110
  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.`;
89
111
  var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
90
112
  var readString = (v) => typeof v === "string" && v.trim().length > 0 ? v : null;
91
113
  var readAmount = (v) => v === null || v === void 0 ? null : String(v);
92
114
  var readAmountKind = (v) => v === "usd" ? "usd" : "token";
93
- var readAction = (v) => v === "swap" || v === "send" ? v : null;
115
+ var readAction = (v) => v === "swap" || v === "send" || v === "offramp" ? v : null;
94
116
  var offTemplate = (message = TEMPLATE_HINT) => ({ ok: false, offTemplate: true, message });
95
117
  function extractIntents(content) {
96
118
  let json = content.trim();
@@ -115,7 +137,7 @@ function extractIntents(content) {
115
137
  for (const item of items) {
116
138
  const action = readAction(item.action);
117
139
  if (!action) return offTemplate();
118
- intents.push({
140
+ const intent = {
119
141
  action,
120
142
  amount: readAmount(item.amount),
121
143
  amountKind: readAmountKind(item.amountKind),
@@ -124,13 +146,16 @@ function extractIntents(content) {
124
146
  tokenOut: readString(item.tokenOut),
125
147
  chainOut: readString(item.chainOut),
126
148
  recipient: readString(item.recipient)
127
- });
149
+ };
150
+ if (action === "offramp") intent.fiatCurrency = readString(item.fiatCurrency);
151
+ intents.push(intent);
128
152
  }
129
153
  return { ok: true, intents };
130
154
  }
131
155
  async function parseIntent(text, options = {}) {
132
156
  const norm = text.trim();
133
157
  if (!norm) return offTemplate();
158
+ if (looksLikeBankDetails(norm)) return offTemplate(BANK_DETAILS_HINT);
134
159
  const token = options.hfAccessToken?.trim();
135
160
  if (!token) return offTemplate(`Natural-language parsing isn't set up here. ${TEMPLATE_HINT}`);
136
161
  const doFetch = options.fetch ?? ((...args) => fetch(...args));
@@ -170,12 +195,15 @@ var CHAIN_NAMES = {
170
195
  8453: "Base",
171
196
  42161: "Arbitrum",
172
197
  56: "BNB Chain",
198
+ 204: "opBNB",
173
199
  43114: "Avalanche",
174
200
  11155111: "Ethereum Sepolia",
175
201
  84532: "Base Sepolia",
176
202
  11155420: "OP Sepolia",
177
203
  421614: "Arbitrum Sepolia",
178
204
  80002: "Polygon Amoy",
205
+ 97: "BNB Chain Testnet",
206
+ 5611: "opBNB Testnet",
179
207
  9000000001: "Solana",
180
208
  9000000002: "Solana Devnet"
181
209
  };
@@ -186,12 +214,15 @@ var EXPLORERS = {
186
214
  8453: "https://basescan.org",
187
215
  42161: "https://arbiscan.io",
188
216
  56: "https://bscscan.com",
217
+ 204: "https://opbnb.bscscan.com",
189
218
  43114: "https://snowtrace.io",
190
219
  11155111: "https://sepolia.etherscan.io",
191
220
  84532: "https://sepolia.basescan.org",
192
221
  11155420: "https://sepolia-optimism.etherscan.io",
193
222
  421614: "https://sepolia.arbiscan.io",
194
- 80002: "https://amoy.polygonscan.com"
223
+ 80002: "https://amoy.polygonscan.com",
224
+ 97: "https://testnet.bscscan.com",
225
+ 5611: "https://testnet.opbnbscan.com"
195
226
  };
196
227
  var SOLANA_CHAIN_IDS = /* @__PURE__ */ new Set([9000000001, 9000000002]);
197
228
  function chainName(chainId, info) {
@@ -251,9 +282,14 @@ var CHAIN_ALIASES = {
251
282
  op: { id: 10, name: "Optimism" },
252
283
  polygon: { id: 137, name: "Polygon" },
253
284
  matic: { id: 137, name: "Polygon" },
285
+ bsc: { id: 56, name: "BNB Chain" },
286
+ bnb: { id: 56, name: "BNB Chain" },
287
+ "bnb chain": { id: 56, name: "BNB Chain" },
288
+ "binance smart chain": { id: 56, name: "BNB Chain" },
289
+ opbnb: { id: 204, name: "opBNB" },
254
290
  solana: { id: 9000000002, name: "Solana Devnet" },
255
291
  "sol network": { id: 9000000002, name: "Solana Devnet" },
256
- // testnets — the solver settles across these five
292
+ // testnets — the solver settles across these
257
293
  "base sepolia": { id: 84532, name: "Base Sepolia" },
258
294
  "op sepolia": { id: 11155420, name: "OP Sepolia" },
259
295
  "optimism sepolia": { id: 11155420, name: "OP Sepolia" },
@@ -263,7 +299,9 @@ var CHAIN_ALIASES = {
263
299
  "eth sepolia": { id: 11155111, name: "Ethereum Sepolia" },
264
300
  sepolia: { id: 11155111, name: "Ethereum Sepolia" },
265
301
  "polygon amoy": { id: 80002, name: "Polygon Amoy" },
266
- amoy: { id: 80002, name: "Polygon Amoy" }
302
+ amoy: { id: 80002, name: "Polygon Amoy" },
303
+ "bsc testnet": { id: 97, name: "BNB Chain Testnet" },
304
+ "bnb testnet": { id: 97, name: "BNB Chain Testnet" }
267
305
  };
268
306
  function normalizeToken(raw) {
269
307
  if (!raw) return null;
@@ -365,7 +403,8 @@ var LABEL = {
365
403
  chainIn: "Source chain",
366
404
  tokenOut: "Destination token",
367
405
  chainOut: "Destination chain",
368
- recipient: "Recipient"
406
+ recipient: "Recipient",
407
+ fiatCurrency: "Currency"
369
408
  };
370
409
  var EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
371
410
  var SOLANA_ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
@@ -634,6 +673,23 @@ var PERMIT2_TYPES = {
634
673
  { name: "amount", type: "uint256" }
635
674
  ]
636
675
  };
676
+ var PERMIT2_ESCROW_LOCK_TYPES = {
677
+ PermitWitnessTransferFrom: [
678
+ { name: "permitted", type: "TokenPermissions" },
679
+ { name: "spender", type: "address" },
680
+ { name: "nonce", type: "uint256" },
681
+ { name: "deadline", type: "uint256" },
682
+ { name: "witness", type: "EscrowLock" }
683
+ ],
684
+ EscrowLock: [
685
+ { name: "tradeId", type: "bytes32" },
686
+ { name: "counterparty", type: "address" }
687
+ ],
688
+ TokenPermissions: [
689
+ { name: "token", type: "address" },
690
+ { name: "amount", type: "uint256" }
691
+ ]
692
+ };
637
693
  var EIP3009_TYPES = {
638
694
  TransferWithAuthorization: [
639
695
  { name: "from", type: "address" },
@@ -670,8 +726,11 @@ function extractDomain(payload) {
670
726
  function getTypesForPayload(payload) {
671
727
  switch (payload.primaryType) {
672
728
  case "PermitBatchWitnessTransferFrom":
673
- case "PermitWitnessTransferFrom":
674
729
  return PERMIT2_TYPES;
730
+ // The batch types do not define this primary type at all, so mapping it
731
+ // to them made every single-token signature fail.
732
+ case "PermitWitnessTransferFrom":
733
+ return PERMIT2_ESCROW_LOCK_TYPES;
675
734
  case "TransferWithAuthorization":
676
735
  case "ReceiveWithAuthorization":
677
736
  case "CancelAuthorization":
@@ -739,6 +798,105 @@ async function signQuote(quote, signTypedDataAsync) {
739
798
  }
740
799
  }
741
800
 
801
+ // src/fiat/intent.ts
802
+ import { getAddress as getAddress2, parseUnits as parseUnits2 } from "viem";
803
+
804
+ // src/fiat/corridors.ts
805
+ var NG_BANKS = [
806
+ { code: "044", name: "Access Bank" },
807
+ { code: "023", name: "Citibank Nigeria" },
808
+ { code: "050", name: "Ecobank Nigeria" },
809
+ { code: "070", name: "Fidelity Bank" },
810
+ { code: "011", name: "First Bank of Nigeria" },
811
+ { code: "214", name: "First City Monument Bank" },
812
+ { code: "058", name: "Guaranty Trust Bank" },
813
+ { code: "082", name: "Keystone Bank" },
814
+ { code: "50211", name: "Kuda Bank" },
815
+ { code: "50515", name: "Moniepoint MFB" },
816
+ { code: "999992", name: "OPay" },
817
+ { code: "999991", name: "PalmPay" },
818
+ { code: "076", name: "Polaris Bank" },
819
+ { code: "101", name: "Providus Bank" },
820
+ { code: "221", name: "Stanbic IBTC Bank" },
821
+ { code: "068", name: "Standard Chartered Bank" },
822
+ { code: "232", name: "Sterling Bank" },
823
+ { code: "032", name: "Union Bank of Nigeria" },
824
+ { code: "033", name: "United Bank For Africa" },
825
+ { code: "215", name: "Unity Bank" },
826
+ { code: "035", name: "Wema Bank" },
827
+ { code: "057", name: "Zenith Bank" }
828
+ ];
829
+ var FIAT_CORRIDORS = {
830
+ NGN: {
831
+ currency: "NGN",
832
+ rail: "ng.nip",
833
+ decimals: 2,
834
+ symbol: "\u20A6",
835
+ accountLabel: "Account number (NUBAN)",
836
+ accountPattern: /^\d{10}$/,
837
+ banks: NG_BANKS
838
+ }
839
+ };
840
+ var FIAT_CURRENCY_ALIASES = {
841
+ ngn: "NGN",
842
+ naira: "NGN",
843
+ "\u20A6": "NGN",
844
+ nigeria: "NGN",
845
+ nigerian: "NGN"
846
+ };
847
+ function normalizeFiatCurrency(raw) {
848
+ if (!raw) return null;
849
+ const k = raw.trim().toLowerCase();
850
+ return FIAT_CURRENCY_ALIASES[k] ?? (FIAT_CORRIDORS[k.toUpperCase()] ? k.toUpperCase() : null);
851
+ }
852
+ function formatFiat(minor, corridor) {
853
+ const n = typeof minor === "bigint" ? minor : BigInt(minor);
854
+ const scale = 10n ** BigInt(corridor.decimals);
855
+ const whole = (n / scale).toLocaleString("en-US");
856
+ if (corridor.decimals === 0) return whole;
857
+ return `${whole}.${(n % scale).toString().padStart(corridor.decimals, "0")}`;
858
+ }
859
+
860
+ // src/fiat/intent.ts
861
+ function validateOfframp(raw, supported) {
862
+ const issues = [];
863
+ if (!raw.amount) issues.push({ field: "amount", kind: "missing", message: "Amount is not stated." });
864
+ else if (!(Number(raw.amount) > 0)) issues.push({ field: "amount", kind: "unknown", message: "Amount must be a positive number." });
865
+ const chain = normalizeChain(raw.chainIn);
866
+ if (!raw.chainIn) issues.push({ field: "chainIn", kind: "missing", message: "Source chain is not stated." });
867
+ else if (!chain) issues.push({ field: "chainIn", kind: "unknown", message: `Chain '${raw.chainIn}' isn't recognized.` });
868
+ const sym = normalizeToken(raw.tokenIn);
869
+ if (!raw.tokenIn) issues.push({ field: "tokenIn", kind: "missing", message: "Source token is not stated." });
870
+ else if (!sym) issues.push({ field: "tokenIn", kind: "unknown", message: `Token '${raw.tokenIn}' isn't recognized.` });
871
+ else if (chain && !findAsset(supported, chain.id, sym)) {
872
+ issues.push({ field: "tokenIn", kind: "unsupported", message: `${sym} isn't supported on ${chain.name} yet.` });
873
+ }
874
+ const currency = normalizeFiatCurrency(raw.fiatCurrency);
875
+ if (!raw.fiatCurrency) {
876
+ issues.push({ field: "fiatCurrency", kind: "missing", message: "Say which currency to receive, e.g. NGN." });
877
+ } else if (!currency) {
878
+ const known = Object.keys(FIAT_CORRIDORS).join(", ");
879
+ issues.push({ field: "fiatCurrency", kind: "unsupported", message: `Cash out to ${raw.fiatCurrency} isn't available yet (only ${known}).` });
880
+ }
881
+ return issues;
882
+ }
883
+ function resolveOfframp(raw, supported) {
884
+ const chain = normalizeChain(raw.chainIn);
885
+ const sym = normalizeToken(raw.tokenIn);
886
+ const currency = normalizeFiatCurrency(raw.fiatCurrency);
887
+ const asset = chain && sym ? findAsset(supported, chain.id, sym) : void 0;
888
+ if (!chain || !asset || !currency || !raw.amount) throw new Error("resolveOfframp called on an invalid intent");
889
+ return {
890
+ chainId: chain.id,
891
+ chainName: chain.name,
892
+ asset: asset.symbol.toUpperCase(),
893
+ token: getAddress2(asset.address),
894
+ decimals: asset.decimals,
895
+ amount: parseUnits2(raw.amount, asset.decimals),
896
+ corridor: FIAT_CORRIDORS[currency]
897
+ };
898
+ }
899
+
742
900
  // src/session.ts
743
901
  var ERC20_TRANSFER_ABI = [
744
902
  {
@@ -790,7 +948,8 @@ var INITIAL = {
790
948
  error: null,
791
949
  isConnected: false,
792
950
  queueIndex: 0,
793
- queueTotal: 0
951
+ queueTotal: 0,
952
+ offramp: null
794
953
  };
795
954
  function createIntentSession(options = {}) {
796
955
  const api = options.api ?? createApiClient(options);
@@ -835,6 +994,7 @@ function createIntentSession(options = {}) {
835
994
  }
836
995
  }
837
996
  async function quoteFor(raw, gen) {
997
+ if (raw.action === "offramp") return;
838
998
  if (!wallet) return set({ phase: "needsWallet" });
839
999
  try {
840
1000
  set({ phase: "quoting", error: null });
@@ -985,6 +1145,12 @@ function createIntentSession(options = {}) {
985
1145
  const parsed = await parseIntent(text, { hfAccessToken, model: options.hfModel, fetch: api.fetch });
986
1146
  if (gen !== generation) return;
987
1147
  if (!parsed.ok) return set({ phase: "offTemplate", hint: parsed.message });
1148
+ const offramp = parsed.intents.find((i) => i.action === "offramp");
1149
+ if (offramp) {
1150
+ 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);
1151
+ if (issues.length) return set({ phase: "invalid", issues });
1152
+ return set({ phase: "offramp", offramp: resolveOfframp(offramp, supported) });
1153
+ }
988
1154
  for (const intent of parsed.intents) {
989
1155
  const issues = validateIntent(intent, supported);
990
1156
  if (issues.length) return set({ phase: "invalid", issues });
@@ -1054,7 +1220,8 @@ function viemWallet(client) {
1054
1220
  functionName: req.functionName,
1055
1221
  args: req.args
1056
1222
  }),
1057
- sendTransaction: (req) => client.sendTransaction({ account, chain: null, to: req.to, value: req.value })
1223
+ sendTransaction: (req) => client.sendTransaction({ account, chain: null, to: req.to, value: req.value }),
1224
+ signMessage: (message) => client.signMessage({ account, message })
1058
1225
  };
1059
1226
  }
1060
1227
  function stripDomainType(types) {
@@ -1062,14 +1229,399 @@ function stripDomainType(types) {
1062
1229
  for (const [name, fields] of Object.entries(types)) if (name !== "EIP712Domain") out[name] = fields;
1063
1230
  return out;
1064
1231
  }
1232
+
1233
+ // src/fiat/payee.ts
1234
+ import { gcm } from "@noble/ciphers/aes";
1235
+ import { secp256k1 } from "@noble/curves/secp256k1";
1236
+ import { hkdf } from "@noble/hashes/hkdf";
1237
+ import { sha256 } from "@noble/hashes/sha256";
1238
+ import { bytesToHex as bytesToHex2, concat, hexToBytes as hexToBytes2, keccak256, stringToBytes } from "viem";
1239
+ var ALG = "ECIES-secp256k1-AES256GCM";
1240
+ var ENVELOPE_VERSION = 1;
1241
+ var PAYLOAD_VERSION = 1;
1242
+ var GCM_TAG_BYTES = 16;
1243
+ var SALT_BYTES = 32;
1244
+ var MAX_NAME_LENGTH = 140;
1245
+ var HKDF_INFO = stringToBytes("recoilpay/fiat-payee/v1");
1246
+ var RAIL = /^[A-Za-z]{2}\.[A-Za-z0-9_]{1,30}$/;
1247
+ var BANK_CODE = /^[A-Za-z0-9]{0,16}$/;
1248
+ var ACCOUNT = /^\+?[A-Za-z0-9]{1,34}$/;
1249
+ var ASCII_SPACE = /[ \t\r\n]/g;
1250
+ function payeeIdentity(payee) {
1251
+ const rail = payee.rail.replace(ASCII_SPACE, "");
1252
+ const bankCode = payee.bankCode.replace(ASCII_SPACE, "");
1253
+ const account = payee.account.replace(ASCII_SPACE, "").replace(/-/g, "");
1254
+ if (!RAIL.test(rail)) throw new Error(`invalid rail: ${JSON.stringify(payee.rail)}`);
1255
+ if (!BANK_CODE.test(bankCode)) throw new Error("invalid bank code");
1256
+ if (!ACCOUNT.test(account)) throw new Error("invalid account number");
1257
+ return {
1258
+ rail: rail.toLowerCase(),
1259
+ bank_code: bankCode.toUpperCase(),
1260
+ account: account.toUpperCase()
1261
+ };
1262
+ }
1263
+ function canonicalPayeeIdentity(identity) {
1264
+ const { account, bank_code, rail } = payeeIdentity({
1265
+ rail: identity.rail,
1266
+ bankCode: identity.bank_code,
1267
+ account: identity.account
1268
+ });
1269
+ return JSON.stringify({ account, bank_code, rail });
1270
+ }
1271
+ function payeeCommitment(identity, salt) {
1272
+ const saltBytes = hexToBytes2(salt);
1273
+ if (saltBytes.length !== SALT_BYTES) throw new Error(`salt must be ${SALT_BYTES} bytes`);
1274
+ return keccak256(concat([stringToBytes(canonicalPayeeIdentity(identity)), saltBytes]));
1275
+ }
1276
+ function normaliseName(name) {
1277
+ const n = name.normalize("NFC").trim().replace(/\s+/g, " ");
1278
+ if (n.length === 0) throw new Error("the account holder name is required");
1279
+ if (n.length > MAX_NAME_LENGTH) throw new Error("the account holder name is too long");
1280
+ return n;
1281
+ }
1282
+ function deriveKey(sharedX, ephemeralPub) {
1283
+ return hkdf(sha256, sharedX, ephemeralPub, HKDF_INFO, 32);
1284
+ }
1285
+ function sealPayee(payee, solverPubkey, salt) {
1286
+ const identity = payeeIdentity(payee);
1287
+ const name = normaliseName(payee.name);
1288
+ const saltHex = salt ?? bytesToHex2(crypto.getRandomValues(new Uint8Array(SALT_BYTES)));
1289
+ const commitment = payeeCommitment(identity, saltHex);
1290
+ const recipient = hexToBytes2(solverPubkey);
1291
+ if (recipient.length !== 65 || recipient[0] !== 4) {
1292
+ throw new Error("solver public key must be 65 bytes uncompressed (0x04\u2026)");
1293
+ }
1294
+ const plaintext = stringToBytes(
1295
+ JSON.stringify({ v: PAYLOAD_VERSION, ...identity, name, salt: saltHex })
1296
+ );
1297
+ const ephemeralPriv = secp256k1.utils.randomPrivateKey();
1298
+ const ephemeralPub = secp256k1.getPublicKey(ephemeralPriv, false);
1299
+ const shared = secp256k1.getSharedSecret(ephemeralPriv, recipient, true).slice(1);
1300
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1301
+ const out = gcm(deriveKey(shared, ephemeralPub), iv).encrypt(plaintext);
1302
+ return {
1303
+ sealed: {
1304
+ v: ENVELOPE_VERSION,
1305
+ alg: ALG,
1306
+ epk: bytesToHex2(ephemeralPub),
1307
+ iv: bytesToHex2(iv),
1308
+ // @noble appends the tag; split so any standard GCM can read it.
1309
+ ct: bytesToHex2(out.slice(0, out.length - GCM_TAG_BYTES)),
1310
+ tag: bytesToHex2(out.slice(out.length - GCM_TAG_BYTES))
1311
+ },
1312
+ commitment,
1313
+ salt: saltHex
1314
+ };
1315
+ }
1316
+ function openPayee(sealed, solverPrivkey, expectedCommitment) {
1317
+ if (sealed.alg !== ALG) throw new Error(`unsupported envelope algorithm: ${sealed.alg}`);
1318
+ if (sealed.v !== ENVELOPE_VERSION) throw new Error(`unsupported envelope version: ${sealed.v}`);
1319
+ const epk = hexToBytes2(sealed.epk);
1320
+ const shared = secp256k1.getSharedSecret(hexToBytes2(solverPrivkey), epk, true).slice(1);
1321
+ const plaintext = gcm(deriveKey(shared, epk), hexToBytes2(sealed.iv)).decrypt(
1322
+ concat([hexToBytes2(sealed.ct), hexToBytes2(sealed.tag)])
1323
+ );
1324
+ const body = JSON.parse(new TextDecoder().decode(plaintext));
1325
+ if (body.v !== PAYLOAD_VERSION) throw new Error(`unsupported payee payload version: ${String(body.v)}`);
1326
+ const identity = payeeIdentity({
1327
+ rail: String(body.rail ?? ""),
1328
+ bankCode: String(body.bank_code ?? ""),
1329
+ account: String(body.account ?? "")
1330
+ });
1331
+ const salt = String(body.salt ?? "");
1332
+ if (payeeCommitment(identity, salt).toLowerCase() !== expectedCommitment.toLowerCase()) {
1333
+ throw new Error("sealed payee details do not match the trade commitment");
1334
+ }
1335
+ return { identity, name: normaliseName(String(body.name ?? "")), salt };
1336
+ }
1337
+
1338
+ // src/fiat/offramp.ts
1339
+ import { formatUnits as formatUnits2, getAddress as getAddress3 } from "viem";
1340
+
1341
+ // src/fiat/actions.ts
1342
+ function fiatConfirmMessage(tradeId) {
1343
+ return `RecoilPay: I confirm I received the fiat payment for trade ${tradeId}.`;
1344
+ }
1345
+ function fiatDisputeMessage(tradeId, reason) {
1346
+ return `RecoilPay: I dispute trade ${tradeId}. Reason: ${reason.trim()}`;
1347
+ }
1348
+ function fiatPaymentSentMessage(tradeId, reference) {
1349
+ return `RecoilPay: I have sent the fiat payment for trade ${tradeId}. Reference: ${reference.trim()}`;
1350
+ }
1351
+
1352
+ // src/fiat/offramp.ts
1353
+ var TERMINAL = ["settled_to_fiat_payer", "refunded_to_funder", "failed"];
1354
+ var PHASE_OF = {
1355
+ quoted: "locking",
1356
+ awaiting_fiat: "awaitingFiat",
1357
+ awaiting_confirmation: "awaitingConfirmation",
1358
+ disputed: "disputed",
1359
+ settled_to_fiat_payer: "settled",
1360
+ refunded_to_funder: "refunded",
1361
+ failed: "failed"
1362
+ };
1363
+ var PLAIN_TEXT = /^[\x20-\x7e]+$/;
1364
+ var MAX_REASON = 500;
1365
+ var errMessage2 = (e) => e instanceof Error ? e.message : typeof e === "string" ? e : "Something went wrong.";
1366
+ var same = (a, b) => typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase();
1367
+ function fmtAsset(amount, decimals) {
1368
+ const n = Number(formatUnits2(amount, decimals));
1369
+ return n.toLocaleString("en-US", { maximumFractionDigits: 6 });
1370
+ }
1371
+ function checkLock(lock, trade, request, quote, user) {
1372
+ const m = lock.typedData.message;
1373
+ const d = lock.typedData.domain;
1374
+ const problems = [
1375
+ lock.typedData.primaryType !== "PermitWitnessTransferFrom" && "not a Permit2 witness transfer",
1376
+ !same(d.verifyingContract, PERMIT2_ADDRESS) && "not signed for Permit2",
1377
+ Number(d.chainId) !== request.chainId && "the wrong chain",
1378
+ !same(m.permitted?.token, request.token) && "the wrong token",
1379
+ m.permitted?.amount !== request.amount.toString() && "the wrong amount",
1380
+ !same(m.spender, lock.escrow) && "a spender other than the escrow",
1381
+ !same(m.witness?.tradeId, lock.tradeId) && "another trade",
1382
+ !same(m.witness?.counterparty, trade.solver_address) && "a counterparty other than the solver",
1383
+ trade.quote_id !== quote.quoteId && "another quote",
1384
+ trade.fiat_minor_units !== quote.fiatNet && "a payout other than the one quoted",
1385
+ !same(trade.user_address, user) && "another wallet"
1386
+ ].filter(Boolean);
1387
+ if (problems.length) throw new Error(`Refusing to sign: the lock names ${problems.join(", ")}.`);
1388
+ }
1389
+ function fmtRate(rate) {
1390
+ const n = Number(rate);
1391
+ return Number.isFinite(n) ? n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 }) : rate;
1392
+ }
1393
+ var INITIAL2 = {
1394
+ phase: "idle",
1395
+ request: null,
1396
+ quote: null,
1397
+ preview: null,
1398
+ needsApproval: false,
1399
+ trade: null,
1400
+ payeeSalt: null,
1401
+ error: null
1402
+ };
1403
+ function createOfframpSession(options = {}) {
1404
+ const api = options.api ?? createApiClient(options);
1405
+ const pollMs = options.pollIntervalMs ?? 3e3;
1406
+ let chainsPromise = null;
1407
+ const chains = () => chainsPromise ??= api.getChains().catch((e) => (chainsPromise = null, Promise.reject(e)));
1408
+ const reader = options.chainReader ?? rpcReaders(chains);
1409
+ let wallet = options.wallet ?? null;
1410
+ let pollTimer;
1411
+ let destroyed = false;
1412
+ let generation = 0;
1413
+ let state = { ...INITIAL2 };
1414
+ const listeners = /* @__PURE__ */ new Set();
1415
+ function set(patch) {
1416
+ if (destroyed) return;
1417
+ state = { ...state, ...patch };
1418
+ for (const l of listeners) l(state);
1419
+ }
1420
+ const stopPolling = () => {
1421
+ if (pollTimer) clearTimeout(pollTimer);
1422
+ pollTimer = void 0;
1423
+ };
1424
+ function showTrade(trade, patch = {}) {
1425
+ set({ trade, phase: PHASE_OF[trade.state], ...patch });
1426
+ }
1427
+ function track(tradeId, gen) {
1428
+ stopPolling();
1429
+ const poll = async () => {
1430
+ try {
1431
+ const trade = await api.getFiatTrade(tradeId);
1432
+ if (gen !== generation) return;
1433
+ if (state.phase === "confirming" || state.phase === "disputing") set({ trade });
1434
+ else showTrade(trade);
1435
+ if (TERMINAL.includes(trade.state)) return;
1436
+ } catch {
1437
+ }
1438
+ if (gen !== generation || destroyed) return;
1439
+ const wait = state.trade?.state === "disputed" ? pollMs * 10 : pollMs;
1440
+ pollTimer = setTimeout(poll, wait);
1441
+ };
1442
+ void poll();
1443
+ }
1444
+ async function quote(request) {
1445
+ const gen = ++generation;
1446
+ stopPolling();
1447
+ set({ ...INITIAL2, request, phase: wallet ? "quoting" : "needsWallet" });
1448
+ if (!wallet) return;
1449
+ try {
1450
+ const res = await api.rankFiatQuotes({
1451
+ direction: "offramp",
1452
+ chain: `eip155:${request.chainId}`,
1453
+ asset: request.asset,
1454
+ fiatCurrency: request.corridor.currency,
1455
+ rail: request.corridor.rail,
1456
+ assetAmount: request.amount.toString()
1457
+ });
1458
+ const best = res.quotes?.[0];
1459
+ if (!best) throw new Error(`No solver is cashing out ${request.asset} on ${request.chainName} to ${request.corridor.currency} for this amount right now.`);
1460
+ const pc = await reader(request.chainId).catch(() => null);
1461
+ const needsApproval = pc ? !await hasPermit2Allowance(pc, request.token, wallet.address, request.amount).catch(() => false) : true;
1462
+ if (gen !== generation) return;
1463
+ const c = request.corridor;
1464
+ set({
1465
+ phase: "quoted",
1466
+ quote: best,
1467
+ needsApproval,
1468
+ preview: {
1469
+ payAmount: fmtAsset(request.amount, request.decimals),
1470
+ paySymbol: request.asset,
1471
+ chainName: request.chainName,
1472
+ receiveAmount: formatFiat(best.fiatNet, c),
1473
+ currency: c.currency,
1474
+ currencySymbol: c.symbol,
1475
+ fee: formatFiat(best.fiatFee, c),
1476
+ rate: fmtRate(best.rate),
1477
+ paymentWindowSecs: best.paymentWindowSecs,
1478
+ quotesConsidered: res.totalConsidered
1479
+ }
1480
+ });
1481
+ } catch (e) {
1482
+ if (gen === generation) set({ phase: "error", error: errMessage2(e) });
1483
+ }
1484
+ }
1485
+ async function confirm(payee) {
1486
+ const { request, quote: chosen } = state;
1487
+ const w = wallet;
1488
+ if (!request || !chosen || !w || state.phase !== "quoted") return;
1489
+ const gen = generation;
1490
+ try {
1491
+ set({ error: null });
1492
+ const details = { ...payee, rail: request.corridor.rail };
1493
+ payeeIdentity(details);
1494
+ if (!request.corridor.accountPattern.test(details.account.replace(/[\s-]/g, ""))) {
1495
+ throw new Error(`That doesn't look like a valid ${request.corridor.accountLabel.toLowerCase()}.`);
1496
+ }
1497
+ if (!details.name.trim()) throw new Error("Enter the account holder's name.");
1498
+ if (await w.getChainId() !== request.chainId) {
1499
+ set({ phase: "switchingChain" });
1500
+ await w.switchChain(request.chainId);
1501
+ }
1502
+ const pc = await reader(request.chainId).catch(() => null);
1503
+ const needsApproval = pc ? !await hasPermit2Allowance(pc, request.token, w.address, request.amount) : state.needsApproval;
1504
+ if (needsApproval) {
1505
+ set({ phase: "approving" });
1506
+ const approve = buildPermit2ApproveRequest(request.token);
1507
+ const hash = await w.writeContract({ chainId: request.chainId, ...approve, abi: approve.abi });
1508
+ if (pc) await pc.waitForTransactionReceipt({ hash });
1509
+ set({ needsApproval: false });
1510
+ }
1511
+ set({ phase: "opening" });
1512
+ const { sealed, commitment, salt } = sealPayee(details, chosen.payeeKey);
1513
+ const opened = await api.openFiatTrade({
1514
+ quoteId: chosen.quoteId,
1515
+ assetAmount: request.amount.toString(),
1516
+ userAddress: getAddress3(w.address),
1517
+ payee: { sealed, commitment }
1518
+ });
1519
+ if (gen !== generation) return;
1520
+ set({ trade: opened.trade, payeeSalt: salt });
1521
+ checkLock(opened.lock, opened.trade, request, chosen, w.address);
1522
+ if (!same(opened.trade.payee_commitment, commitment)) throw new Error("Refusing to sign: the trade is not for the bank details you entered.");
1523
+ set({ phase: "signing" });
1524
+ const { domain, types, primaryType, message } = opened.lock.typedData;
1525
+ const signature = await w.signTypedData({ domain, types, primaryType, message });
1526
+ set({ phase: "locking" });
1527
+ const trade = await api.submitFiatLock(opened.trade.id, signature);
1528
+ if (gen !== generation) return;
1529
+ showTrade(trade);
1530
+ track(trade.id, gen);
1531
+ } catch (e) {
1532
+ if (gen !== generation) return;
1533
+ if (state.trade) {
1534
+ set({ error: errMessage2(e) });
1535
+ track(state.trade.id, gen);
1536
+ } else set({ phase: "error", error: errMessage2(e) });
1537
+ }
1538
+ }
1539
+ async function act(phase, message, send) {
1540
+ const trade = state.trade;
1541
+ const w = wallet;
1542
+ if (!trade || trade.state !== "awaiting_confirmation") return;
1543
+ const gen = generation;
1544
+ const before = state.phase;
1545
+ try {
1546
+ if (!w) throw new Error("Connect the wallet that opened this trade.");
1547
+ if (!same(w.address, trade.user_address)) throw new Error("Switch to the wallet that opened this trade.");
1548
+ if (!w.signMessage) throw new Error("This wallet can't sign messages, so it can't confirm or dispute.");
1549
+ set({ phase, error: null });
1550
+ const signature = await w.signMessage(message(trade.id));
1551
+ const next = await send(trade.id, signature);
1552
+ if (gen !== generation) return;
1553
+ showTrade(next);
1554
+ track(next.id, gen);
1555
+ } catch (e) {
1556
+ if (gen === generation) set({ phase: before, error: errMessage2(e) });
1557
+ }
1558
+ }
1559
+ async function dispute(reason) {
1560
+ const r = reason.trim();
1561
+ if (!r || r.length > MAX_REASON || !PLAIN_TEXT.test(r)) {
1562
+ set({ error: `Describe the problem in 1 to ${MAX_REASON} plain characters (letters, digits, punctuation).` });
1563
+ return;
1564
+ }
1565
+ await act("disputing", (id) => fiatDisputeMessage(id, r), (id, sig) => api.disputeFiatTrade(id, r, sig));
1566
+ }
1567
+ async function resume(tradeId) {
1568
+ const gen = ++generation;
1569
+ stopPolling();
1570
+ set({ ...INITIAL2, phase: "quoting" });
1571
+ try {
1572
+ const trade = await api.getFiatTrade(tradeId);
1573
+ if (gen !== generation) return;
1574
+ showTrade(trade);
1575
+ track(trade.id, gen);
1576
+ } catch (e) {
1577
+ if (gen === generation) set({ phase: "error", error: errMessage2(e) });
1578
+ }
1579
+ }
1580
+ function reset() {
1581
+ generation++;
1582
+ stopPolling();
1583
+ set({ ...INITIAL2 });
1584
+ }
1585
+ function setWallet(next) {
1586
+ const changed = wallet?.address !== next?.address;
1587
+ wallet = next;
1588
+ if (next && state.request && !state.trade && (state.phase === "needsWallet" || changed && state.phase === "quoted")) {
1589
+ void quote(state.request);
1590
+ }
1591
+ }
1592
+ return {
1593
+ getState: () => state,
1594
+ subscribe(listener) {
1595
+ listeners.add(listener);
1596
+ return () => listeners.delete(listener);
1597
+ },
1598
+ quote,
1599
+ confirm,
1600
+ confirmReceived: () => act("confirming", fiatConfirmMessage, (id, sig) => api.confirmFiatTrade(id, sig)),
1601
+ dispute,
1602
+ resume,
1603
+ reset,
1604
+ setWallet,
1605
+ destroy() {
1606
+ generation++;
1607
+ stopPolling();
1608
+ destroyed = true;
1609
+ listeners.clear();
1610
+ }
1611
+ };
1612
+ }
1065
1613
  export {
1066
1614
  ApiError,
1615
+ BANK_DETAILS_HINT,
1067
1616
  CHAIN_ALIASES,
1068
1617
  DEFAULT_API_URL,
1069
1618
  DEFAULT_INTENT_MODEL,
1070
1619
  EMPTY_SUPPORTED,
1620
+ FIAT_CORRIDORS,
1621
+ FIAT_CURRENCY_ALIASES,
1071
1622
  HF_ROUTER_URL,
1072
1623
  MAX_UINT256,
1624
+ NG_BANKS,
1073
1625
  PERMIT2_ADDRESS,
1074
1626
  SOLANA_CHAIN_IDS,
1075
1627
  TEMPLATE_HINT,
@@ -1078,23 +1630,38 @@ export {
1078
1630
  buildPermit2ApproveRequest,
1079
1631
  buildQuoteRequest,
1080
1632
  buildSupportedSet,
1633
+ canonicalPayeeIdentity,
1081
1634
  chainName,
1635
+ checkLock,
1082
1636
  createApiClient,
1083
1637
  createIntentSession,
1638
+ createOfframpSession,
1084
1639
  explorerUrl,
1085
1640
  extractIntents,
1641
+ fiatConfirmMessage,
1642
+ fiatDisputeMessage,
1643
+ fiatPaymentSentMessage,
1086
1644
  findAsset,
1645
+ formatFiat,
1087
1646
  getPermit2Allowance,
1088
1647
  hasPermit2Allowance,
1089
1648
  interopAddress,
1090
1649
  isTerminalStatus,
1650
+ looksLikeBankDetails,
1091
1651
  normalizeChain,
1652
+ normalizeFiatCurrency,
1092
1653
  normalizeToken,
1654
+ openPayee,
1093
1655
  parseIntent,
1656
+ payeeCommitment,
1657
+ payeeIdentity,
1094
1658
  resolveIntent,
1659
+ resolveOfframp,
1095
1660
  rpcReaders,
1661
+ sealPayee,
1096
1662
  signQuote,
1097
1663
  stripDomainType,
1098
1664
  validateIntent,
1665
+ validateOfframp,
1099
1666
  viemWallet
1100
1667
  };