@anywalletkit/protocol-swap2-core 0.9.0 → 0.10.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.
@@ -190,6 +190,24 @@ interface SerializedExecutionExpectation {
190
190
  token: string;
191
191
  maxAmount: string;
192
192
  };
193
+ /**
194
+ * Bridges: the destination chain (CAIP-2) and the reviewed recipient there. Value leaving the account may only go into
195
+ * the bridge (plus the provider's declared fee), and a bridge instruction whose recipient the account can read must
196
+ * name this one; an account that cannot prove where the funds go refuses the transaction. The engine also names the
197
+ * reviewed destination token (zero address = native), the quoted minimum in its base units with its decimals (the
198
+ * catalog's, else the caller's verified `toTokenDecimals`; never the quote's alone), and the slippage the plan allows, so a bridge
199
+ * order's own token and minimum can be held to them.
200
+ * Set on every cross-chain serialized leg. A Solana account maps the whole expectation with wallet-solana's
201
+ * `serializedGuardFromExecution`; a hand mapping that drops `bridgeOut` leaves the leg checked against the SOL ceiling only.
202
+ */
203
+ bridgeOut?: {
204
+ destChain: string;
205
+ recipient: string;
206
+ destToken: string;
207
+ minAmountOut: string;
208
+ destDecimals?: number;
209
+ slippageBps?: number;
210
+ };
193
211
  }
194
212
  /**
195
213
  * A bound wallet account, accepted opaquely: the write surface we need is
@@ -190,6 +190,24 @@ interface SerializedExecutionExpectation {
190
190
  token: string;
191
191
  maxAmount: string;
192
192
  };
193
+ /**
194
+ * Bridges: the destination chain (CAIP-2) and the reviewed recipient there. Value leaving the account may only go into
195
+ * the bridge (plus the provider's declared fee), and a bridge instruction whose recipient the account can read must
196
+ * name this one; an account that cannot prove where the funds go refuses the transaction. The engine also names the
197
+ * reviewed destination token (zero address = native), the quoted minimum in its base units with its decimals (the
198
+ * catalog's, else the caller's verified `toTokenDecimals`; never the quote's alone), and the slippage the plan allows, so a bridge
199
+ * order's own token and minimum can be held to them.
200
+ * Set on every cross-chain serialized leg. A Solana account maps the whole expectation with wallet-solana's
201
+ * `serializedGuardFromExecution`; a hand mapping that drops `bridgeOut` leaves the leg checked against the SOL ceiling only.
202
+ */
203
+ bridgeOut?: {
204
+ destChain: string;
205
+ recipient: string;
206
+ destToken: string;
207
+ minAmountOut: string;
208
+ destDecimals?: number;
209
+ slippageBps?: number;
210
+ };
193
211
  }
