@rabby-wallet/rabby-bridge 0.0.13-alpha.1 → 0.0.14-alpha.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 (47) hide show
  1. package/README.md +25 -21
  2. package/dist/calldata/chains.js +3 -2
  3. package/dist/calldata/destination.js +18 -2
  4. package/dist/calldata/destinationSwap.js +6 -3
  5. package/dist/calldata/destinationUniswap.d.ts +7 -0
  6. package/dist/calldata/destinationUniswap.js +57 -0
  7. package/dist/calldata/expect.d.ts +3 -1
  8. package/dist/calldata/expect.js +9 -10
  9. package/dist/calldata/lifi/across/acrossV4Erc20Min.js +1 -0
  10. package/dist/calldata/lifi/across/acrossV4NativeMin.js +1 -0
  11. package/dist/calldata/lifi/params.js +2 -0
  12. package/dist/calldata/protocols.js +2 -1
  13. package/dist/calldata/relay/calls.d.ts +1 -1
  14. package/dist/calldata/relay/calls.js +5 -1
  15. package/dist/calldata/relay/index.js +5 -2
  16. package/dist/calldata/relay/multicall.js +1 -1
  17. package/dist/calldata/relay/nestedDeposit.d.ts +8 -0
  18. package/dist/calldata/relay/nestedDeposit.js +107 -0
  19. package/dist/calldata/relay/permit2TransferAndMulticall.js +1 -1
  20. package/dist/calldata/relay/permit3009TransferAndMulticall.js +1 -1
  21. package/dist/calldata/relay/permit3009TransferAndMulticallSigned.js +1 -1
  22. package/dist/calldata/relay/permitTransferAndMulticall.js +1 -1
  23. package/dist/calldata/relay/permitTransferAndMulticallLegacy.js +1 -1
  24. package/dist/calldata/relay/transferAndMulticall.js +1 -1
  25. package/dist/calldata/socket/addresses.d.ts +9 -0
  26. package/dist/calldata/socket/addresses.js +9 -0
  27. package/dist/calldata/socket/protocols/cctp.js +4 -3
  28. package/dist/calldata/socket/protocols/eco.d.ts +1 -1
  29. package/dist/calldata/socket/protocols/eco.js +32 -3
  30. package/dist/calldata/socket/protocols/index.js +4 -3
  31. package/dist/calldata/socket/protocols/native.js +1 -0
  32. package/dist/calldata/types.d.ts +4 -0
  33. package/dist/chains.d.ts +2 -1
  34. package/dist/chains.js +2 -0
  35. package/dist/data.d.ts +3 -0
  36. package/dist/data.js +3 -0
  37. package/dist/errors/constants.d.ts +15 -0
  38. package/dist/errors/constants.js +15 -0
  39. package/dist/errors/index.d.ts +3 -4
  40. package/dist/errors/index.js +1 -1
  41. package/dist/errors/sanitize.js +1 -1
  42. package/dist/providers/relay.d.ts +3 -0
  43. package/dist/providers/relay.js +52 -0
  44. package/dist/providers/socket.js +2 -1
  45. package/dist/quote.d.ts +10 -5
  46. package/dist/quote.js +61 -24
  47. package/package.json +1 -1
