@persistenceone/bridgekitty 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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/dist/backends/across.d.ts +10 -0
  4. package/dist/backends/across.js +285 -0
  5. package/dist/backends/debridge.d.ts +11 -0
  6. package/dist/backends/debridge.js +380 -0
  7. package/dist/backends/lifi.d.ts +19 -0
  8. package/dist/backends/lifi.js +295 -0
  9. package/dist/backends/persistence.d.ts +86 -0
  10. package/dist/backends/persistence.js +642 -0
  11. package/dist/backends/relay.d.ts +11 -0
  12. package/dist/backends/relay.js +292 -0
  13. package/dist/backends/squid.d.ts +31 -0
  14. package/dist/backends/squid.js +476 -0
  15. package/dist/backends/types.d.ts +125 -0
  16. package/dist/backends/types.js +11 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +154 -0
  19. package/dist/routing/engine.d.ts +49 -0
  20. package/dist/routing/engine.js +336 -0
  21. package/dist/tools/check-status.d.ts +3 -0
  22. package/dist/tools/check-status.js +93 -0
  23. package/dist/tools/execute-bridge.d.ts +3 -0
  24. package/dist/tools/execute-bridge.js +428 -0
  25. package/dist/tools/get-chains.d.ts +3 -0
  26. package/dist/tools/get-chains.js +162 -0
  27. package/dist/tools/get-quote.d.ts +3 -0
  28. package/dist/tools/get-quote.js +534 -0
  29. package/dist/tools/get-tokens.d.ts +3 -0
  30. package/dist/tools/get-tokens.js +128 -0
  31. package/dist/tools/help.d.ts +2 -0
  32. package/dist/tools/help.js +204 -0
  33. package/dist/tools/multi-quote.d.ts +3 -0
  34. package/dist/tools/multi-quote.js +310 -0
  35. package/dist/tools/onboard.d.ts +3 -0
  36. package/dist/tools/onboard.js +218 -0
  37. package/dist/tools/wallet.d.ts +14 -0
  38. package/dist/tools/wallet.js +744 -0
  39. package/dist/tools/xprt-farm.d.ts +3 -0
  40. package/dist/tools/xprt-farm.js +1308 -0
  41. package/dist/tools/xprt-rewards.d.ts +2 -0
  42. package/dist/tools/xprt-rewards.js +177 -0
  43. package/dist/tools/xprt-staking.d.ts +2 -0
  44. package/dist/tools/xprt-staking.js +565 -0
  45. package/dist/utils/chains.d.ts +22 -0
  46. package/dist/utils/chains.js +154 -0
  47. package/dist/utils/circuit-breaker.d.ts +64 -0
  48. package/dist/utils/circuit-breaker.js +160 -0
  49. package/dist/utils/evm.d.ts +18 -0
  50. package/dist/utils/evm.js +46 -0
  51. package/dist/utils/fill-detector.d.ts +70 -0
  52. package/dist/utils/fill-detector.js +298 -0
  53. package/dist/utils/gas-estimator.d.ts +67 -0
  54. package/dist/utils/gas-estimator.js +340 -0
  55. package/dist/utils/sanitize-error.d.ts +23 -0
  56. package/dist/utils/sanitize-error.js +101 -0
  57. package/dist/utils/token-registry.d.ts +70 -0
  58. package/dist/utils/token-registry.js +669 -0
  59. package/dist/utils/tokens.d.ts +17 -0
  60. package/dist/utils/tokens.js +37 -0
  61. package/dist/utils/tx-simulator.d.ts +27 -0
  62. package/dist/utils/tx-simulator.js +105 -0
  63. package/package.json +75 -0