194
212
  /**
195
213
  * A bound wallet account, accepted opaquely: the write surface we need is
@@ -7,8 +7,9 @@ import {
7
7
  nativeUnits,
8
8
  normalizeTypedDomain,
9
9
  swap2ChainId
10
- } from "../chunk-PU5NM3Y6.js";
10
+ } from "../chunk-PN76KVPT.js";
11
11
  import "../chunk-IVR3SREQ.js";
12
+ import "../chunk-F5TRF52T.js";
12
13
  import "../chunk-2KC734UX.js";
13
14
  export {
14
15
  BaseSwap2Protocol,
@@ -81,6 +81,70 @@ function tonRawAddress(address) {
81
81
  return `${wc}:${Array.from(bytes.subarray(2, 34), (b) => b.toString(16).padStart(2, "0")).join("")}`;
82
82
  }
83
83
 
84
+ // src/validate.ts
85
+ var SLIPPAGE_ROUNDING_BPS = 1;
86
+ function slippageFloor(dest, slippageBps) {
87
+ const keep = 1e4 - slippageBps - SLIPPAGE_ROUNDING_BPS;
88
+ return keep <= 0 ? 0n : dest * BigInt(keep) / 10000n;
89
+ }
90
+ function validQuote(q, params, provider) {
91
+ try {
92
+ if (q.provider !== provider || q.sourceAmount !== params.amountIn) return false;
93
+ if (typeof q.intentId !== "string" || !q.intentId || typeof q.depositAddress !== "string" || q.depositMemo !== null && typeof q.depositMemo !== "string") return false;
94
+ if (![q.sourceAmount, q.destAmount, q.minDestAmount].every((value) => /^[0-9]+$/.test(value))) return false;
95
+ const source = BigInt(q.sourceAmount);
96
+ const dest = BigInt(q.destAmount);
97
+ const min = BigInt(q.minDestAmount);
98
+ if (source <= 0n || dest <= 0n || min <= 0n || min > dest) return false;
99
+ if (params.minAmountOut !== void 0) {
100
+ if (!/^[0-9]+$/.test(params.minAmountOut) || BigInt(params.minAmountOut) <= 0n || min < BigInt(params.minAmountOut)) return false;
101
+ }
102
+ if (params.slippageBps !== void 0) {
103
+ if (!Number.isInteger(params.slippageBps) || params.slippageBps < 0 || params.slippageBps >= 1e4) return false;
104
+ if (min < slippageFloor(dest, params.slippageBps)) return false;
105
+ }
106
+ if (!Number.isFinite(q.etaSeconds) || q.etaSeconds < 0) return false;
107
+ if (!Number.isFinite(q.reliability) || q.reliability < 0 || q.reliability > 1) return false;
108
+ if (!Number.isFinite(q.feeUsd) || q.feeUsd < 0) return false;
109
+ if (q.fees && (!Array.isArray(q.fees) || !q.fees.every(
110
+ (fee) => ["network", "protocol", "affiliate", "other"].includes(fee.type) && /^[0-9]+$/.test(fee.amount) && typeof fee.token === "string" && fee.token.length > 0 && typeof fee.included === "boolean" && (fee.description === void 0 || typeof fee.description === "string")
111
+ ))) return false;
112
+ if (q.providerData && (Array.isArray(q.providerData) || typeof q.providerData !== "object" || !Object.values(q.providerData).every((value) => typeof value === "string"))) return false;
113
+ const tx = q.txRequest;
114
+ if (tx) {
115
+ if (tx.value !== void 0 && !/^[0-9]+$/.test(tx.value)) return false;
116
+ if (tx.gasLimit !== void 0 && !/^[0-9]+$/.test(tx.gasLimit)) return false;
117
+ if (tx.chainId !== void 0 && (!Number.isSafeInteger(tx.chainId) || tx.chainId <= 0)) return false;
118
+ if ([tx.to, tx.data, tx.approvalAddress, tx.serializedTx].some((value) => value !== void 0 && typeof value !== "string")) return false;
119
+ }
120
+ if (!Number.isFinite(q.validUntilMs) || q.validUntilMs <= Date.now()) return false;
121
+ return true;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+ function remotePinRefusal(provider, q, chain) {
127
+ if (provider.remote !== true) return null;
128
+ const tx = q.txRequest;
129
+ const plain = tx ? tx.data === void 0 && tx.serializedTx === void 0 : typeof q.depositAddress === "string" && q.depositAddress !== "";
130
+ if (plain && provider.depositAddressRoutes !== true) {
131
+ return `${provider.name}: the route pays ${tx?.to || q.depositAddress}, an address the backend named, and the app did not allow deposit-address routes for this provider (remoteProvider depositAddressRoutes)`;
132
+ }
133
+ if (!chain.startsWith("eip155:")) return null;
134
+ if (!tx || tx.serializedTx !== void 0) return null;
135
+ const pinned = (list, address) => (list ?? []).some((a) => typeof a === "string" && a.toLowerCase() === address.toLowerCase());
136
+ if (tx.approvalAddress && !pinned(provider.spenders?.(chain), tx.approvalAddress)) {
137
+ return `${provider.name}: the route approves ${tx.approvalAddress}, which the app did not pin as a spender on ${chain} (remoteProvider spenders)`;
138
+ }
139
+ if (tx.data !== void 0 && tx.to && !pinned(provider.callTargets?.(chain), tx.to)) {
140
+ return `${provider.name}: the route calls ${tx.to}, which the app did not pin as a call target on ${chain} (remoteProvider callTargets)`;
141
+ }
142
+ if (tx.data !== void 0 && provider.verifiesQuotes !== true && provider.verifiesBuilds !== true) {
143
+ return `${provider.name}: the route's calldata is not checked by the app (remoteProvider verifyQuote / verifyBuild) \u2014 a pinned target alone does not say whom it pays`;
144
+ }
145
+ return null;
146
+ }
147
+
84
148
  // src/errors.ts
85
149
  var Swap2Error = class extends Error {
86
150
  constructor(message, code, options) {
@@ -310,16 +374,18 @@ function reviewedIdentity(plan) {
310
374
  return JSON.stringify(pinned, (_k, v) => typeof v === "bigint" ? v.toString() : v);
311
375
  }
312
376
  var SWAP2_ID_PREFIX = "awk1";
313
- function encodeId(provider, intentId, sourceTxHash, destTxHash) {
377
+ var CONFIRMED_SEGMENT = "ok";
378
+ function encodeId(provider, intentId, sourceTxHash, destTxHash, destTxConfirmed = false) {
314
379
  const enc = (v) => encodeURIComponent(v).replace(/\./g, "%2E");
315
- const parts = [provider, intentId, ...sourceTxHash != null ? [sourceTxHash, ...destTxHash != null ? [destTxHash] : []] : []];
380
+ const dest = destTxHash != null ? [destTxHash, ...destTxConfirmed ? [CONFIRMED_SEGMENT] : []] : [];
381
+ const parts = [provider, intentId, ...sourceTxHash != null ? [sourceTxHash, ...dest] : []];
316
382
  return `${SWAP2_ID_PREFIX}.${parts.map(enc).join(".")}`;
317
383
  }
318
384
  function decodeId(id) {
319
385
  try {
320
386
  if (!id.startsWith(`${SWAP2_ID_PREFIX}.`)) throw new Error("unsupported id version");
321
387
  const parts = id.slice(SWAP2_ID_PREFIX.length + 1).split(".");
322
- if (parts.length < 2 || parts.length > 4 || !parts[0] || !parts[1]) throw new Error("invalid id shape");
388
+ if (parts.length < 2 || parts.length > 5 || !parts[0] || !parts[1]) throw new Error("invalid id shape");
323
389
  const provider = decodeURIComponent(parts[0]);
324
390
  const intentId = decodeURIComponent(parts[1]);
325
391
  const sourceTxHash = parts[2] ? decodeURIComponent(parts[2]) : void 0;
@@ -327,7 +393,8 @@ function decodeId(id) {
327
393
  if (!/^[A-Za-z0-9_-]+$/.test(provider) || !intentId || parts[2] !== void 0 && !sourceTxHash || parts[3] !== void 0 && !destTxHash) {
328
394
  throw new Error("invalid id contents");
329
395
  }
330
- return { provider, intentId, ...sourceTxHash ? { sourceTxHash } : {}, ...destTxHash ? { destTxHash } : {} };
396
+ if (parts[4] !== void 0 && parts[4] !== CONFIRMED_SEGMENT) throw new Error("invalid id contents");
397
+ return { provider, intentId, ...sourceTxHash ? { sourceTxHash } : {}, ...destTxHash ? { destTxHash } : {}, ...parts[4] !== void 0 ? { destTxConfirmed: true } : {} };
331
398
  } catch {
332
399
  throw new Swap2ValidationError("swap2: malformed swap2 status id");
333
400
  }
@@ -460,13 +527,30 @@ function assertQuoteConstraints(q, options) {
460
527
  }
461
528
  if (options.slippage !== void 0) {
462
529
  const scale = 1000000000n;
463
- const permitted = BigInt(floorScaled(options.slippage, 1e9));
464
- const required = nominal * (scale - permitted) / scale;
530
+ const permitted = BigInt(floorScaled(options.slippage, 1e9)) + BigInt(SLIPPAGE_ROUNDING_BPS) * 100000n;
531
+ const required = permitted >= scale ? 0n : nominal * (scale - permitted) / scale;
465
532
  if (guaranteed < required) {
466
533
  throw new Error(`swap2: quote exceeds caller slippage (${options.slippage})`);
467
534
  }
468
535
  }
469
536
  }
537
+ function bridgeOutOf(params, q, toTokenDecimals) {
538
+ let decimals = (0, import_swap2_assets2.resolveAsset)(params.destChain, params.destToken)?.decimals;
539
+ if (decimals === void 0 && toTokenDecimals !== void 0) {
540
+ if (q.destDecimals !== void 0 && q.destDecimals !== toTokenDecimals) {
541
+ throw new Swap2ValidationError(`swap2: the quote's destDecimals ${q.destDecimals} do not match toTokenDecimals ${toTokenDecimals}`);
542
+ }
543
+ decimals = toTokenDecimals;
544
+ }
545
+ return {
546
+ destChain: params.destChain,
547
+ recipient: params.destAddress,
548
+ destToken: params.destToken,
549
+ minAmountOut: q.minDestAmount,
550
+ ...decimals !== void 0 ? { destDecimals: decimals } : {},
551
+ ...params.slippageBps !== void 0 ? { slippageBps: params.slippageBps } : {}
552
+ };
553
+ }
470
554
  function requireExecutionFloor(options) {
471
555
  if (options.minAmountOut === void 0) {
472
556
  throw new Error("swap2: swap2() requires caller-supplied minAmountOut");
@@ -565,6 +649,10 @@ var BaseSwap2Protocol = class extends import_core.Swap2Protocol {
565
649
  }
566
650
  const destChain = toCaip2(options.toChain, this.fromChain);
567
651
  const constraints = callerConstraints(options);
652
+ const toDecimals = options.toTokenDecimals;
653
+ if (toDecimals !== void 0 && (!Number.isInteger(toDecimals) || toDecimals < 0 || toDecimals > 36)) {
654
+ throw new Swap2ValidationError("swap2: toTokenDecimals must be whole decimals in 0..36");
655
+ }
568
656
  const amountIn = exactPositiveAmount(options.fromTokenAmount, "fromTokenAmount");
569
657
  const source = this.sourceAsset(options);
570
658
  const params = {
@@ -1094,7 +1182,8 @@ var BaseSwap2Protocol = class extends import_core.Swap2Protocol {
1094
1182
  maxValueOut: ((source.contract ? 0n : BigInt(q.sourceAmount)) + allowance).toString(),
1095
1183
  // Only a same-chain swap lands in this account; a bridge delivers elsewhere.
1096
1184
  ...params.destChain === this.fromChain ? { tokenIn: { token: params.destToken, minAmount: q.minDestAmount } } : {},
1097
- ...source.contract ? { source: { token: source.contract, maxAmount: q.sourceAmount } } : {}
1185
+ ...source.contract ? { source: { token: source.contract, maxAmount: q.sourceAmount } } : {},
1186
+ ...params.destChain !== this.fromChain ? { bridgeOut: bridgeOutOf(params, q, executionOptions.toTokenDecimals) } : {}
1098
1187
  };
1099
1188
  sent = await signer.sendSerializedTransaction(unsigned.serializedTx, expect);
1100
1189
  } else if (tokenId) {
@@ -1178,10 +1267,10 @@ var BaseSwap2Protocol = class extends import_core.Swap2Protocol {
1178
1267
  }
1179
1268
  /** Status of a swap by the id `swap2()` returned, asked from the provider that executed it. */