package/README.md CHANGED
@@ -20,10 +20,10 @@ See [reachable route discovery](tests/fixtures/route-discovery-20261104/README.m
20
20
 
21
21
  ## Validation rollout
22
22
 
23
- The first rollout enforces Router/Spender allowlists and observes local calldata
24
- validation. No provider API or RPC requests are made. `buildTx` and
25
- `getQuoteListWithTx` keep the original backend transaction when source, destination
26
- or receiver checks fail or cannot be completed; `onError` receives those observations.
23
+ Router/Spender allowlists and required calldata checks are always enforced.
24
+ Reported calldata failures block: `validateBridgeTx` and `buildTx` throw
25
+ `InvalidBridgeCalldataError`, and `getQuoteListWithTx` filters the failed quote.
26
+ No provider API or RPC requests are made.
27
27
 
28
28
  ```ts
29
29
  const quotes = await getQuoteListWithTx(aggregatorId, {
@@ -33,7 +33,7 @@ const quotes = await getQuoteListWithTx(aggregatorId, {
33
33
  ```
34
34
 
35
35
  - Quotes and transactions come from the existing backend flow, including Relay
36
- and Socket, in both modes. The SDK no longer accepts `providerClient` or exposes
36
+ and Socket. The SDK no longer accepts `providerClient` or exposes
37
37
  a direct-provider client/fetch adapter.
38
38
  - Router/Spender mismatches, unsupported source chains/aggregators,
39
39
  missing required slippage, missing backend results and backend request failures
@@ -42,7 +42,7 @@ const quotes = await getQuoteListWithTx(aggregatorId, {
42
42
  checked locally. Missing source fields and mismatches are reported.
43
43
  - Socket quote IDs (including CCTP), RFQ outer/vault IDs and Relay multicall
44
44
  request IDs are compared locally when a selected quote provides a matching
45
- identifier. Mismatches are reported without blocking or changing field flags;
45
+ identifier. Mismatches block without changing the three field flags;
46
46
  missing/unrecognized keys are skipped. See the [binding limits](src/calldata/README.md).
47
47
  - Known destination tokens, minimum amounts and chains are compared. Unknown
48
48
  destination paths remain unverified and are reported with available method,
@@ -50,7 +50,7 @@ const quotes = await getQuoteListWithTx(aggregatorId, {
50
50
  - Locally derivable final receivers are compared. External order data is skipped
51
51
  without a provider request; a skipped receiver is never counted as verified.
52
52
  - Parse failures, known mismatches and unexpected missing fields are reported with
53
- `blocking: false`, including when `lite` is omitted or false.
53
+ `pass: false, blocking: true`.
54
54
  - Relay and Socket RFQ/CCTP external destination fields are skipped rather than
55
55
  fetched. LayerSwap uses its local receiver; no background order lookup runs.
56
56
  Skipping an external receiver does not claim order-hash or provider verification.
@@ -61,16 +61,15 @@ const quotes = await getQuoteListWithTx(aggregatorId, {
61
61
  Actual mismatches retain their existing error codes. Repeated failures are not
62
62
  deduplicated, sampled or rate-limited by the SDK.
63
63
 
64
- `validateBridgeQuote` also accepts `{ lite, onError }` as its fourth argument;
65
- `validateBridgeTx` accepts it after the optional expectation as its sixth argument.
66
- `lite` retains its legacy missing-spender behavior; it no longer selects strict
67
- calldata enforcement and never skips a missing Router allowlist.
64
+ `validateBridgeTx` accepts `{ onError }` after the optional expectation as its sixth
65
+ argument. A missing Router allowlist or an approval address without a configured
66
+ spender is rejected.
68
67
 
69
68
  `validateBridgeTx` now returns `BridgeValidationResult` instead of `true`:
70
69
 
71
70
  ```ts
72
71
  const result = validateBridgeTx(aggregatorId, chainId, bridgeId, tx, expectation);
73
- // result.pass === result.routerPass
72
+ // Throws on required-check failures; otherwise result.pass is true.
74
73
  // result.fromPass / result.toPass / result.receiverPass
75
74
  // result.unverifiedFields / result.errors
76
75
  ```
@@ -79,12 +78,13 @@ The three field flags are true only when all required local comparisons for that
79
78
  group succeeded. False means a failure or an incomplete check; `errors` and
80
79
  `unverifiedFields` distinguish them. Known skipped fields can leave `errors` empty
81
80
  while a field flag is false. Without an expectation, field flags are false.
82
- They do not contribute to `pass` in this rollout. Router rejection still throws
81
+ Validation rejects reportable failures, not every false field flag: known skipped
82
+ fields remain allowed. Router rejection still throws
83
83
  `InvalidBridgeContractError`; its sanitized detail carries `pass: false`,
84
84
  `routerPass: false` and `blocking: true`.
85
85
 
86
- Raw decode/calldata-check results retain `ok: false` on failure. Observation
87
- details include all five pass flags, path diagnostics, `lite` and `blocking: false`.
86
+ Raw decode/calldata-check results retain `ok: false` on failure. Failure
87
+ details include all five pass flags, path diagnostics and `blocking`.
88
88
  These are failure-event/path counts, not distinct user counts or a denominator for
89
89
  all successful transactions. See error reporting below for failures that still throw.
90
90
 
@@ -103,7 +103,9 @@ legacy `reason` fields remain available.
103
103
  - Thrown SDK errors extend `BridgeError`. Use `error.toJSON()` for a report.
104
104
  - `onError` receives failures filtered from `getQuote` / `getQuoteListWithTx`,
105
105
  plus local validation failures and unexpected missing fields from transaction checks.
106
- Directly thrown errors are handled by the caller, avoiding duplicate reporting.
106
+ Calldata failures are reported once before throwing; do not report those
107
+ errors again in a caller's catch when an `onError` reporter is supplied.
108
+ Other directly thrown errors are handled by the caller.
107
109
  Reporting callback failures do not affect validation or quote filtering.
108
110
 
109
111
  ```ts
@@ -138,17 +140,19 @@ reports merely because a known local limitation prevents verification.
138
140
 
139
141
  Use `toBridgeErrorReport(resultOrError)` to convert a calldata check,
140
142
  transaction validation result, detailed decode result or exception into a report.
141
- A transaction with `pass: true` can still produce an observation report when its
142
- `errors` are nonempty. Results without reportable failures return `undefined`,
143
+ Results without reportable failures return `undefined`,
143
144
  including known skipped destination fields.
144
145
  The `onError` callback and `BridgeError.toJSON()` use the same
145
146
  runtime allowlist. Do not upload the entire check, exception, parsed transaction,
146
147
  request parameters or quote response.
147
148
 
148
149
  ```ts
149
- const check = validateBridgeTx(aggregatorId, chainId, bridgeId, tx, expectation);
150
- const report = toBridgeErrorReport(check);
151
- if (report) reportToBusiness(report);
150
+ try {
151
+ validateBridgeTx(aggregatorId, chainId, bridgeId, tx, expectation);
152
+ } catch (error) {
153
+ reportToBusiness(toBridgeErrorReport(error));
154
+ throw error;
155
+ }
152
156
  ```
153
157
 
154
158
  Reports can retain recognized aggregator/bridge/method labels, source/destination
@@ -23,11 +23,12 @@ export const evmChainId = (chain) => {
23
23
  return CHAIN_IDS[chainEnum ? CHAIN_ENUM_TO_SERVER_ID[chainEnum] : chain.toLowerCase()];
24
24
  };
25
25
  export const sourceChainMismatch = (expected, actual) => {
26
- if (actual === undefined)
26
+ const expectedId = evmChainId(expected);
27
+ if (actual === undefined || expectedId === undefined)
27
28
  return false;
28
29
  try {
29
30
  return (typeof actual === 'number' && !Number.isSafeInteger(actual)) ||
30
- evmChainId(expected) === undefined || BigInt(actual) !== evmChainId(expected);
31
+ BigInt(actual) !== expectedId;
31
32
  }
32
33
  catch {
33
34
  return true;
@@ -4,6 +4,7 @@ import { isZeroWord, readSelector } from './hex.js';
4
4
  import { NATIVE_TOKEN, WRAPPED_NATIVE_TOKENS, subtractFees } from './protocols.js';
5
5
  import { calldataDiagnostics } from './diagnostics.js';
6
6
  import { ALLOWANCE_HOLDER, HYPEREVM_LIFI_ROUTER, readLifiSwap, readSettlerSwap } from './destinationSwap.js';
7
+ import { AVALANCHE_UNIVERSAL_ROUTER, readUniversalRouterSwap } from './destinationUniswap.js';
7
8
  export const destinationCall = {
8
9
  name: 'call', kind: 'tuple', components: [
9
10
  { name: 'target', kind: 'address' }, { name: 'data', kind: 'bytes' }, { name: 'value', kind: 'uint' },
@@ -47,6 +48,7 @@ const ACROSS_MESSAGE_HANDLERS = {
47
48
  143: '0xec41f75c686e376ab2a4f18bde263ab5822c4511',
48
49
  999: '0x5e7840e06faccb6d1c3b5f5e0d1d3d07f2829bba',
49
50
  4663: '0xa8ad2e87e2043711d8bec77e8bc3e2683c0ab6bd',
51
+ 9745: '0x5e7840e06faccb6d1c3b5f5e0d1d3d07f2829bba',
50
52
  43114: '0x9610954acdca5ff7905f051a040ce33fe613c60e',
51
53
  };
52
54
  const readAcrossMessage = (parsed, message) => {
@@ -88,8 +90,8 @@ const readAcrossMessage = (parsed, message) => {
88
90
  const lifiRouter = parsed.destinationChainId === 999n ? HYPEREVM_LIFI_ROUTER : undefined;
89
91
  const possibleRefundTokens = new Set();
90
92
  const refundReceivers = [];
91
- for (const raw of calls) {
92
- const call = asRecord(raw);
93
+ for (let index = 0; index < calls.length; index++) {
94
+ const call = asRecord(calls[index]);
93
95
  const target = asString(call.target);
94
96
  const data = asString(call.callData);
95
97
  const value = asBigint(call.value);
@@ -151,6 +153,20 @@ const readAcrossMessage = (parsed, message) => {
151
153
  const drain = decodeCall([{ name: 'token', kind: 'address' }, { name: 'receiver', kind: 'address' }], data);
152
154
  const token = asString(drain.token);
153
155
  const amount = balances.get(token) ?? 0n;
156
+ if (parsed.destinationChainId === 43114n && asString(drain.receiver) === AVALANCHE_UNIVERSAL_ROUTER) {
157
+ if (index + 1 >= calls.length)
158
+ return parsed;
159
+ const next = asRecord(calls[index + 1]);
160
+ if (asString(next.target) !== AVALANCHE_UNIVERSAL_ROUTER || asBigint(next.value) !== 0n)
161
+ return parsed;
162
+ const swap = readUniversalRouterSwap(asString(next.callData), handler);
163
+ if (!swap || swap.inputToken !== token || swap.inputAmount !== amount)
164
+ return parsed;
165
+ balances.set(token, 0n);
166
+ balances.set(swap.outputToken, (balances.get(swap.outputToken) ?? 0n) + swap.minOutputAmount);
167
+ index++;
168
+ continue;
169
+ }
154
170
  if (possibleRefundTokens.delete(token))
155
171
  refundReceivers.push(asString(drain.receiver));
156
172
  if (amount > 0n)
@@ -5,7 +5,9 @@ import { methods as lifiSwaps } from './lifi/swap/index.js';
5
5
  export const ALLOWANCE_HOLDER = '0x0000000000001ff3684f28c67538d4d072c22734';
6
6
  // Reviewed deployments, not dynamic registry lookups. Evidence and renewal limits:
7
7
  // tests/fixtures/route-discovery-20261104/evidence/base-settler-verification.json
8
+ // tests/fixtures/reported-20261111-evidence.json (Ethereum)
8
9
  const SETTLERS = {
10
+ 1: '0xaddc26a3854feabfd20d6bf658254088d83a425a',
9
11
  8453: '0x7d6839415693801e94cdc4a7d732c9a3fdbbd1d7',
10
12
  999: '0xf58d4122251f2ace689f257eaf2688883e30fc6e',
11
13
  };
@@ -52,7 +54,8 @@ export const readSettlerSwap = (data, chain, recipient, balances) => {
52
54
  // These reviewed actions cannot mutate/zeroize slippage. CHECK_SLIPPAGE, SELECT
53
55
  // and unreviewed actions must not bypass the final enforced transfer to Handler.
54
56
  if (!actions.length || readSelector(actions[0]) !== '0xc1fb425e' ||
55
- actions.slice(1).some((action) => !['0x38c9c147', '0x8d68a156', '0x5f80abae'].includes(readSelector(action) ?? '')))
57
+ actions.slice(1).some((action) => !['0x38c9c147', '0x8d68a156', '0x5f80abae'].includes(readSelector(action) ?? '') &&
58
+ !(chain === 1n && readSelector(action) === '0xf61460f9')))
56
59
  return undefined;
57
60
  const funding = decodeCall([
58
61
  { name: 'recipient', kind: 'address' }, { name: 'token', kind: 'address' },
@@ -64,8 +67,8 @@ export const readSettlerSwap = (data, chain, recipient, balances) => {
64
67
  return undefined;
65
68
  let inputAmount = asBigint(args.amount);
66
69
  const maxUint256 = (1n << 256n) - 1n;
67
- // shortcut: only HyperEVM's reviewed 100%-balance encoding; review other ratios before support.
68
- if (chain === 999n && inputAmount > maxUint256 - 1000000n) {
70
+ // shortcut: only reviewed Ethereum/HyperEVM 100%-balance encoding; review other ratios before support.
71
+ if ((chain === 1n || chain === 999n) && inputAmount > maxUint256 - 1000000n) {
69
72
  if (inputAmount !== maxUint256)
70
73
  return undefined;
71
74
  inputAmount = balances?.get(asString(args.token)) ?? 0n;
@@ -0,0 +1,7 @@
1
+ export declare const AVALANCHE_UNIVERSAL_ROUTER = "0x661e93cca42afacb172121ef892830ca3b70f08d";
2
+ export declare const readUniversalRouterSwap: (data: string, recipient: string) => {
3
+ inputToken: string;
4
+ inputAmount: bigint;
5
+ outputToken: string;
6
+ minOutputAmount: bigint;
7
+ } | undefined;
@@ -0,0 +1,57 @@
1
+ import { asArray, asBigint, asRecord, asString, decodeCall, decodeParameters } from './abi.js';
2
+ import { readSelector } from './hex.js';
3
+ import { isNative, NATIVE_TOKEN } from './protocols.js';
4
+ // Reviewed UniversalRouter v2.1.2; see reported-20261111-evidence.json.
5
+ export const AVALANCHE_UNIVERSAL_ROUTER = '0x661e93cca42afacb172121ef892830ca3b70f08d';
6
+ export const readUniversalRouterSwap = (data, recipient) => {
7
+ if (readSelector(data) !== '0x24856bc3')
8
+ return undefined;
9
+ const execution = decodeCall([
10
+ { name: 'commands', kind: 'bytes' },
11
+ { name: 'inputs', kind: 'array', item: { name: 'input', kind: 'bytes' } },
12
+ ], data);
13
+ const inputs = asArray(execution.inputs);
14
+ // No optional/reverting commands or unrelated transfers may bypass the payout.
15
+ if (asString(execution.commands) !== '0x10' || inputs.length !== 1)
16
+ return undefined;
17
+ const plan = decodeParameters([
18
+ { name: 'actions', kind: 'bytes' },
19
+ { name: 'params', kind: 'array', item: { name: 'param', kind: 'bytes' } },
20
+ ], asString(inputs[0]));
21
+ const params = asArray(plan.params).map(asString);
22
+ if (asString(plan.actions) !== '0x070b0e' || params.length !== 3)
23
+ return undefined;
24
+ const swap = asRecord(decodeParameters([{ name: 'swap', kind: 'tuple', components: [
25
+ { name: 'currencyIn', kind: 'address' },
26
+ { name: 'path', kind: 'array', item: { name: 'hop', kind: 'tuple', components: [
27
+ { name: 'intermediateCurrency', kind: 'address' }, { name: 'fee', kind: 'uint' },
28
+ { name: 'tickSpacing', kind: 'uint' }, { name: 'hooks', kind: 'address' }, { name: 'hookData', kind: 'bytes' },
29
+ ] } },
30
+ { name: 'minHopPriceX36', kind: 'array', item: { name: 'price', kind: 'uint' } },
31
+ { name: 'amountIn', kind: 'uint' }, { name: 'amountOutMinimum', kind: 'uint' },
32
+ ] }], params[0]).swap);
33
+ const path = asArray(swap.path).map(asRecord);
34
+ const prices = asArray(swap.minHopPriceX36);
35
+ // shortcut: one ERC20 hop without hooks; review additional V4 plans before support.
36
+ if (path.length !== 1 || prices.length > 1)
37
+ return undefined;
38
+ const hop = path[0];
39
+ const inputToken = asString(swap.currencyIn), outputToken = asString(hop.intermediateCurrency);
40
+ const inputAmount = asBigint(swap.amountIn), minOutputAmount = asBigint(swap.amountOutMinimum);
41
+ if (isNative(inputToken) || isNative(outputToken) || inputToken === outputToken ||
42
+ asString(hop.hooks) !== NATIVE_TOKEN || asString(hop.hookData) !== '0x' ||
43
+ asBigint(hop.fee) > 1000000n || asBigint(hop.tickSpacing) <= 0n || asBigint(hop.tickSpacing) > 32767n ||
44
+ inputAmount <= 0n || inputAmount >= (1n << 128n) || minOutputAmount <= 0n || minOutputAmount >= (1n << 128n))
45
+ return undefined;
46
+ const settle = decodeParameters([
47
+ { name: 'currency', kind: 'address' }, { name: 'amount', kind: 'uint' }, { name: 'payerIsUser', kind: 'bool' },
48
+ ], params[1]);
49
+ const take = decodeParameters([
50
+ { name: 'currency', kind: 'address' }, { name: 'recipient', kind: 'address' }, { name: 'amount', kind: 'uint' },
51
+ ], params[2]);
52
+ // OPEN_DELTA (0) settles the swap's debt from Router and takes all output back to Handler.
53
+ if (asString(settle.currency) !== inputToken || asBigint(settle.amount) !== 0n || settle.payerIsUser !== false ||
54
+ asString(take.currency) !== outputToken || asString(take.recipient) !== recipient || asBigint(take.amount) !== 0n)
55
+ return undefined;
56
+ return { inputToken, inputAmount, outputToken, minOutputAmount };
57
+ };
@@ -10,6 +10,8 @@ export type BridgeCallExpectation = {
10
10
  toTokenRawAmount?: string;
11
11
  /** Selected backend quote key; only protocol identifiers with known semantics are compared. */
12
12
  quoteKey?: string | Record<string, unknown>;
13
+ /** Relay `protocol.v2.orderData` captured with the transaction being signed. */
14
+ orderData?: unknown;
13
15
  /** 小数滑点。0.01 表示 1%。 */
14
16
  slippage?: string | number;
15
17
  };
@@ -21,7 +23,7 @@ export type CompareMask = {
21
23
  receiver: boolean;
22
24
  };
23
25
  export declare const knownCompare: (parsed: ParsedBridgeCall) => CompareMask;
24
- /** Actual source payment may exceed local intent by at most 0.1%; lower payments fail comparison. */
26
+ /** Source payment is capped at 100.1% of local intent; smaller payments are allowed. */
25
27
  export declare const sourceAmountMatches: (actual: bigint | undefined, expected: bigint) => boolean;
26
28
  export declare const expectationFailures: (parsed: ParsedBridgeCall, expectation: BridgeCallExpectation, chainId: string, mask: CompareMask) => BridgeErrorDefinition[];
27
29
  export declare const finishCheck: (aggregatorId: string, parsed: ParsedBridgeCall, structural: BridgeErrorDefinition | undefined, expectation: BridgeCallExpectation | undefined, chainId: string, mask: CompareMask) => BridgeCheck;
@@ -55,9 +55,9 @@ const selectedQuoteIdentifier = (quoteKey) => {
55
55
  const match = /^(0x[0-9a-f]{64})(?::[^:]+)?$/i.exec(key.quote_id);
56
56
  return match?.[1].toLowerCase();
57
57
  };
58
- /** Actual source payment may exceed local intent by at most 0.1%; lower payments fail comparison. */
59
- export const sourceAmountMatches = (actual, expected) => actual !== undefined && expected >= 0n && actual >= expected && (actual - expected) * 1000n <= expected;
60
- /** Allow a 5% relative difference from the quote's slippage-adjusted minimum. */
58
+ /** Source payment is capped at 100.1% of local intent; smaller payments are allowed. */
59
+ export const sourceAmountMatches = (actual, expected) => actual !== undefined && actual >= 0n && expected >= 0n && actual * 1000n <= expected * 1001n;
60
+ /** Allow up to 5% below the slippage-adjusted minimum; higher guarantees always pass. */
61
61
  const minReceiveMatches = (minReceive, quoted, slippage) => {
62
62
  if (minReceive <= 0n || quoted <= 0n || slippage < 0 || slippage >= 1) {
63
63
  return false;
@@ -67,11 +67,7 @@ const minReceiveMatches = (minReceive, quoted, slippage) => {
67
67
  if (minimum <= 0n) {
68
68
  return false;
69
69
  }
70
- if (minReceive === minimum) {
71
- return true;
72
- }
73
- const difference = minReceive > minimum ? minReceive - minimum : minimum - minReceive;
74
- return difference * 20n <= minimum;
70
+ return minReceive * 20n >= minimum * 19n;
75
71
  };
76
72
  export const expectationFailures = (parsed, expectation, chainId, mask) => {
77
73
  const reasons = [];
@@ -116,9 +112,12 @@ export const expectationFailures = (parsed, expectation, chainId, mask) => {
116
112
  reasons.push(BRIDGE_ERRORS.MIN_RECEIVE_MISMATCH);
117
113
  }
118
114
  }
119
- if (mask.receiver && !sameToken(parsed.receiver, expectation.userAddress, chainId)) {
115
+ if (mask.receiver && !parsed.fixedReceiver && !sameToken(parsed.receiver, expectation.userAddress, chainId)) {
120
116
  reasons.push(BRIDGE_ERRORS.RECEIVER_MISMATCH);
121
117
  }
118
+ if (parsed.refundRecipients?.some((recipient) => recipient.toLowerCase() !== expectation.userAddress.toLowerCase())) {
119
+ reasons.push(BRIDGE_ERRORS.REFUND_RECIPIENT_MISMATCH);
120
+ }
122
121
  return reasons;
123
122
  };
124
123
  export const finishCheck = (aggregatorId, parsed, structural, expectation, chainId, mask) => {
@@ -148,7 +147,7 @@ export const finishCheck = (aggregatorId, parsed, structural, expectation, chain
148
147
  minOutputAmount: mask.toAmount && expectation?.toTokenRawAmount !== undefined,
149
148
  receiver: mask.receiver,
150
149
  }).filter(([, checked]) => !expectation || !checked).map(([field]) => field);
151
- if (expectation?.toChainId && (parsed.destinationChainId === undefined || evmChainId(expectation.toChainId) === undefined)) {
150
+ if (expectation?.toChainId && parsed.destinationChainId === undefined) {
152
151
  unverifiedFields.push('destinationChainId');
153
152
  }
154
153
  if (errors.length) {
@@ -19,6 +19,7 @@ export const startBridgeTokensViaAcrossV4ERC20Min = {
19
19
  sourceAmount: asBigint(args.inputAmount),
20
20
  destinationChainId: asBigint(parameters.destinationChainId),
21
21
  receiver: accountFromBytes32(asString(parameters.receiver)),
22
+ refundRecipients: [accountFromBytes32(asString(parameters.depositor))],
22
23
  outputToken: accountFromBytes32(asString(parameters.receivingAssetId)),
23
24
  minOutputAmount: asBigint(parameters.outputAmount),
24
25
  };
@@ -15,6 +15,7 @@ export const startBridgeTokensViaAcrossV4NativeMin = {
15
15
  sourceAmount: value,
16
16
  destinationChainId: asBigint(parameters.destinationChainId),
17
17
  receiver: accountFromBytes32(asString(parameters.receiver)),
18
+ refundRecipients: [accountFromBytes32(asString(parameters.depositor))],
18
19
  outputToken: accountFromBytes32(asString(parameters.receivingAssetId)),
19
20
  minOutputAmount: asBigint(parameters.outputAmount),
20
21
  };
@@ -164,6 +164,7 @@ export const applyBridgeData = (method, raw) => {
164
164
  };
165
165
  export const applyAcrossData = (parsed, raw) => {
166
166
  const data = asRecord(raw);
167
+ parsed.refundRecipients = [accountFromBytes32(asString(data.refundAddress))];
167
168
  parsed.receiver = accountFromBytes32(asString(data.receiverAddress));
168
169
  parsed.outputToken = accountFromBytes32(asString(data.receivingAssetId));
169
170
  parsed.minOutputAmount = asBigint(data.outputAmount);
@@ -258,6 +259,7 @@ export const applyPolymerData = (parsed, raw) => {
258
259
  };
259
260
  export const applyLifiIntentData = (parsed, raw) => {
260
261
  const data = asRecord(raw);
262
+ parsed.refundRecipients = [asString(data.depositAndRefundAddress)];
261
263
  parsed.receiver = accountFromBytes32(asString(data.recipient));
262
264
  parsed.outputToken = accountFromBytes32(asString(data.outputToken));
263
265
  parsed.minOutputAmount = (parsed.bridgeAmount ?? 0n) * asBigint(data.outputAmountMultiplier) / 10n ** 18n;
@@ -16,7 +16,7 @@ export const isNative = (token) => token === NATIVE_TOKEN || token.toLowerCase()
16
16
  // Circle CCTP domains and Wormhole IDs are different namespaces from EVM chain IDs.
17
17
  export const CCTP_CHAINS = {
18
18
  0: 1n, 1: 43114n, 2: 10n, 3: 42161n, 6: 8453n, 7: 137n,
19
- 10: 130n, 11: 59144n, 13: 146n, 15: 143n, 19: 999n,
19
+ 10: 130n, 11: 59144n, 13: 146n, 15: 143n, 19: 999n, 21: 57073n,
20
20
  };
21
21
  export const WORMHOLE_CHAINS = {
22
22
  2: 1n, 4: 56n, 5: 137n, 6: 43114n, 23: 42161n, 24: 10n,
@@ -48,6 +48,7 @@ export const CCTP_USDC = {
48
48
  146: '0x29219dd400f2bf60e5a23d13be72b486d4038894',
49
49
  999: '0xb88339cb7199b77e23db6e890353e22632ba630f',
50
50
  59144: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff',
51
+ 57073: '0x2d270e6886d130d724215a266106e6832161eaed',
51
52
  };
52
53
  export const subtractFees = (amount, ...fees) => {
53
54
  const total = fees.reduce((sum, fee) => sum + fee, 0n);
@@ -3,7 +3,7 @@ import type { ParsedBridgeCall } from '../types.js';
3
3
  export declare const callTuple: AbiParam;
4
4
  export declare const callsParam: AbiParam;
5
5
  export declare const requestIdFromMetadata: (metadata: string) => string | undefined;
6
- export declare const relayCall: (method: string, metadata: string, value: bigint, source?: {
6
+ export declare const relayCall: (method: string, metadata: string, value: bigint, calls: AbiValue, source?: {
7
7
  token?: string;
8
8
  amount?: bigint;
9
9
  }) => ParsedBridgeCall;
@@ -2,6 +2,7 @@ import { BRIDGE_ERRORS, BridgeError } from '../../errors/index.js';
2
2
  import { asBigint, asRecord, asString } from '../abi.js';
3
3
  import { isZeroWord } from '../hex.js';
4
4
  import { NATIVE_TOKEN } from '../protocols.js';
5
+ import { nestedRelayOrder } from './nestedDeposit.js';
5
6
  export const callTuple = {
6
7
  name: 'call',
7
8
  kind: 'tuple',
@@ -25,13 +26,16 @@ export const requestIdFromMetadata = (metadata) => {
25
26
  const requestId = `0x${body.slice(0, 64)}`;
26
27
  return isZeroWord(requestId) ? undefined : requestId;
27
28
  };
28
- export const relayCall = (method, metadata, value, source) => ({
29
+ export const relayCall = (method, metadata, value, calls, source) => ({
29
30
  aggregatorId: 'relay',
30
31
  method,
31
32
  kind: 'deposit',
32
33
  requestId: requestIdFromMetadata(metadata),
33
34
  sourceToken: source?.token ?? (value > 0n ? NATIVE_TOKEN : undefined),
34
35
  sourceAmount: source?.amount && source.amount > 0n ? source.amount : value,
36
+ ...nestedRelayOrder(calls, method === 'transferAndMulticall' && value === 0n && source?.token && source.amount !== undefined
37
+ ? { token: source.token, amount: source.amount }
38
+ : method === 'multicall' && value > 0n ? { token: NATIVE_TOKEN, amount: value } : undefined),
35
39
  });
36
40
  const amountOf = (record) => {
37
41
  const amount = record.amount ?? record.value;
@@ -10,6 +10,7 @@ import { permit3009TransferAndMulticallSigned } from './permit3009TransferAndMul
10
10
  import { permitTransferAndMulticall } from './permitTransferAndMulticall.js';
11
11
  import { permitTransferAndMulticallLegacy } from './permitTransferAndMulticallLegacy.js';
12
12
  import { transferAndMulticall } from './transferAndMulticall.js';
13
+ import { getBridgeRouter } from '../../list.js';
13
14
  import { relayFailure } from './validate.js';
14
15
  import { BRIDGE_ERRORS } from '../../errors/index.js';
15
16
  const METHODS = [
@@ -37,6 +38,8 @@ export const validateRelay = (data, value, expectation, chainId = '') => {
37
38
  if (!hit.ok) {
38
39
  return { ...hit, aggregatorId: 'relay' };
39
40
  }
40
- return finishCheck('relay', hit.parsed, expectation && hit.parsed.depositor !== undefined && hit.parsed.depositor !== expectation.userAddress.toLowerCase()
41
- ? BRIDGE_ERRORS.DEPOSITOR_MISMATCH : relayFailure(hit.parsed), expectation, chainId, relayCompareMask(hit.parsed));
41
+ return finishCheck('relay', hit.parsed, hit.parsed.innerTarget && chainId && hit.parsed.innerTarget !== getBridgeRouter('relay', chainId)
42
+ ? BRIDGE_ERRORS.RELAY_CANONICAL_DEPOSITORY_MISMATCH
43
+ : expectation && hit.parsed.depositor !== undefined && hit.parsed.depositor !== expectation.userAddress.toLowerCase()
44
+ ? BRIDGE_ERRORS.DEPOSITOR_MISMATCH : relayFailure(hit.parsed), expectation, chainId, relayCompareMask(hit.parsed));
42
45
  };
@@ -10,6 +10,6 @@ export const multicall = {
10
10
  { name: 'nftRecipient', kind: 'address' },
11
11
  { name: 'metadata', kind: 'bytes' },
12
12
  ], data);
13
- return relayCall('multicall', asString(args.metadata), value);
13
+ return relayCall('multicall', asString(args.metadata), value, args.calls);
14
14
  },
15
15
  };
@@ -0,0 +1,8 @@
1
+ import { type AbiValue } from '../abi.js';
2
+ import type { ParsedBridgeCall } from '../types.js';
3
+ export declare const relayFundingRouter: (method: string, target: string) => string | undefined;
4
+ /** Read one final deposit from actual calls, never from metadata or signatures. */
5
+ export declare const nestedRelayOrder: (rawCalls: AbiValue, funding?: {
6
+ token: string;
7
+ amount: bigint;
8
+ }) => Pick<ParsedBridgeCall, "orderId" | "depositor" | "innerTarget" | "bridgeToken" | "bridgeAmount" | "bridgeReceiver"> | undefined;
@@ -0,0 +1,107 @@
1
+ import { depositErc20 } from './depositErc20.js';
2
+ import { depositErc20Allowance } from './depositErc20Allowance.js';
3
+ import { depositNative } from './depositNative.js';
4
+ import { asArray, asBigint, asRecord, asString, decodeCall } from '../abi.js';
5
+ import { BRIDGE_ROUTER_WHITELIST } from '../../list.js';
6
+ import { isZeroWord, readSelector } from '../hex.js';
7
+ import { NATIVE_TOKEN } from '../protocols.js';
8
+ const DEPOSITORIES = new Set(Object.values(BRIDGE_ROUTER_WHITELIST.relay));
9
+ // shortcut: reviewed v3.2 cleanup wrappers only; review new deployments before adding them.
10
+ const PROXY_ROUTERS = {
11
+ '0x5567954cd026cebfe486062cb75848e0f1c12e58': '0x0729e2a4716d9f7dd17858d5d504426d228697f7',
12
+ '0xe4dfbd5e6784db1058124e2c87554c0591f0fb7b': '0x6328ea02d28131e58b28e55c6258258a30d90e05',
13
+ };
14
+ const ROUTERS = new Set(Object.values(PROXY_ROUTERS));
15
+ export const relayFundingRouter = (method, target) => method === 'transferAndMulticall' ? PROXY_ROUTERS[target]
16
+ : method === 'multicall' && ROUTERS.has(target) ? target : undefined;
17
+ const CLEANUP_NATIVE = '0x5d1fe6a2';
18
+ const CLEANUP_ERC20 = '0x73b7bb2f';
19
+ /** Read one final deposit from actual calls, never from metadata or signatures. */
20
+ export const nestedRelayOrder = (rawCalls, funding) => {
21
+ try {
22
+ const calls = asArray(rawCalls).map(asRecord);
23
+ if (!calls.length)
24
+ return undefined;
25
+ // Multiple deposit/cleanup calls cannot be bound to a single backend order.
26
+ if (calls.slice(0, -1).some((call) => DEPOSITORIES.has(asString(call.target)) ||
27
+ (ROUTERS.has(asString(call.target)) && [CLEANUP_NATIVE, CLEANUP_ERC20].includes(readSelector(asString(call.callData)) ?? ''))))
28
+ return undefined;
29
+ const last = calls[calls.length - 1];
30
+ if (last.allowFailure !== false)
31
+ return undefined;
32
+ let target = asString(last.target), data = asString(last.callData);
33
+ const wrapper = ROUTERS.has(target) ? target : undefined;
34
+ let value = asBigint(last.value);
35
+ let token;
36
+ let nativeCleanup = false;
37
+ let cleanupAmount;
38
+ if (ROUTERS.has(target)) {
39
+ if (value !== 0n)
40
+ return undefined;
41
+ if (readSelector(data) === CLEANUP_NATIVE) {
42
+ const args = decodeCall([{ name: 'amount', kind: 'uint' }, { name: 'to', kind: 'address' }, { name: 'data', kind: 'bytes' }], data);
43
+ target = asString(args.to);
44
+ data = asString(args.data);
45
+ value = asBigint(args.amount);
46
+ cleanupAmount = value;
47
+ nativeCleanup = true;
48
+ }
49
+ else if (readSelector(data) === CLEANUP_ERC20) {
50
+ const args = decodeCall([
51
+ { name: 'tokens', kind: 'array', item: { name: 'token', kind: 'address' } },
52
+ { name: 'tos', kind: 'array', item: { name: 'to', kind: 'address' } },
53
+ { name: 'datas', kind: 'array', item: { name: 'data', kind: 'bytes' } },
54
+ { name: 'amounts', kind: 'array', item: { name: 'amount', kind: 'uint' } },
55
+ ], data);
56
+ if (Object.values(args).some((items) => asArray(items).length !== 1))
57
+ return undefined;
58
+ target = asString(asArray(args.tos)[0]);
59
+ data = asString(asArray(args.datas)[0]);
60
+ token = asString(asArray(args.tokens)[0]);
61
+ cleanupAmount = asBigint(asArray(args.amounts)[0]);
62
+ }
63
+ else
64
+ return undefined;
65
+ }
66
+ if (!DEPOSITORIES.has(target))
67
+ return undefined;
68
+ const method = [depositNative, depositErc20, depositErc20Allowance].find((item) => item.selector === readSelector(data));
69
+ if (!method || (nativeCleanup && method !== depositNative) || (token && method === depositNative))
70
+ return undefined;
71
+ const parsed = method.decode(data, value);
72
+ if (isZeroWord(parsed.orderId) || (token && token !== parsed.sourceToken))
73
+ return undefined;
74
+ const result = { orderId: parsed.orderId, depositor: parsed.depositor, innerTarget: target };
75
+ if (!funding || funding.amount <= 0n || funding.token !== parsed.sourceToken)
76
+ return result;
77
+ // shortcut: only one approval before deposit; add funding analysis before supporting swaps/other calls.
78
+ const prefix = calls.slice(0, -1);
79
+ if (prefix.length > 1)
80
+ return result;
81
+ let approved;
82
+ if (prefix.length) {
83
+ const call = prefix[0];
84
+ if (funding.token === NATIVE_TOKEN || call.allowFailure !== false ||
85
+ asString(call.target) !== funding.token || asBigint(call.value) !== 0n ||
86
+ readSelector(asString(call.callData)) !== '0x095ea7b3')
87
+ return result;
88
+ const args = decodeCall([{ name: 'spender', kind: 'address' }, { name: 'amount', kind: 'uint' }], asString(call.callData));
89
+ if (asString(args.spender) !== target)
90
+ return result;
91
+ approved = asBigint(args.amount);
92
+ }
93
+ // Zero cleanup means all remaining balance; here the full input is still on Router.
94
+ const amount = cleanupAmount === 0n ? funding.amount : cleanupAmount;
95
+ const depositAmount = method === depositErc20Allowance ? amount ?? approved
96
+ : nativeCleanup ? amount : parsed.sourceAmount;
97
+ if (depositAmount !== funding.amount ||
98
+ (cleanupAmount !== undefined && amount !== funding.amount) ||
99
+ (method !== depositNative && value !== 0n))
100
+ return result;
101
+ return { ...result, bridgeToken: funding.token, bridgeAmount: funding.amount, bridgeReceiver: wrapper };
102
+ }
103
+ catch {
104
+ // Unknown or malformed calls leave the order unresolved for the signing check.
105
+ return undefined;
106
+ }
107
+ };
@@ -35,7 +35,7 @@ export const permit2TransferAndMulticall = {
35
35
  ], data);
36
36
  const source = firstPositive(asArray(asRecord(args.permit).permitted));
37
37
  return {
38
- ...relayCall('permit2TransferAndMulticall', asString(args.metadata), value, source),
38
+ ...relayCall('permit2TransferAndMulticall', asString(args.metadata), value, args.calls, source),
39
39
  depositor: asString(args.user),
40
40
  };
41
41
  },
@@ -32,7 +32,7 @@ const decodePermits = (name, selector, tail) => ({
32
32
  ], data);
33
33
  const source = firstPositive(asArray(args.permits));
34
34
  const tokens = asArray(args.tokens).map((token) => asString(token));
35
- return relayCall(name, asString(args.metadata), value, {
35
+ return relayCall(name, asString(args.metadata), value, args.calls, {
36
36
  token: tokens[0] ?? source.token,
37
37
  amount: source.amount,
38
38
  });
@@ -32,7 +32,7 @@ export const permit3009TransferAndMulticallSigned = {
32
32
  ], data);
33
33
  const source = firstPositive(asArray(args.permits));
34
34
  const tokens = asArray(args.tokens).map((token) => asString(token));
35
- return relayCall('permit3009TransferAndMulticallSigned', asString(args.metadata), value, {
35
+ return relayCall('permit3009TransferAndMulticallSigned', asString(args.metadata), value, args.calls, {
36
36
  token: tokens[0] ?? source.token,
37
37
  amount: source.amount,
38
38
  });
@@ -29,6 +29,6 @@ export const permitTransferAndMulticall = {
29
29
  { name: 'metadata', kind: 'bytes' },
30
30
  ], data);
31
31
  const source = firstPositive(asArray(args.permits));
32
- return relayCall('permitTransferAndMulticall', asString(args.metadata), value, source);
32
+ return relayCall('permitTransferAndMulticall', asString(args.metadata), value, args.calls, source);
33
33
  },
34
34
  };
@@ -30,6 +30,6 @@ export const permitTransferAndMulticallLegacy = {
30
30
  { name: 'metadata', kind: 'bytes' },
31
31
  ], data);
32
32
  const source = firstPositive(asArray(args.permits));
33
- return relayCall('permitTransferAndMulticallLegacy', asString(args.metadata), value, source);
33
+ return relayCall('permitTransferAndMulticallLegacy', asString(args.metadata), value, args.calls, source);
34
34
  },
35
35
  };