@@ -0,0 +1,642 @@
1
+ import { ethers } from "ethers";
2
+ import { BackendValidationError } from "./types.js";
3
+ import { formatTokenAmount } from "../utils/tokens.js";
4
+ import { estimateGasCostUsd, getGasUnits, getProvider } from "../utils/gas-estimator.js";
5
+ import { sanitizeError } from "../utils/sanitize-error.js";
6
+ /**
7
+ * @deprecated Use BackendValidationError from types.ts instead.
8
+ * Kept as re-export for backward compatibility.
9
+ */
10
+ export const PersistenceValidationError = BackendValidationError;
11
+ const BASE_URL = "https://api.interop.persistence.one";
12
+ const TIMEOUT_MS = 15_000;
13
+ // Contract addresses (same on Base 8453 and BSC 56)
14
+ const SETTLEMENT_CONTRACT = "0x5e53703b62472c336D2d7963e789b911cFafFeA7";
15
+ const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
16
+ // ABI fragments for settlement contract
17
+ const SETTLEMENT_ABI = [
18
+ "function prepareCrossChainOrder(address inputToken, uint256 inputAmount, address outputToken, uint256 outputAmount, address recipientAddress, uint32 destinationChainId, uint32 initiateDeadline, uint32 fillDeadline) external view returns (tuple(address settlementContract, address swapper, uint256 nonce, uint32 originChainId, uint32 initiateDeadline, uint32 fillDeadline, bytes orderData))",
19
+ "function initiate(tuple(address settlementContract, address swapper, uint256 nonce, uint32 originChainId, uint32 initiateDeadline, uint32 fillDeadline, bytes orderData) order, bytes signature, bytes fillerData) external",
20
+ ];
21
+ const ERC20_ABI = [
22
+ "function approve(address spender, uint256 amount) returns (bool)",
23
+ "function allowance(address owner, address spender) view returns (uint256)",
24
+ ];
25
+ // EIP-712 types for Permit2 witness signing
26
+ const PERMIT2_DOMAIN = {
27
+ name: "Permit2",
28
+ chainId: 0, // set dynamically
29
+ verifyingContract: PERMIT2_ADDRESS,
30
+ };
31
+ // The witness type for CrossChainOrder
32
+ const CROSS_CHAIN_ORDER_TYPE = {
33
+ CrossChainOrder: [
34
+ { name: "settlementContract", type: "address" },
35
+ { name: "swapper", type: "address" },
36
+ { name: "nonce", type: "uint256" },
37
+ { name: "originChainId", type: "uint32" },
38
+ { name: "initiateDeadline", type: "uint32" },
39
+ { name: "fillDeadline", type: "uint32" },
40
+ { name: "orderData", type: "bytes" },
41
+ ],
42
+ };
43
+ // Full Permit2 PermitTransferFrom + witness types
44
+ const PERMIT2_WITNESS_TYPES = {
45
+ PermitWitnessTransferFrom: [
46
+ { name: "permitted", type: "TokenPermissions" },
47
+ { name: "spender", type: "address" },
48
+ { name: "nonce", type: "uint256" },
49
+ { name: "deadline", type: "uint256" },
50
+ { name: "witness", type: "CrossChainOrder" },
51
+ ],
52
+ TokenPermissions: [
53
+ { name: "token", type: "address" },
54
+ { name: "amount", type: "uint256" },
55
+ ],
56
+ ...CROSS_CHAIN_ORDER_TYPE,
57
+ };
58
+ async function fetchJson(url, init) {
59
+ const controller = new AbortController();
60
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
61
+ try {
62
+ const res = await fetch(url, { ...init, signal: controller.signal });
63
+ if (!res.ok) {
64
+ const text = await res.text().catch(() => "");
65
+ throw new Error(`Persistence ${res.status}: ${text.slice(0, 200)}`);
66
+ }
67
+ return res.json();
68
+ }
69
+ finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
73
+ // Persistence Interop currently supports BTC variants on Base and BSC
74
+ const SUPPORTED_CHAINS = [
75
+ { id: 8453, name: "Base", key: "base" },
76
+ { id: 56, name: "BNB Chain", key: "bsc" },
77
+ ];
78
+ // Amount caps in raw units (8-decimal BTC): 0.000045–0.001 BTC = 4500–100000
79
+ // MIN is set slightly below 0.00005 (5000) to allow return-leg fills after solver fees (~0.5%).
80
+ // The xprt-farm tool uses 5000 as the minimum to START a round, but the backend accepts 4500
81
+ // so that the return leg can proceed with the solver's output (which is slightly less than input).
82
+ const MIN_AMOUNT_RAW = 4500n;
83
+ const MAX_AMOUNT_RAW = 100000n;
84
+ const MAX_QUOTES_RETURNED = 10;
85
+ // Supported BTC token addresses
86
+ const BTC_TOKENS = {
87
+ 8453: { address: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", symbol: "cbBTC" },
88
+ 56: { address: "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", symbol: "BTCB" },
89
+ };
90
+ export class PersistenceBackend {
91
+ name = "persistence";
92
+ /**
93
+ * Validate amount against Persistence Interop caps.
94
+ * Caps are defined in 8-decimal BTC units (MIN_AMOUNT_RAW=5000, MAX_AMOUNT_RAW=100000).
95
+ * BTCB uses 18 decimals, cbBTC uses 8 decimals — normalize before comparing.
96
+ */
97
+ validateAmount(amountRaw, fromChainId) {
98
+ let amt;
99
+ try {
100
+ amt = BigInt(amountRaw);
101
+ }
102
+ catch {
103
+ throw new BackendValidationError(`Invalid amount: "${amountRaw}" is not a valid number`);
104
+ }
105
+ if (amt <= 0n) {
106
+ throw new BackendValidationError(`Amount must be positive. Got: ${amountRaw}`);
107
+ }
108
+ // Normalize to 8-decimal BTC for cap comparison
109
+ // BTCB (BSC, chain 56) = 18 decimals, cbBTC (Base, chain 8453) = 8 decimals
110
+ const fromToken = BTC_TOKENS[fromChainId];
111
+ const fromDecimals = fromToken?.symbol === "BTCB" ? 18 : 8;
112
+ const normalized = fromDecimals > 8 ? amt / (10n ** BigInt(fromDecimals - 8)) : amt;
113
+ if (normalized < MIN_AMOUNT_RAW) {
114
+ throw new BackendValidationError(`Amount too small (${amountRaw} raw, ~${Number(normalized) / 1e8} BTC). Minimum is 0.000045 BTC.`);
115
+ }
116
+ if (normalized > MAX_AMOUNT_RAW) {
117
+ throw new BackendValidationError(`Amount too large (${amountRaw} raw, ~${Number(normalized) / 1e8} BTC). Maximum is 0.001 BTC.`);
118
+ }
119
+ }
120
+ async getQuote(params) {
121
+ try {
122
+ // Only support BTC cross-chain between Base and BSC
123
+ const fromBtc = BTC_TOKENS[params.fromChainId];
124
+ const toBtc = BTC_TOKENS[params.toChainId];
125
+ if (!fromBtc || !toBtc)
126
+ return null;
127
+ // Check if the from token matches our supported BTC variant (address or symbol)
128
+ const fromAddr = params.fromTokenAddress.toLowerCase();
129
+ if (fromAddr !== fromBtc.address.toLowerCase() &&
130
+ fromAddr !== fromBtc.symbol.toLowerCase()) {
131
+ return null;
132
+ }
133
+ // Check if the to token matches our supported BTC variant
134
+ const toAddr = params.toTokenAddress.toLowerCase();
135
+ if (toAddr !== toBtc.address.toLowerCase() &&
136
+ toAddr !== toBtc.symbol.toLowerCase()) {
137
+ return null;
138
+ }
139
+ // BUG-001 & BUG-002: Validate amount caps and reject zero/negative
140
+ this.validateAmount(params.amountRaw, params.fromChainId);
141
+ const data = await fetchJson(`${BASE_URL}/quotes/request`, {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json" },
144
+ body: JSON.stringify({
145
+ sourceChainId: params.fromChainId,
146
+ destinationChainId: params.toChainId,
147
+ sourceAsset: fromBtc.address,
148
+ destinationAsset: toBtc.address,
149
+ sourceAmount: params.amountRaw,
150
+ }),
151
+ });
152
+ // API returns {quotes: [...]} or an array
153
+ const allQuotes = Array.isArray(data) ? data : (data.quotes ?? [data]);
154
+ if (!allQuotes.length || allQuotes[0]?.error)
155
+ return null;
156
+ // Filter out expired quotes (OBSERVATION-001 & OBSERVATION-002)
157
+ const now = Date.now();
158
+ const validQuotes = allQuotes.filter((q) => {
159
+ if (!q.expirationTime)
160
+ return true;
161
+ const expiry = new Date(q.expirationTime).getTime();
162
+ return expiry > now;
163
+ });
164
+ if (!validQuotes.length)
165
+ return null;
166
+ // Sort by best output and take top N (OBSERVATION-001: reduce 300+ to best 10)
167
+ validQuotes.sort((a, b) => {
168
+ try {
169
+ const diff = BigInt(b.estimatedDestinationAmount ?? "0") - BigInt(a.estimatedDestinationAmount ?? "0");
170
+ return diff > 0n ? 1 : diff < 0n ? -1 : 0;
171
+ }
172
+ catch {
173
+ return 0;
174
+ }
175
+ });
176
+ const topQuotes = validQuotes.slice(0, Number(MAX_QUOTES_RETURNED));
177
+ const best = topQuotes[0];
178
+ const dstDecimals = toBtc.symbol === "BTCB" ? 18 : 8;
179
+ const outputRaw = best.estimatedDestinationAmount ?? "0";
180
+ const feeUsd = 0;
181
+ // Estimate source chain gas cost (chain-aware)
182
+ const gasUnits = getGasUnits("persistence", params.fromChainId);
183
+ const gasEstimate = await estimateGasCostUsd(params.fromChainId, gasUnits);
184
+ const gasCostUsd = gasEstimate?.costUsd ?? null;
185
+ // Solver-based: apply 0.5% slippage for min output
186
+ let minOutputRaw;
187
+ try {
188
+ const outputBig = BigInt(outputRaw);
189
+ minOutputRaw = (outputBig * 995n / 1000n).toString();
190
+ }
191
+ catch {
192
+ minOutputRaw = outputRaw;
193
+ }
194
+ return {
195
+ backendName: "persistence",
196
+ provider: "Persistence Interop (direct)",
197
+ outputAmount: formatTokenAmount(outputRaw, dstDecimals),
198
+ outputAmountRaw: outputRaw,
199
+ minOutputAmount: formatTokenAmount(minOutputRaw, dstDecimals),
200
+ minOutputAmountRaw: minOutputRaw,
201
+ outputDecimals: dstDecimals,
202
+ estimatedGasCostUsd: gasCostUsd,
203
+ usingFallbackPrices: gasEstimate?.usingFallbackPrices,
204
+ estimatedFeeUsd: gasCostUsd !== null ? feeUsd + gasCostUsd : null,
205
+ feeBreakdown: {
206
+ gasCostUsd,
207
+ protocolFeeUsd: feeUsd,
208
+ integratorFeeUsd: 0,
209
+ integratorFeePercent: null,
210
+ totalFeeUsd: gasCostUsd !== null ? feeUsd + gasCostUsd : null,
211
+ },
212
+ estimatedTimeSeconds: 10,
213
+ route: `${fromBtc.symbol} → Persistence Solver → ${toBtc.symbol}`,
214
+ quoteData: {
215
+ ...best,
216
+ // Stash params needed for buildTransaction/signAndExecute
217
+ sourceChainId: params.fromChainId,
218
+ destinationChainId: params.toChainId,
219
+ sourceAmount: params.amountRaw,
220
+ fromAddress: params.fromAddress,
221
+ },
222
+ expiresAt: best.expirationTime ? new Date(best.expirationTime).getTime() : Date.now() + 60_000,
223
+ _meta: {
224
+ totalQuotesFromSolver: allQuotes.length,
225
+ expiredFiltered: allQuotes.length - validQuotes.length,
226
+ returnedAfterFilter: topQuotes.length,
227
+ },
228
+ };
229
+ }
230
+ catch (err) {
231
+ if (err instanceof BackendValidationError) {
232
+ throw err; // Let validation errors propagate
233
+ }
234
+ console.error("[persistence] quote error:", err.message);
235
+ return null;
236
+ }
237
+ }
238
+ /**
239
+ * Prepare a CrossChainOrder for signing. This calls the settlement contract
240
+ * on-chain to get a properly formed order with nonce and orderData.
241
+ */
242
+ async prepareOrder(quote, swapperAddress) {
243
+ const data = quote.quoteData;
244
+ const sourceChainId = data.sourceChainId ?? data.chainId ?? 8453;
245
+ const destChainId = data.destinationChainId ?? data.destChainId ?? 56;
246
+ const fromBtc = BTC_TOKENS[sourceChainId];
247
+ const toBtc = BTC_TOKENS[destChainId];
248
+ if (!fromBtc || !toBtc) {
249
+ throw new Error(`Unsupported chain pair: ${sourceChainId} → ${destChainId}`);
250
+ }
251
+ const provider = await getProvider(sourceChainId);
252
+ const settlement = new ethers.Contract(SETTLEMENT_CONTRACT, SETTLEMENT_ABI, provider);
253
+ const now = Math.floor(Date.now() / 1000);
254
+ const initiateDeadline = now + 300; // 5 minutes (allows slow RPC confirmation)
255
+ const fillDeadline = now + 1800; // 30 minutes (tightened from 2h — fills take <30s)
256
+ const inputAmount = data.sourceAmount;
257
+ if (!inputAmount)
258
+ throw new Error(`Missing sourceAmount in quote data`);
259
+ // MEDIUM-001: Use slippage-adjusted minOutputRaw instead of raw estimatedDestinationAmount
260
+ const rawOutputAmount = data.estimatedDestinationAmount ?? data.outputAmount;
261
+ if (!rawOutputAmount)
262
+ throw new Error(`Missing output amount in quote data`);
263
+ // Validate output is reasonable (at least 95% of input for same-asset cross-chain)
264
+ try {
265
+ const inputBig = BigInt(inputAmount);
266
+ const outputBig = BigInt(rawOutputAmount);
267
+ // Normalize to same decimals for comparison
268
+ const fromDecimals = BTC_TOKENS[sourceChainId]?.symbol === "BTCB" ? 18 : 8;
269
+ const toDecimals = BTC_TOKENS[destChainId]?.symbol === "BTCB" ? 18 : 8;
270
+ const normalizedInput = fromDecimals > toDecimals
271
+ ? inputBig / (10n ** BigInt(fromDecimals - toDecimals))
272
+ : inputBig * (10n ** BigInt(toDecimals - fromDecimals));
273
+ const minAcceptable = normalizedInput * 95n / 100n;
274
+ if (outputBig < minAcceptable) {
275
+ throw new Error(`Solver output too low: ${rawOutputAmount} is less than 95% of input (${normalizedInput.toString()}). Possible manipulation.`);
276
+ }
277
+ }
278
+ catch (e) {
279
+ if (e.message.includes("Solver output too low"))
280
+ throw e;
281
+ // If BigInt conversion fails, continue with the raw amount
282
+ }
283
+ // Use minOutputRaw (with 0.5% slippage) from the quote for on-chain order
284
+ const outputAmount = quote.minOutputAmountRaw ?? rawOutputAmount;
285
+ console.log(`[persistence] Preparing order: ${fromBtc.symbol} (${sourceChainId}) → ${toBtc.symbol} (${destChainId})`);
286
+ console.log(`[persistence] Input: ${inputAmount}, Output: ${outputAmount}`);
287
+ // Call prepareCrossChainOrder with `from` set so the contract sees msg.sender
288
+ // as the swapper address. This makes the contract return the correct swapper
289
+ // and a valid Permit2 nonce (next unused slot in the Permit2 bitmap).
290
+ const callData = settlement.interface.encodeFunctionData("prepareCrossChainOrder", [
291
+ fromBtc.address,
292
+ inputAmount,
293
+ toBtc.address,
294
+ outputAmount,
295
+ swapperAddress,
296
+ destChainId,
297
+ initiateDeadline,
298
+ fillDeadline,
299
+ ]);
300
+ const rawResult = await provider.call({
301
+ to: SETTLEMENT_CONTRACT,
302
+ data: callData,
303
+ from: swapperAddress,
304
+ });
305
+ const orderResult = settlement.interface.decodeFunctionResult("prepareCrossChainOrder", rawResult);
306
+ // orderResult[0] is the tuple: (settlementContract, swapper, nonce, originChainId, initiateDeadline, fillDeadline, orderData)
307
+ const contractOrder = orderResult[0];
308
+ const order = {
309
+ settlementContract: contractOrder.settlementContract,
310
+ swapper: contractOrder.swapper,
311
+ nonce: BigInt(contractOrder.nonce),
312
+ originChainId: Number(contractOrder.originChainId),
313
+ initiateDeadline: Number(contractOrder.initiateDeadline),
314
+ fillDeadline: Number(contractOrder.fillDeadline),
315
+ orderData: contractOrder.orderData,
316
+ };
317
+ console.log(`[persistence] Contract returned nonce: ${order.nonce}, swapper: ${order.swapper}`);
318
+ // Build EIP-712 typed data for Permit2 witness signing
319
+ const eip712Domain = {
320
+ ...PERMIT2_DOMAIN,
321
+ chainId: sourceChainId,
322
+ };
323
+ const eip712Value = {
324
+ permitted: {
325
+ token: fromBtc.address,
326
+ amount: inputAmount,
327
+ },
328
+ spender: SETTLEMENT_CONTRACT,
329
+ nonce: order.nonce,
330
+ deadline: BigInt(initiateDeadline),
331
+ witness: {
332
+ settlementContract: order.settlementContract,
333
+ swapper: order.swapper,
334
+ nonce: order.nonce,
335
+ originChainId: order.originChainId,
336
+ initiateDeadline: order.initiateDeadline,
337
+ fillDeadline: order.fillDeadline,
338
+ orderData: order.orderData,
339
+ },
340
+ };
341
+ // Build approval tx for Permit2
342
+ const erc20Iface = new ethers.Interface(ERC20_ABI);
343
+ const approvalData = erc20Iface.encodeFunctionData("approve", [
344
+ PERMIT2_ADDRESS,
345
+ inputAmount,
346
+ ]);
347
+ return {
348
+ order,
349
+ eip712Domain,
350
+ eip712Types: PERMIT2_WITNESS_TYPES,
351
+ eip712Value,
352
+ inputToken: fromBtc.address,
353
+ inputAmount,
354
+ approvalTx: {
355
+ to: fromBtc.address,
356
+ data: approvalData,
357
+ value: "0x0",
358
+ chainId: sourceChainId,
359
+ },
360
+ };
361
+ }
362
+ /**
363
+ * Build transaction data for the MCP flow (no server-side signing).
364
+ * Calls prepareOrder() via on-chain view to get the EIP-712 typed data
365
+ * and Permit2 approval tx that the agent/wallet must sign externally.
366
+ *
367
+ * Use signAndExecute() for flows where a signer (private key) is available.
368
+ */
369
+ async buildTransaction(quote) {
370
+ const data = quote.quoteData;
371
+ const fromAddress = data.fromAddress;
372
+ if (!fromAddress) {
373
+ throw new Error("fromAddress not available in Persistence quote. Re-quote with a fromAddress set.");
374
+ }
375
+ const prepared = await this.prepareOrder(quote, fromAddress);
376
+ const sourceChainId = data.sourceChainId ?? 8453;
377
+ const orderId = data.id ?? `pending-${Date.now()}`;
378
+ // Serialize bigint values in EIP-712 typed data to strings for JSON
379
+ const serializeBigInts = (obj) => {
380
+ if (typeof obj === "bigint")
381
+ return obj.toString();
382
+ if (Array.isArray(obj))
383
+ return obj.map(serializeBigInts);
384
+ if (obj !== null && typeof obj === "object") {
385
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, serializeBigInts(v)]));
386
+ }
387
+ return obj;
388
+ };
389
+ return {
390
+ // The on-chain initiate() call targets the settlement contract,
391
+ // but execution requires the EIP-712 signature first — return placeholders.
392
+ to: SETTLEMENT_CONTRACT,
393
+ data: "0x",
394
+ value: "0x0",
395
+ chainId: sourceChainId,
396
+ provider: "persistence",
397
+ trackingId: `persistence:${orderId}`,
398
+ approvalTx: prepared.approvalTx,
399
+ eip712: {
400
+ domain: serializeBigInts(prepared.eip712Domain),
401
+ types: prepared.eip712Types,
402
+ value: serializeBigInts(prepared.eip712Value),
403
+ description: "Permit2 CrossChainOrder — sign this EIP-712 message with your wallet to authorize the " +
404
+ "Persistence Interop bridge. After signing, call the settlement contract's initiate() " +
405
+ "with the order struct and your signature.",
406
+ },
407
+ };
408
+ }
409
+ /**
410
+ * Full sign-and-execute flow for when a signer (private key) is available.
411
+ * This is used by test scripts and the ACP listener.
412
+ *
413
+ * Returns the source chain tx hash and order ID for tracking.
414
+ */
415
+ async signAndExecute(quote, signer) {
416
+ const data = quote.quoteData;
417
+ const sourceChainId = data.sourceChainId ?? data.chainId ?? 8453;
418
+ // Ensure signer is connected to the right chain
419
+ const provider = await getProvider(sourceChainId);
420
+ const connectedSigner = signer.connect(provider);
421
+ const swapperAddress = await connectedSigner.getAddress();
422
+ // Step 1: Prepare the order
423
+ console.log("[persistence] Step 1: Preparing cross-chain order...");
424
+ let prepared = await this.prepareOrder(quote, swapperAddress);
425
+ // Step 2: Check and set Permit2 allowance
426
+ console.log("[persistence] Step 2: Checking Permit2 allowance...");
427
+ const erc20 = new ethers.Contract(prepared.inputToken, ERC20_ABI, connectedSigner);
428
+ const currentAllowance = await erc20.allowance(swapperAddress, PERMIT2_ADDRESS);
429
+ if (currentAllowance < BigInt(prepared.inputAmount)) {
430
+ console.log("[persistence] Approving Permit2...");
431
+ const approveTx = await erc20.approve(PERMIT2_ADDRESS, prepared.inputAmount);
432
+ console.log(`[persistence] Approval tx: ${approveTx.hash}`);
433
+ await approveTx.wait();
434
+ // Brief delay after approval to let RPC nodes sync the new allowance.
435
+ // Without this, estimateGas for initiate() can hit a node that hasn't seen the approval yet,
436
+ // causing a spurious TRANSFER_FROM_FAILED error.
437
+ console.log("[persistence] Permit2 approved. Waiting 2s for RPC propagation...");
438
+ await new Promise(r => setTimeout(r, 2_000));
439
+ }
440
+ else {
441
+ console.log("[persistence] Permit2 already has sufficient allowance.");
442
+ }
443
+ // Step 3: Sign EIP-712 typed data
444
+ console.log("[persistence] Step 3: Signing EIP-712 typed data...");
445
+ let signature = await connectedSigner.signTypedData(prepared.eip712Domain, prepared.eip712Types, prepared.eip712Value);
446
+ console.log("[persistence] Signature obtained.");
447
+ // Step 4: Initiate on-chain (with nonce-collision retry)
448
+ console.log("[persistence] Step 4: Initiating on-chain...");
449
+ const settlement = new ethers.Contract(SETTLEMENT_CONTRACT, SETTLEMENT_ABI, connectedSigner);
450
+ const fillerData = ethers.zeroPadValue("0x", 32);
451
+ let orderTuple = [
452
+ prepared.order.settlementContract,
453
+ prepared.order.swapper,
454
+ prepared.order.nonce,
455
+ prepared.order.originChainId,
456
+ prepared.order.initiateDeadline,
457
+ prepared.order.fillDeadline,
458
+ prepared.order.orderData,
459
+ ];
460
+ let initiateTx;
461
+ let receipt;
462
+ const MAX_NONCE_RETRIES = 2;
463
+ let lastInitiateError = null;
464
+ for (let attempt = 0; attempt <= MAX_NONCE_RETRIES; attempt++) {
465
+ try {
466
+ if (attempt > 0) {
467
+ // Re-prepare with fresh nonce from the contract
468
+ console.log(`[persistence] Retry ${attempt}/${MAX_NONCE_RETRIES}: preparing fresh order with new nonce...`);
469
+ const freshPrepared = await this.prepareOrder(quote, swapperAddress);
470
+ prepared = freshPrepared;
471
+ // Re-sign with fresh nonce
472
+ const freshSignature = await connectedSigner.signTypedData(freshPrepared.eip712Domain, freshPrepared.eip712Types, freshPrepared.eip712Value);
473
+ signature = freshSignature;
474
+ // Rebuild order tuple
475
+ orderTuple[0] = freshPrepared.order.settlementContract;
476
+ orderTuple[1] = freshPrepared.order.swapper;
477
+ orderTuple[2] = freshPrepared.order.nonce;
478
+ orderTuple[3] = freshPrepared.order.originChainId;
479
+ orderTuple[4] = freshPrepared.order.initiateDeadline;
480
+ orderTuple[5] = freshPrepared.order.fillDeadline;
481
+ orderTuple[6] = freshPrepared.order.orderData;
482
+ console.log(`[persistence] Fresh nonce: ${freshPrepared.order.nonce}`);
483
+ }
484
+ initiateTx = await settlement.initiate(orderTuple, signature, fillerData);
485
+ console.log(`[persistence] Initiate tx: ${initiateTx.hash}`);
486
+ receipt = await initiateTx.wait();
487
+ if (receipt && receipt.status === 0) {
488
+ throw new Error(`Initiate transaction reverted on-chain (block ${receipt.blockNumber})`);
489
+ }
490
+ console.log(`[persistence] Confirmed in block ${receipt?.blockNumber}`);
491
+ lastInitiateError = null;
492
+ break; // Success — exit retry loop
493
+ }
494
+ catch (initiateError) {
495
+ lastInitiateError = initiateError;
496
+ const errMsg = initiateError.message ?? "";
497
+ // Distinguish nonce errors (retryable) from transfer/balance errors (not retryable)
498
+ const isTransferError = errMsg.includes("TRANSFER_FROM_FAILED") ||
499
+ errMsg.includes("TRANSFER_FAILED") ||
500
+ errMsg.includes("insufficient balance") ||
501
+ errMsg.includes("ERC20: transfer amount exceeds balance");
502
+ const isNonceError = !isTransferError && (errMsg.includes("NONCE_ALREADY_USED") ||
503
+ errMsg.includes("InvalidNonce") ||
504
+ errMsg.includes("nonce too low") ||
505
+ errMsg.includes("nonce has already been used"));
506
+ if (isTransferError && attempt < MAX_NONCE_RETRIES) {
507
+ // Approval may not have propagated to all RPC nodes yet (race condition).
508
+ // Wait a few seconds for sync, then retry with a fresh nonce/order.
509
+ console.warn(`[persistence] initiate() got transfer error — waiting 3s for approval propagation (attempt ${attempt + 1}/${MAX_NONCE_RETRIES + 1})...`);
510
+ await new Promise(r => setTimeout(r, 3_000));
511
+ continue;
512
+ }
513
+ else if (isTransferError) {
514
+ // All retries exhausted — this is a genuine balance/allowance issue
515
+ console.warn(`[persistence] initiate() failed with transfer/balance error after ${attempt + 1} attempts: ${errMsg.slice(0, 200)}`);
516
+ }
517
+ else if (isNonceError && attempt < MAX_NONCE_RETRIES) {
518
+ console.warn(`[persistence] initiate() failed with nonce error (attempt ${attempt + 1}), will retry with fresh nonce`);
519
+ continue;
520
+ }
521
+ // Final failure — revoke approval and throw
522
+ console.warn(`[persistence] initiate() failed after ${attempt + 1} attempt(s). Revoking Permit2 ERC20 approval.`);
523
+ try {
524
+ const revokeTx = await erc20.approve(PERMIT2_ADDRESS, 0);
525
+ await revokeTx.wait();
526
+ console.log("[persistence] Permit2 approval revoked (set to 0).");
527
+ }
528
+ catch (revokeError) {
529
+ console.error(`[persistence] Failed to revoke Permit2 approval: ${revokeError.message}`);
530
+ }
531
+ throw initiateError;
532
+ }
533
+ }
534
+ if (lastInitiateError)
535
+ throw lastInitiateError;
536
+ // Step 5: Submit to backend
537
+ console.log("[persistence] Step 5: Submitting to backend...");
538
+ const orderId = data.id ?? `order-${Date.now()}`;
539
+ // Compute orderHash: keccak256 of the ABI-encoded order struct (hoisted for retry access)
540
+ const orderHash = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["address", "address", "uint256", "uint32", "uint32", "uint32", "bytes"], [
541
+ prepared.order.settlementContract,
542
+ prepared.order.swapper,
543
+ prepared.order.nonce,
544
+ prepared.order.originChainId,
545
+ prepared.order.initiateDeadline,
546
+ prepared.order.fillDeadline,
547
+ prepared.order.orderData,
548
+ ]));
549
+ console.log(`[persistence] Order hash: ${orderHash}`);
550
+ try {
551
+ await fetchJson(`${BASE_URL}/orders/submit-with-tx`, {
552
+ method: "POST",
553
+ headers: { "Content-Type": "application/json" },
554
+ body: JSON.stringify({
555
+ settlementContract: prepared.order.settlementContract,
556
+ swapper: swapperAddress,
557
+ nonce: Number(prepared.order.nonce),
558
+ originChainId: sourceChainId,
559
+ initiateDeadline: Number(prepared.order.initiateDeadline),
560
+ fillDeadline: Number(prepared.order.fillDeadline),
561
+ orderData: prepared.order.orderData,
562
+ signature,
563
+ orderHash,
564
+ sourceChainTxHash: initiateTx.hash,
565
+ }),
566
+ });
567
+ console.log("[persistence] Order submitted to backend.");
568
+ }
569
+ catch (err) {
570
+ console.warn(`[persistence] Backend submission failed, retrying once: ${err.message}`);
571
+ try {
572
+ await new Promise(r => setTimeout(r, 2000));
573
+ await fetchJson(`${BASE_URL}/orders/submit-with-tx`, {
574
+ method: "POST",
575
+ headers: { "Content-Type": "application/json" },
576
+ body: JSON.stringify({
577
+ settlementContract: prepared.order.settlementContract,
578
+ swapper: swapperAddress,
579
+ nonce: Number(prepared.order.nonce),
580
+ originChainId: sourceChainId,
581
+ initiateDeadline: Number(prepared.order.initiateDeadline),
582
+ fillDeadline: Number(prepared.order.fillDeadline),
583
+ orderData: prepared.order.orderData,
584
+ signature,
585
+ orderHash,
586
+ sourceChainTxHash: initiateTx.hash,
587
+ }),
588
+ });
589
+ console.log("[persistence] Order submitted to backend (retry succeeded).");
590
+ }
591
+ catch (retryErr) {
592
+ console.warn(`[persistence] Backend submission retry also failed (non-fatal): ${retryErr.message}`);
593
+ }
594
+ }
595
+ return {
596
+ txHash: initiateTx.hash,
597
+ orderId,
598
+ trackingId: `persistence:${orderId}`,
599
+ };
600
+ }
601
+ async getStatus(trackingId, meta) {
602
+ try {
603
+ const orderId = meta?.orderId ?? trackingId.replace("persistence:", "");
604
+ // Use /orders/{orderId}/status for order lifecycle status
605
+ const data = await fetchJson(`${BASE_URL}/orders/${orderId}/status`);
606
+ // Map API statuses to BridgeStatus states
607
+ const stateMap = {
608
+ CREATED: "pending",
609
+ ACCEPTED: "pending",
610
+ SUBMITTED_SOURCE: "in_progress",
611
+ USER_SUBMITTED_SOURCE: "in_progress",
612
+ PENDING_CONFIRMATION: "in_progress",
613
+ FULFILLED: "completed",
614
+ EXPIRED: "failed",
615
+ UNFULFILLED: "failed",
616
+ VERIFICATION_FAILED: "failed",
617
+ };
618
+ return {
619
+ state: stateMap[data.status] ?? "in_progress",
620
+ humanReadable: `Persistence Interop: ${data.status ?? "unknown"}`,
621
+ sourceTxHash: data.sourceChainTxHash,
622
+ destTxHash: data.destinationChainTxHash,
623
+ provider: "persistence",
624
+ elapsed: 0,
625
+ };
626
+ }
627
+ catch (err) {
628
+ return {
629
+ state: "unknown",
630
+ humanReadable: `Status check failed: ${sanitizeError(err)}`,
631
+ provider: "persistence",
632
+ elapsed: 0,
633
+ };
634
+ }
635
+ }
636
+ async getSupportedChains() {
637
+ return SUPPORTED_CHAINS.map((c) => ({
638
+ ...c,
639
+ providers: ["persistence"],
640
+ }));
641
+ }
642
+ }
@@ -0,0 +1,11 @@
1
+ import type { BridgeBackend, BridgeQuote, BridgeStatus, ChainInfo, QuoteParams, TransactionRequest } from "./types.js";
2
+ export declare class RelayBackend implements BridgeBackend {
3
+ name: string;
4
+ private appFeeRecipient?;
5
+ private appFeeBps?;
6
+ constructor(appFeeRecipient?: string, appFeeBps?: string);
7
+ getQuote(params: QuoteParams): Promise<BridgeQuote | null>;
8
+ buildTransaction(quote: BridgeQuote): Promise<TransactionRequest>;
9
+ getStatus(trackingId: string, meta?: Record<string, string>): Promise<BridgeStatus>;
10
+ getSupportedChains(): Promise<ChainInfo[]>;
11
+ }