1180
1269
  async getSwap2Status(id, _options) {
1181
- const { provider: name, intentId, sourceTxHash, destTxHash } = decodeId(id);
1270
+ const { provider: name, intentId, sourceTxHash, destTxHash, destTxConfirmed } = decodeId(id);
1182
1271
  const provider = this.providerFor(name);
1183
1272
  if (!provider) throw new Error(`swap2: no swap2 found for id '${id}'`);
1184
- return mapStatus(await provider.trackIntent({ intentId, ...sourceTxHash != null ? { sourceTxHash } : {}, ...destTxHash != null ? { destTxHash } : {} }));
1273
+ return mapStatus(await provider.trackIntent({ intentId, ...sourceTxHash != null ? { sourceTxHash } : {}, ...destTxHash != null ? { destTxHash, ...destTxConfirmed ? { destTxConfirmed } : {} } : {} }));
1185
1274
  }
1186
1275
  /**
1187
1276
  * Two-phase routes (CCTP-style burn-and-mint): once `getSwap2Status` reports
@@ -1254,7 +1343,21 @@ var BaseSwap2Protocol = class extends import_core.Swap2Protocol {
1254
1343
  });
1255
1344
  if (!sent.hash) throw new Swap2ExecutionError("swap2: account did not return a claim transaction hash");
1256
1345
  this.emitPhase("broadcast", { hash: sent.hash });
1257
- return { hash: sent.hash, chain: unsigned.chain, id: encodeId(name, intentId, sourceTxHash, sent.hash) };
1346
+ let confirmed = false;
1347
+ if (typeof signer.waitForTransaction === "function") {
1348
+ try {
1349
+ await signer.waitForTransaction(sent.hash);
1350
+ confirmed = true;
1351
+ } catch (e) {
1352
+ const err = e;
1353
+ const timedOut = /timeout|timed out/i.test(String(err?.name ?? "")) || /timed out|timeout/i.test(String(err?.message ?? ""));
1354
+ const reverted = err?.code === "reverted" || !timedOut && /\breverted\b/i.test(String(err?.message ?? ""));
1355
+ if (reverted) {
1356
+ throw new Swap2ExecutionError(`swap2: the claim transaction ${sent.hash} on ${unsigned.chain} reverted \u2014 poll the status of '${id}' and claim again if it is still action-required`, { cause: e });
1357
+ }
1358
+ }
1359
+ }
1360
+ return { hash: sent.hash, chain: unsigned.chain, id: encodeId(name, intentId, sourceTxHash, sent.hash, confirmed) };
1258
1361
  }
1259
1362
  /** The inherited bridge() has no floor field: quote first, then bind execution
1260
1363
  * to that quote's minimum so the inherited convenience API stays safe. */
@@ -1331,7 +1434,10 @@ function structuredCloneSafe(value) {
1331
1434
  if (value === null || typeof value !== "object") return value;
1332
1435
  if (Array.isArray(value)) return value.map((v) => structuredCloneSafe(v));
1333
1436
  const out = {};
1334
- for (const [k, v] of Object.entries(value)) out[k] = structuredCloneSafe(v);
1437
+ for (const [k, v] of Object.entries(value)) {
1438
+ if (k === "__proto__") Object.defineProperty(out, k, { value: structuredCloneSafe(v), writable: true, enumerable: true, configurable: true });
1439
+ else out[k] = structuredCloneSafe(v);
1440
+ }
1335
1441
  return out;
1336
1442
  }
1337
1443
  function deepFreeze(value) {
@@ -1342,66 +1448,6 @@ function deepFreeze(value) {
1342
1448
  return value;
1343
1449
  }
1344
1450
 
1345
- // src/validate.ts
1346
- function validQuote(q, params, provider) {
1347
- try {
1348
- if (q.provider !== provider || q.sourceAmount !== params.amountIn) return false;
1349
- if (typeof q.intentId !== "string" || !q.intentId || typeof q.depositAddress !== "string" || q.depositMemo !== null && typeof q.depositMemo !== "string") return false;
1350
- if (![q.sourceAmount, q.destAmount, q.minDestAmount].every((value) => /^[0-9]+$/.test(value))) return false;
1351
- const source = BigInt(q.sourceAmount);
1352
- const dest = BigInt(q.destAmount);
1353
- const min = BigInt(q.minDestAmount);
1354
- if (source <= 0n || dest <= 0n || min <= 0n || min > dest) return false;
1355
- if (params.minAmountOut !== void 0) {
1356
- if (!/^[0-9]+$/.test(params.minAmountOut) || BigInt(params.minAmountOut) <= 0n || min < BigInt(params.minAmountOut)) return false;
1357
- }
1358
- if (params.slippageBps !== void 0) {
1359
- if (!Number.isInteger(params.slippageBps) || params.slippageBps < 0 || params.slippageBps >= 1e4) return false;
1360
- const required = dest * BigInt(1e4 - params.slippageBps) / 10000n;
1361
- if (min < required) return false;
1362
- }
1363
- if (!Number.isFinite(q.etaSeconds) || q.etaSeconds < 0) return false;
1364
- if (!Number.isFinite(q.reliability) || q.reliability < 0 || q.reliability > 1) return false;
1365
- if (!Number.isFinite(q.feeUsd) || q.feeUsd < 0) return false;
1366
- if (q.fees && (!Array.isArray(q.fees) || !q.fees.every(
1367
- (fee) => ["network", "protocol", "affiliate", "other"].includes(fee.type) && /^[0-9]+$/.test(fee.amount) && typeof fee.token === "string" && fee.token.length > 0 && typeof fee.included === "boolean" && (fee.description === void 0 || typeof fee.description === "string")
1368
- ))) return false;
1369
- if (q.providerData && (Array.isArray(q.providerData) || typeof q.providerData !== "object" || !Object.values(q.providerData).every((value) => typeof value === "string"))) return false;
1370
- const tx = q.txRequest;
1371
- if (tx) {
1372
- if (tx.value !== void 0 && !/^[0-9]+$/.test(tx.value)) return false;
1373
- if (tx.gasLimit !== void 0 && !/^[0-9]+$/.test(tx.gasLimit)) return false;
1374
- if (tx.chainId !== void 0 && (!Number.isSafeInteger(tx.chainId) || tx.chainId <= 0)) return false;
1375
- if ([tx.to, tx.data, tx.approvalAddress, tx.serializedTx].some((value) => value !== void 0 && typeof value !== "string")) return false;
1376
- }
1377
- if (!Number.isFinite(q.validUntilMs) || q.validUntilMs <= Date.now()) return false;
1378
- return true;
1379
- } catch {
1380
- return false;
1381
- }
1382
- }
1383
- function remotePinRefusal(provider, q, chain) {
1384
- if (provider.remote !== true) return null;
1385
- const tx = q.txRequest;
1386
- const plain = tx ? tx.data === void 0 && tx.serializedTx === void 0 : typeof q.depositAddress === "string" && q.depositAddress !== "";
1387
- if (plain && provider.depositAddressRoutes !== true) {
1388
- return `${provider.name}: the route pays ${tx?.to || q.depositAddress}, an address the backend named, and the app did not allow deposit-address routes for this provider (remoteProvider depositAddressRoutes)`;
1389
- }
1390
- if (!chain.startsWith("eip155:")) return null;
1391
- if (!tx || tx.serializedTx !== void 0) return null;
1392
- const pinned = (list, address) => (list ?? []).some((a) => typeof a === "string" && a.toLowerCase() === address.toLowerCase());
1393
- if (tx.approvalAddress && !pinned(provider.spenders?.(chain), tx.approvalAddress)) {
1394
- return `${provider.name}: the route approves ${tx.approvalAddress}, which the app did not pin as a spender on ${chain} (remoteProvider spenders)`;
1395
- }
1396
- if (tx.data !== void 0 && tx.to && !pinned(provider.callTargets?.(chain), tx.to)) {
1397
- return `${provider.name}: the route calls ${tx.to}, which the app did not pin as a call target on ${chain} (remoteProvider callTargets)`;
1398
- }
1399
- if (tx.data !== void 0 && provider.verifiesQuotes !== true && provider.verifiesBuilds !== true) {
1400
- return `${provider.name}: the route's calldata is not checked by the app (remoteProvider verifyQuote / verifyBuild) \u2014 a pinned target alone does not say whom it pays`;
1401
- }
1402
- return null;
1403
- }
1404
-
1405
1451
  // src/swap2/single.ts
1406
1452
  var SingleProviderSwap2 = class extends BaseSwap2Protocol {
1407
1453
  /** Why the last resolveQuote came back empty — the provider's own reason, like the router's. */