@rabby-wallet/rabby-bridge 0.0.13-alpha.0 → 0.0.13-alpha.1

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 (41) hide show
  1. package/README.md +67 -39
  2. package/dist/calldata/across/validate.js +8 -4
  3. package/dist/calldata/destination.js +71 -8
  4. package/dist/calldata/destinationSwap.d.ts +14 -0
  5. package/dist/calldata/destinationSwap.js +80 -0
  6. package/dist/calldata/expect.d.ts +3 -1
  7. package/dist/calldata/expect.js +35 -6
  8. package/dist/calldata/index.js +8 -5
  9. package/dist/calldata/lifi/destination.d.ts +5 -0
  10. package/dist/calldata/lifi/destination.js +40 -0
  11. package/dist/calldata/lifi/index.js +3 -1
  12. package/dist/calldata/lifi/params.d.ts +1 -3
  13. package/dist/calldata/lifi/params.js +13 -36
  14. package/dist/calldata/lifi/swap/swapMethod.js +1 -1
  15. package/dist/calldata/lifi/swapData.d.ts +5 -0
  16. package/dist/calldata/lifi/swapData.js +33 -0
  17. package/dist/calldata/mayan/fastMctpBridge.js +6 -1
  18. package/dist/calldata/protocols.js +1 -0
  19. package/dist/calldata/relay/index.js +3 -1
  20. package/dist/calldata/socket/bridge.js +1 -1
  21. package/dist/calldata/socket/exec.js +1 -1
  22. package/dist/calldata/socket/protocols/index.js +10 -5
  23. package/dist/calldata/socket/protocols/oft.js +3 -1
  24. package/dist/calldata/socket/swapAndBridge.js +6 -2
  25. package/dist/calldata/socket/validate.js +3 -0
  26. package/dist/errors/constants.d.ts +15 -0
  27. package/dist/errors/constants.js +15 -0
  28. package/dist/errors/index.d.ts +7 -2
  29. package/dist/errors/index.js +1 -1
  30. package/dist/errors/sanitize.js +5 -6
  31. package/dist/index.d.ts +2 -3
  32. package/dist/index.js +1 -1
  33. package/dist/quote.d.ts +14 -6
  34. package/dist/quote.js +88 -172
  35. package/package.json +1 -1
  36. package/dist/providers/index.d.ts +0 -7
  37. package/dist/providers/index.js +0 -340
  38. package/dist/providers/request.d.ts +0 -23
  39. package/dist/providers/request.js +0 -69
  40. package/dist/providers/types.d.ts +0 -36
  41. package/dist/providers/types.js +0 -1
package/README.md CHANGED
@@ -7,11 +7,10 @@ Minimal bridge SDK for Rabby:
7
7
  - validates backend bridge quotes before returning them
8
8
  - decodes source transactions for Across, LI.FI, Relay, Socket and Mayan
9
9
  - keeps chain-state-dependent output fields explicitly unverified, without RPC requests
10
- - validates Relay orders, three Socket routes and LI.FI LayerSwap through direct provider requests
10
+ - performs local validation only; no direct-provider HTTP requests or RPC reads
11
11
 
12
12
  See [calldata parsing](src/calldata/README.md) for supported paths, integration and verification boundaries.
13
- See [direct provider integration](src/providers/README.md) for the shared extension/mobile
14
- transport interface, quote binding, application-fee constraints and signing checks.
13
+ See [offline provider evidence](src/providers/README.md) for retained historical fixtures and verification limits.
15
14
 
16
15
  ## Test
17
16
 
@@ -19,50 +18,75 @@ Run `yarn test` to build the package and execute the Jest suite.
19
18
 
20
19
  See [reachable route discovery](tests/fixtures/route-discovery-20261104/README.md) for the latest parsing changes, fixture coverage and remaining verification limits.
21
20
 
22
- ## Lite rollout
21
+ ## Validation rollout
23
22
 
24
- Pass `lite: true` to `getQuote`, `buildTx` or `getQuoteListWithTx` to observe new
25
- validation without letting it change the business decisions from `main`.
26
- The default is `false`, retaining strict behavior.
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.
27
27
 
28
28
  ```ts
29
29
  const quotes = await getQuoteListWithTx(aggregatorId, {
30
30
  ...params,
31
- lite: true,
32
31
  onError: (report) => reportToBusiness(report),
33
32
  }, api);
34
33
  ```
35
34
 
36
35
  - Quotes and transactions come from the existing backend flow, including Relay
37
- and Socket. Provider quotes never replace the selected backend quote in lite mode.
38
- - Configured Router/Spender mismatches, unsupported source chains/aggregators,
36
+ and Socket, in both modes. The SDK no longer accepts `providerClient` or exposes
37
+ a direct-provider client/fetch adapter.
38
+ - Router/Spender mismatches, unsupported source chains/aggregators,
39
39
  missing required slippage, missing backend results and backend request failures
40
40
  keep their original blocking behavior.
41
- - Calldata parsing, intent comparison, missing-field checks, stricter missing
42
- contract configuration and provider verification still run against the returned
43
- transaction. Their failures are reported without discarding that quote/transaction.
44
- - No parser is reported as unsupported, including aggregators that strict mode
45
- does not currently parse. It is never reported as a successful parse.
46
- - Relay/Socket verification uses available proof/cache for the selected transaction.
47
- If that proof or client is missing, the original missing-proof error is reported;
48
- dependent hash/order checks cannot complete. An unrelated fresh provider quote
49
- is not fetched or used as evidence for the backend transaction.
50
- - LayerSwap lookup runs in the background in lite mode. Its result is reported
51
- through `onError` and does not delay returning the backend transaction.
41
+ - Source token, source amount, supplied sender/chain and protocol structure are
42
+ checked locally. Missing source fields and mismatches are reported.
43
+ - Socket quote IDs (including CCTP), RFQ outer/vault IDs and Relay multicall
44
+ request IDs are compared locally when a selected quote provides a matching
45
+ identifier. Mismatches are reported without blocking or changing field flags;
46
+ missing/unrecognized keys are skipped. See the [binding limits](src/calldata/README.md).
47
+ - Known destination tokens, minimum amounts and chains are compared. Unknown
48
+ destination paths remain unverified and are reported with available method,
49
+ selector and nested-call diagnostics.
50
+ - Locally derivable final receivers are compared. External order data is skipped
51
+ without a provider request; a skipped receiver is never counted as verified.
52
+ - Parse failures, known mismatches and unexpected missing fields are reported with
53
+ `blocking: false`, including when `lite` is omitted or false.
54
+ - Relay and Socket RFQ/CCTP external destination fields are skipped rather than
55
+ fetched. LayerSwap uses its local receiver; no background order lookup runs.
56
+ Skipping an external receiver does not claim order-hash or provider verification.
57
+ - Known unresolved output token/minimum fields and Relay/Socket external
58
+ receiver/chain fields stay in `unverifiedFields` without producing reports.
59
+ Missing source fields, unknown receivers/paths and a missing expected amount
60
+ for a locally available minimum use `BRIDGE_UNVERIFIED_BRIDGE_FIELDS`.
61
+ Actual mismatches retain their existing error codes. Repeated failures are not
62
+ deduplicated, sampled or rate-limited by the SDK.
52
63
 
53
64
  `validateBridgeQuote` also accepts `{ lite, onError }` as its fourth argument;
54
65
  `validateBridgeTx` accepts it after the optional expectation as its sixth argument.
55
- Raw decode/calldata-check results remain truthful (`ok: false` on failure).
56
- Direct provider-client methods remain strict; use the high-level entry points for
57
- lite rollout decisions.
58
-
59
- Error codes and details are preserved. Lite observations carry `lite: true` and
60
- `blocking: false`; legacy failures carry `blocking: true` when reported by the
61
- high-level flow. Keep the callback installed while background checks are pending.
62
- Dependent checks still stop at their first unusable prerequisite, and independent
63
- stages may emit multiple reports. These are failure-event/path counts, not distinct
64
- user counts or a denominator for all successful transactions. No user identifiers
65
- are added. See error reporting below for catching failures that still throw.
66
+ `lite` retains its legacy missing-spender behavior; it no longer selects strict
67
+ calldata enforcement and never skips a missing Router allowlist.
68
+
69
+ `validateBridgeTx` now returns `BridgeValidationResult` instead of `true`:
70
+
71
+ ```ts
72
+ const result = validateBridgeTx(aggregatorId, chainId, bridgeId, tx, expectation);
73
+ // result.pass === result.routerPass
74
+ // result.fromPass / result.toPass / result.receiverPass
75
+ // result.unverifiedFields / result.errors
76
+ ```
77
+
78
+ The three field flags are true only when all required local comparisons for that
79
+ group succeeded. False means a failure or an incomplete check; `errors` and
80
+ `unverifiedFields` distinguish them. Known skipped fields can leave `errors` empty
81
+ 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
83
+ `InvalidBridgeContractError`; its sanitized detail carries `pass: false`,
84
+ `routerPass: false` and `blocking: true`.
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`.
88
+ These are failure-event/path counts, not distinct user counts or a denominator for
89
+ all successful transactions. See error reporting below for failures that still throw.
66
90
 
67
91
  ## Error reporting
68
92
 
@@ -77,7 +101,8 @@ legacy `reason` fields remain available.
77
101
  Independent intent mismatches are collected together; decoding or dependent
78
102
  protocol operations stop at their first failure.
79
103
  - Thrown SDK errors extend `BridgeError`. Use `error.toJSON()` for a report.
80
- - `onError` receives failures filtered from `getQuote` / `getQuoteListWithTx`.
104
+ - `onError` receives failures filtered from `getQuote` / `getQuoteListWithTx`,
105
+ plus local validation failures and unexpected missing fields from transaction checks.
81
106
  Directly thrown errors are handled by the caller, avoiding duplicate reporting.
82
107
  Reporting callback failures do not affect validation or quote filtering.
83
108
 
@@ -106,19 +131,22 @@ retained for compatibility; prefer `toJSON()` for reporting.
106
131
  Stages distinguish input checks, contract allowlists, ABI decoding, protocol
107
132
  structure, intent comparison, provider requests, provider responses, transaction
108
133
  binding and completeness. Unexpected uncategorized failures use `BRIDGE_UNKNOWN`.
109
- Deferred chain-state output fields remain in `unverifiedFields`; they do not
110
- become errors merely because RPC validation is postponed.
134
+ Deferred chain-state output fields remain in `unverifiedFields` without producing
135
+ reports merely because a known local limitation prevents verification.
111
136
 
112
137
  ### Privacy-safe diagnostics
113
138
 
114
- Use `toBridgeErrorReport(resultOrError)` to convert a failed calldata check,
115
- detailed decode result or exception into a report. Passing a successful result
116
- returns `undefined`. The `onError` callback and `BridgeError.toJSON()` use the same
139
+ Use `toBridgeErrorReport(resultOrError)` to convert a calldata check,
140
+ 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
+ including known skipped destination fields.
144
+ The `onError` callback and `BridgeError.toJSON()` use the same
117
145
  runtime allowlist. Do not upload the entire check, exception, parsed transaction,
118
146
  request parameters or quote response.
119
147
 
120
148
  ```ts
121
- const check = validateBridgeCalldata(aggregatorId, chainId, tx, expectation);
149
+ const check = validateBridgeTx(aggregatorId, chainId, bridgeId, tx, expectation);
122
150
  const report = toBridgeErrorReport(check);
123
151
  if (report) reportToBusiness(report);
124
152
  ```
@@ -2,6 +2,12 @@ import { BRIDGE_ERRORS } from '../../errors/index.js';
2
2
  import { getBridgeRouter } from '../../list.js';
3
3
  import { evmChainId } from '../chains.js';
4
4
  import { isZeroWord } from '../hex.js';
5
+ // These chains use a Periphery as the outer router, separate from their canonical SpokePool.
6
+ const SPOKE_POOLS = {
7
+ 56: '0x4e8e101924ede233c13e2d8622dc8aed2872d505',
8
+ 324: '0xe0b015e54d54fc84a6cb9b666099c46ade9335ff',
9
+ 57073: '0xef684c38f94f48775959ecf2012d7e864ffb9dd4',
10
+ };
5
11
  export const acrossFailure = (parsed, chainId, expectedDepositor) => {
6
12
  if (expectedDepositor && parsed.depositor?.toLowerCase() !== expectedDepositor.toLowerCase()) {
7
13
  return BRIDGE_ERRORS.DEPOSITOR_MISMATCH;
@@ -19,10 +25,8 @@ export const acrossFailure = (parsed, chainId, expectedDepositor) => {
19
25
  return undefined;
20
26
  }
21
27
  const spoke = parsed.spokePool.toLowerCase();
22
- // BSC's configured outer router is a periphery, separate from its canonical SpokePool.
23
- const expectedSpoke = evmChainId(chainId) === 56n
24
- ? '0x4e8e101924ede233c13e2d8622dc8aed2872d505'
25
- : getBridgeRouter('across', chainId)?.toLowerCase();
28
+ const expectedSpoke = SPOKE_POOLS[evmChainId(chainId)?.toString() ?? ''] ??
29
+ getBridgeRouter('across', chainId)?.toLowerCase();
26
30
  if (expectedSpoke !== spoke) {
27
31
  return BRIDGE_ERRORS.SPOKE_POOL_IS_NOT_IN_THE_ALLOWLIST;
28
32
  }
@@ -3,6 +3,7 @@ import { asArray, asBigint, asRecord, asString, decodeCall, decodeParameters } f
3
3
  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
+ import { ALLOWANCE_HOLDER, HYPEREVM_LIFI_ROUTER, readLifiSwap, readSettlerSwap } from './destinationSwap.js';
6
7
  export const destinationCall = {
7
8
  name: 'call', kind: 'tuple', components: [
8
9
  { name: 'target', kind: 'address' }, { name: 'data', kind: 'bytes' }, { name: 'value', kind: 'uint' },
@@ -11,7 +12,7 @@ export const destinationCall = {
11
12
  export const readTransfer = (target, data, value) => {
12
13
  if (data === '0x' && value > 0n)
13
14
  return { receiver: target, outputToken: NATIVE_TOKEN, minOutputAmount: value };
14
- if (readSelector(data) !== '0xa9059cbb')
15
+ if (value !== 0n || readSelector(data) !== '0xa9059cbb')
15
16
  return undefined;
16
17
  const args = decodeCall([{ name: 'to', kind: 'address' }, { name: 'amount', kind: 'uint' }], data);
17
18
  return { receiver: asString(args.to), outputToken: target, minOutputAmount: asBigint(args.amount) };
@@ -27,8 +28,11 @@ export const readDestinationTransfers = (calls) => {
27
28
  if (!transfers.length || transfers.some((transfer) => !transfer))
28
29
  return { diagnostics };
29
30
  const first = transfers[0];
30
- if (!transfers.every((transfer) => transfer !== undefined && transfer.receiver === first.receiver && transfer.outputToken === first.outputToken))
31
+ if (!transfers.every((transfer) => transfer?.receiver === first.receiver))
31
32
  return { diagnostics };
33
+ // Different assets can have one recipient without having one comparable output amount.
34
+ if (!transfers.every((transfer) => transfer?.outputToken === first.outputToken))
35
+ return { receiver: first.receiver, diagnostics };
32
36
  return { ...first, diagnostics, minOutputAmount: transfers.reduce((sum, transfer) => sum + (transfer?.minOutputAmount ?? 0n), 0n) };
33
37
  };
34
38
  const instructions = {
@@ -40,6 +44,7 @@ const instructions = {
40
44
  ],
41
45
  };
42
46
  const ACROSS_MESSAGE_HANDLERS = {
47
+ 143: '0xec41f75c686e376ab2a4f18bde263ab5822c4511',
43
48
  999: '0x5e7840e06faccb6d1c3b5f5e0d1d3d07f2829bba',
44
49
  4663: '0xa8ad2e87e2043711d8bec77e8bc3e2683c0ab6bd',
45
50
  43114: '0x9610954acdca5ff7905f051a040ce33fe613c60e',
@@ -78,6 +83,11 @@ const readAcrossMessage = (parsed, message) => {
78
83
  .filter((selector) => Boolean(selector)))].slice(0, 16);
79
84
  const balances = new Map([[parsed.bridgeOutputToken, parsed.bridgeOutputAmount]]);
80
85
  const payouts = [];
86
+ const directTransfers = [];
87
+ const approvals = new Map();
88
+ const lifiRouter = parsed.destinationChainId === 999n ? HYPEREVM_LIFI_ROUTER : undefined;
89
+ const possibleRefundTokens = new Set();
90
+ const refundReceivers = [];
81
91
  for (const raw of calls) {
82
92
  const call = asRecord(raw);
83
93
  const target = asString(call.target);
@@ -87,6 +97,32 @@ const readAcrossMessage = (parsed, message) => {
87
97
  // emitData is telemetry; its amounts are not an enforced receive floor.
88
98
  if (selector === '0xd836083e' && target === emitter && value === 0n)
89
99
  continue;
100
+ if (selector === '0x095ea7b3' && value === 0n && balances.has(target)) {
101
+ const approval = decodeCall([{ name: 'spender', kind: 'address' }, { name: 'amount', kind: 'uint' }], data);
102
+ const amount = asBigint(approval.amount);
103
+ const spender = asString(approval.spender);
104
+ if ((spender !== ALLOWANCE_HOLDER && spender !== lifiRouter) ||
105
+ (amount > (balances.get(target) ?? 0n) && amount !== (1n << 256n) - 1n))
106
+ return parsed;
107
+ approvals.set(`${target}:${spender}`, amount);
108
+ continue;
109
+ }
110
+ if ((target === ALLOWANCE_HOLDER || target === lifiRouter) && value === 0n) {
111
+ const swap = target === ALLOWANCE_HOLDER
112
+ ? readSettlerSwap(data, parsed.destinationChainId, handler, balances)
113
+ : readLifiSwap(data, parsed.destinationChainId, target, handler);
114
+ const approvalKey = `${swap?.inputToken}:${target}`;
115
+ if (!swap || (approvals.get(approvalKey) ?? 0n) < swap.inputAmount ||
116
+ (balances.get(swap.inputToken) ?? 0n) < swap.inputAmount)
117
+ return parsed;
118
+ approvals.set(approvalKey, (approvals.get(approvalKey) ?? 0n) - swap.inputAmount);
119
+ balances.set(swap.inputToken, (balances.get(swap.inputToken) ?? 0n) - swap.inputAmount);
120
+ balances.set(swap.outputToken, (balances.get(swap.outputToken) ?? 0n) + swap.minOutputAmount);
121
+ // GenericSwapFacet returns unused input to Handler; its later payout must agree too.
122
+ if (target === lifiRouter)
123
+ possibleRefundTokens.add(swap.inputToken);
124
+ continue;
125
+ }
90
126
  if (selector === '0xc41e8295' && target === handler) {
91
127
  if (value !== 0n)
92
128
  return parsed;
@@ -115,6 +151,8 @@ const readAcrossMessage = (parsed, message) => {
115
151
  const drain = decodeCall([{ name: 'token', kind: 'address' }, { name: 'receiver', kind: 'address' }], data);
116
152
  const token = asString(drain.token);
117
153
  const amount = balances.get(token) ?? 0n;
154
+ if (possibleRefundTokens.delete(token))
155
+ refundReceivers.push(asString(drain.receiver));
118
156
  if (amount > 0n)
119
157
  payouts.push({ outputToken: token, minOutputAmount: amount, receiver: asString(drain.receiver) });
120
158
  balances.set(token, 0n);
@@ -125,16 +163,41 @@ const readAcrossMessage = (parsed, message) => {
125
163
  return parsed;
126
164
  const token = transfer.outputToken;
127
165
  balances.set(token, subtractFees(balances.get(token) ?? 0n, transfer.minOutputAmount ?? 0n));
166
+ directTransfers.push(transfer);
167
+ }
168
+ const fallback = asString(args.fallbackRecipient);
169
+ const hasDrain = payouts.length > 0;
170
+ // The Handler drains the original bridge asset to fallback even after successful calls.
171
+ const remaining = balances.get(parsed.bridgeOutputToken) ?? 0n;
172
+ if (!isZeroWord(fallback) && possibleRefundTokens.delete(parsed.bridgeOutputToken)) {
173
+ refundReceivers.push(fallback);
128
174
  }
129
- if (payouts.length === 1) {
130
- const fallback = asString(args.fallbackRecipient);
131
- if (!isZeroWord(fallback) && fallback !== payouts[0].receiver) {
175
+ if (!isZeroWord(fallback) && remaining > 0n) {
176
+ payouts.push({ receiver: fallback, outputToken: parsed.bridgeOutputToken, minOutputAmount: remaining });
177
+ balances.set(parsed.bridgeOutputToken, 0n);
178
+ }
179
+ // Existing drain routes may contain explicit fee transfers; without a drain every
180
+ // direct transfer must agree on the recipient, and no tracked funds may be stranded.
181
+ const delivered = hasDrain
182
+ ? [...payouts, ...directTransfers.filter((transfer) => transfer.receiver === payouts[0].receiver)]
183
+ : [...directTransfers, ...payouts];
184
+ if (delivered.length && !possibleRefundTokens.size && [...balances.values()].every((amount) => amount === 0n)) {
185
+ const receiver = delivered[0].receiver;
186
+ if (!delivered.every((payout) => payout.receiver === receiver) ||
187
+ !refundReceivers.every((recipient) => recipient === receiver))
188
+ return parsed;
189
+ if (!isZeroWord(fallback) && fallback !== receiver) {
132
190
  throw new BridgeError(BRIDGE_ERRORS.ACROSS_FALLBACK_RECEIVER_MISMATCH);
133
191
  }
134
- if (!isZeroWord(fallback) && parsed.bridgeOutputToken !== payouts[0].outputToken) {
135
- throw new BridgeError(BRIDGE_ERRORS.ACROSS_FALLBACK_TOKEN_MISMATCH);
192
+ parsed.receiver = receiver;
193
+ const token = delivered[0].outputToken;
194
+ if (delivered.every((payout) => payout.outputToken === token) &&
195
+ (isZeroWord(fallback) || token === parsed.bridgeOutputToken)) {
196
+ parsed.outputToken = token;
197
+ parsed.minOutputAmount = delivered.reduce((sum, payout) => sum + (payout.minOutputAmount ?? 0n), 0n);
136
198
  }
137
- Object.assign(parsed, payouts[0]);
199
+ // Different success/fallback assets do not invalidate a shared receiver, but
200
+ // neither asset nor a single minimum can be claimed for both outcomes.
138
201
  }
139
202
  return parsed;
140
203
  };
@@ -0,0 +1,14 @@
1
+ export declare const ALLOWANCE_HOLDER = "0x0000000000001ff3684f28c67538d4d072c22734";
2
+ export declare const HYPEREVM_LIFI_ROUTER = "0x0a0758d937d1059c356d4714e57f5df0239bce1a";
3
+ export declare const readLifiSwap: (data: string, chain: bigint | undefined, target: string, recipient: string) => {
4
+ inputToken: string;
5
+ inputAmount: bigint;
6
+ outputToken: string;
7
+ minOutputAmount: bigint;
8
+ } | undefined;
9
+ export declare const readSettlerSwap: (data: string, chain: bigint | undefined, recipient: string, balances?: ReadonlyMap<string, bigint>) => {
10
+ inputToken: string;
11
+ inputAmount: bigint;
12
+ outputToken: string;
13
+ minOutputAmount: bigint;
14
+ } | undefined;
@@ -0,0 +1,80 @@
1
+ import { asArray, asBigint, asRecord, asString, decodeCall } from './abi.js';
2
+ import { readSelector } from './hex.js';
3
+ import { isNative, NATIVE_TOKEN } from './protocols.js';
4
+ import { methods as lifiSwaps } from './lifi/swap/index.js';
5
+ export const ALLOWANCE_HOLDER = '0x0000000000001ff3684f28c67538d4d072c22734';
6
+ // Reviewed deployments, not dynamic registry lookups. Evidence and renewal limits:
7
+ // tests/fixtures/route-discovery-20261104/evidence/base-settler-verification.json
8
+ const SETTLERS = {
9
+ 8453: '0x7d6839415693801e94cdc4a7d732c9a3fdbbd1d7',
10
+ 999: '0xf58d4122251f2ace689f257eaf2688883e30fc6e',
11
+ };
12
+ export const HYPEREVM_LIFI_ROUTER = '0x0a0758d937d1059c356d4714e57f5df0239bce1a';
13
+ export const readLifiSwap = (data, chain, target, recipient) => {
14
+ if (chain !== 999n || target !== HYPEREVM_LIFI_ROUTER || readSelector(data) !== '0x4666fc80')
15
+ return undefined;
16
+ const method = lifiSwaps.find((item) => item.selector === '0x4666fc80');
17
+ const parsed = method?.decode(data, 0n);
18
+ if (!parsed || parsed.receiver !== recipient)
19
+ return undefined;
20
+ const { sourceToken: inputToken, sourceAmount: inputAmount, outputToken, minOutputAmount } = parsed;
21
+ if (!inputToken || isNative(inputToken) || !outputToken || isNative(outputToken) ||
22
+ inputAmount === undefined || inputAmount <= 0n || minOutputAmount === undefined || minOutputAmount <= 0n)
23
+ return undefined;
24
+ return { inputToken, inputAmount, outputToken, minOutputAmount };
25
+ };
26
+ export const readSettlerSwap = (data, chain, recipient, balances) => {
27
+ if (readSelector(data) !== '0x2213bc0b')
28
+ return undefined;
29
+ const args = decodeCall([
30
+ { name: 'operator', kind: 'address' }, { name: 'token', kind: 'address' },
31
+ { name: 'amount', kind: 'uint' }, { name: 'target', kind: 'address' }, { name: 'data', kind: 'bytes' },
32
+ ], data);
33
+ const settler = SETTLERS[chain?.toString() ?? ''];
34
+ if (!settler || asString(args.target) !== settler || asString(args.operator) !== settler ||
35
+ isNative(asString(args.token)) || asBigint(args.amount) <= 0n)
36
+ return undefined;
37
+ const inner = asString(args.data);
38
+ if (readSelector(inner) !== '0x1fff991f')
39
+ return undefined;
40
+ const execution = decodeCall([
41
+ { name: 'slippage', kind: 'tuple', components: [
42
+ { name: 'recipient', kind: 'address' }, { name: 'buyToken', kind: 'address' }, { name: 'minAmountOut', kind: 'uint' },
43
+ ] },
44
+ { name: 'actions', kind: 'array', item: { name: 'action', kind: 'bytes' } },
45
+ { name: 'zid', kind: 'bytes', size: 32 },
46
+ ], inner);
47
+ const slippage = asRecord(execution.slippage);
48
+ if (asString(slippage.recipient) !== recipient || asString(slippage.buyToken) === NATIVE_TOKEN ||
49
+ asBigint(slippage.minAmountOut) <= 0n)
50
+ return undefined;
51
+ const actions = asArray(execution.actions).map(asString);
52
+ // These reviewed actions cannot mutate/zeroize slippage. CHECK_SLIPPAGE, SELECT
53
+ // and unreviewed actions must not bypass the final enforced transfer to Handler.
54
+ if (!actions.length || readSelector(actions[0]) !== '0xc1fb425e' ||
55
+ actions.slice(1).some((action) => !['0x38c9c147', '0x8d68a156', '0x5f80abae'].includes(readSelector(action) ?? '')))
56
+ return undefined;
57
+ const funding = decodeCall([
58
+ { name: 'recipient', kind: 'address' }, { name: 'token', kind: 'address' },
59
+ { name: 'amount', kind: 'uint' }, { name: 'nonce', kind: 'uint' },
60
+ { name: 'deadline', kind: 'uint' }, { name: 'signature', kind: 'bytes' },
61
+ ], actions[0]);
62
+ if (asString(funding.recipient) !== settler || asString(funding.token) !== asString(args.token) ||
63
+ asBigint(funding.amount) !== asBigint(args.amount) || asString(funding.signature) !== '0x')
64
+ return undefined;
65
+ let inputAmount = asBigint(args.amount);
66
+ 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) {
69
+ if (inputAmount !== maxUint256)
70
+ return undefined;
71
+ inputAmount = balances?.get(asString(args.token)) ?? 0n;
72
+ if (inputAmount <= 0n)
73
+ return undefined;
74
+ }
75
+ return {
76
+ inputToken: asString(args.token), inputAmount,
77
+ outputToken: isNative(asString(slippage.buyToken)) ? NATIVE_TOKEN : asString(slippage.buyToken),
78
+ minOutputAmount: asBigint(slippage.minAmountOut),
79
+ };
80
+ };
@@ -8,6 +8,8 @@ export type BridgeCallExpectation = {
8
8
  /** 目标链 id。目标币是原生币时,用它识别 `hood` 这类链 id,而不是源链 id。 */
9
9
  toChainId?: string;
10
10
  toTokenRawAmount?: string;
11
+ /** Selected backend quote key; only protocol identifiers with known semantics are compared. */
12
+ quoteKey?: string | Record<string, unknown>;
11
13
  /** 小数滑点。0.01 表示 1%。 */
12
14
  slippage?: string | number;
13
15
  };
@@ -19,7 +21,7 @@ export type CompareMask = {
19
21
  receiver: boolean;
20
22
  };
21
23
  export declare const knownCompare: (parsed: ParsedBridgeCall) => CompareMask;
22
- /** Actual source payment may exceed local intent by at most 0.1%; lower payments still reject. */
24
+ /** Actual source payment may exceed local intent by at most 0.1%; lower payments fail comparison. */
23
25
  export declare const sourceAmountMatches: (actual: bigint | undefined, expected: bigint) => boolean;
24
26
  export declare const expectationFailures: (parsed: ParsedBridgeCall, expectation: BridgeCallExpectation, chainId: string, mask: CompareMask) => BridgeErrorDefinition[];
25
27
  export declare const finishCheck: (aggregatorId: string, parsed: ParsedBridgeCall, structural: BridgeErrorDefinition | undefined, expectation: BridgeCallExpectation | undefined, chainId: string, mask: CompareMask) => BridgeCheck;
@@ -39,7 +39,23 @@ const parseAmount = (amount) => {
39
39
  return undefined;
40
40
  }
41
41
  };
42
- /** Actual source payment may exceed local intent by at most 0.1%; lower payments still reject. */
42
+ const selectedQuoteIdentifier = (quoteKey) => {
43
+ let key = quoteKey;
44
+ if (typeof key === 'string') {
45
+ try {
46
+ key = JSON.parse(key);
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ if (!key || typeof key !== 'object' || Array.isArray(key) || typeof key.quote_id !== 'string')
53
+ return undefined;
54
+ // Socket/Relay cache keys preserve the provider ID before a cache UUID suffix.
55
+ const match = /^(0x[0-9a-f]{64})(?::[^:]+)?$/i.exec(key.quote_id);
56
+ return match?.[1].toLowerCase();
57
+ };
58
+ /** Actual source payment may exceed local intent by at most 0.1%; lower payments fail comparison. */
43
59
  export const sourceAmountMatches = (actual, expected) => actual !== undefined && expected >= 0n && actual >= expected && (actual - expected) * 1000n <= expected;
44
60
  /** Allow a 5% relative difference from the quote's slippage-adjusted minimum. */
45
61
  const minReceiveMatches = (minReceive, quoted, slippage) => {
@@ -59,6 +75,16 @@ const minReceiveMatches = (minReceive, quoted, slippage) => {
59
75
  };
60
76
  export const expectationFailures = (parsed, expectation, chainId, mask) => {
61
77
  const reasons = [];
78
+ const quoteIdentifier = selectedQuoteIdentifier(expectation.quoteKey);
79
+ if (quoteIdentifier && parsed.aggregatorId === 'socket' && parsed.quoteId !== quoteIdentifier) {
80
+ reasons.push(BRIDGE_ERRORS.QUOTE_ID_MISMATCH);
81
+ }
82
+ // Relay direct deposits sign an order hash, distinct from the cache key's requestId.
83
+ if (quoteIdentifier && parsed.aggregatorId === 'relay' &&
84
+ !['depositNative', 'depositErc20', 'depositErc20Allowance'].includes(parsed.method) &&
85
+ parsed.requestId !== quoteIdentifier) {
86
+ reasons.push(BRIDGE_ERRORS.REQUEST_ID_MISMATCH);
87
+ }
62
88
  if (expectation.toChainId && parsed.destinationChainRef && parsed.destinationChainId === undefined) {
63
89
  reasons.push(BRIDGE_ERRORS.TO_CHAIN_MISMATCH);
64
90
  }
@@ -98,16 +124,19 @@ export const expectationFailures = (parsed, expectation, chainId, mask) => {
98
124
  export const finishCheck = (aggregatorId, parsed, structural, expectation, chainId, mask) => {
99
125
  mask = {
100
126
  ...mask,
101
- toToken: mask.toToken && !(parsed.outputToken === undefined && parsed.outputTokenReference),
102
- toAmount: mask.toAmount && !(parsed.minOutputAmount === undefined && parsed.outputAmountReference),
127
+ fromToken: mask.fromToken && parsed.sourceToken !== undefined,
128
+ fromAmount: mask.fromAmount && parsed.sourceAmount !== undefined,
129
+ toToken: mask.toToken && parsed.outputToken !== undefined,
130
+ toAmount: mask.toAmount && parsed.minOutputAmount !== undefined,
131
+ receiver: mask.receiver && parsed.receiver !== undefined,
103
132
  };
104
133
  const expected = expectation
105
134
  ? expectationFailures(parsed, expectation, chainId, mask)
106
135
  : [];
136
+ // A locally proven receiver is still useful when destination assets/amounts
137
+ // cannot be reduced to one outcome. Unknown execution/receivers remain check failures.
107
138
  const unsupportedCall = (parsed.hasDestinationCall || parsed.destinationCallData !== undefined) &&
108
- (parsed.receiver === undefined ||
109
- (parsed.outputToken === undefined && !parsed.outputTokenReference) ||
110
- (parsed.minOutputAmount === undefined && !parsed.outputAmountReference));
139
+ parsed.receiver === undefined;
111
140
  const errors = [
112
141
  ...(structural ? [structural] : []),
113
142
  ...(unsupportedCall ? [BRIDGE_ERRORS.UNSUPPORTED_DESTINATION_CALL] : []),
@@ -60,16 +60,17 @@ export const decodeBridgeCalldata = (aggregatorId, tx) => {
60
60
  };
61
61
  export const validateBridgeCalldata = (aggregatorId, chainId, tx, expectation) => {
62
62
  const context = { aggregatorId, chainId, toChainId: expectation?.toChainId, ...calldataDiagnostics(tx.data || tx.input) };
63
+ const inputErrors = [];
63
64
  if (sourceChainMismatch(chainId, tx.chainId))
64
- return { ...bridgeFailure([{ ...BRIDGE_ERRORS.FROM_CHAIN_MISMATCH, ...context }]), aggregatorId };
65
+ inputErrors.push(BRIDGE_ERRORS.FROM_CHAIN_MISMATCH);
65
66
  if (expectation && tx.from !== undefined &&
66
67
  (typeof tx.from !== 'string' || tx.from.toLowerCase() !== expectation.userAddress.toLowerCase())) {
67
- return { ...bridgeFailure([{ ...BRIDGE_ERRORS.FROM_ADDRESS_MISMATCH, ...context }]), aggregatorId };
68
+ inputErrors.push(BRIDGE_ERRORS.FROM_ADDRESS_MISMATCH);
68
69
  }
69
70
  const validate = VALIDATORS[aggregatorId];
70
71
  if (!validate) {
71
72
  return {
72
- ...bridgeFailure([{ ...BRIDGE_ERRORS.UNSUPPORTED_CALLDATA_AGGREGATOR, ...context }]),
73
+ ...bridgeFailure([...inputErrors, BRIDGE_ERRORS.UNSUPPORTED_CALLDATA_AGGREGATOR].map((error) => ({ ...error, ...context }))),
73
74
  aggregatorId,
74
75
  };
75
76
  }
@@ -78,8 +79,10 @@ export const validateBridgeCalldata = (aggregatorId, chainId, tx, expectation) =
78
79
  value = txValue(tx);
79
80
  }
80
81
  catch (error) {
81
- return { ...bridgeFailure(asBridgeError(error, BRIDGE_ERRORS.INVALID_TRANSACTION_VALUE, context).errors), aggregatorId };
82
+ return { ...bridgeFailure([...inputErrors, ...asBridgeError(error, BRIDGE_ERRORS.INVALID_TRANSACTION_VALUE).errors]
83
+ .map((detail) => ({ ...detail, ...context }))), aggregatorId };
82
84
  }
83
85
  const check = validate(txCalldata(tx), value, expectation, chainId);
84
- return { ...check, errors: check.errors.map((error) => sanitizeBridgeError({ ...error, ...context })) };
86
+ const errors = [...inputErrors, ...check.errors].map((error) => sanitizeBridgeError({ ...error, ...context }));
87
+ return { ...check, ...(errors.length ? bridgeFailure(errors) : {}), errors };
85
88
  };
@@ -0,0 +1,5 @@
1
+ import { type AbiValue } from '../abi.js';
2
+ import type { ParsedBridgeCall } from '../types.js';
3
+ export declare const readLifiDestination: (parsed: ParsedBridgeCall, data: {
4
+ [key: string]: AbiValue;
5
+ }) => Partial<ParsedBridgeCall> | undefined;
@@ -0,0 +1,40 @@
1
+ import { asArray, asBigint, asRecord, asString, decodeCall } from '../abi.js';
2
+ import { accountFromBytes32, readSelector } from '../hex.js';
3
+ import { ALLOWANCE_HOLDER, readSettlerSwap } from '../destinationSwap.js';
4
+ import { isNative } from '../protocols.js';
5
+ // Base ReceiverOIF constructor binds these two contracts. No RPC or mutable
6
+ // caller-supplied identity is used; see evidence/base-lifi-oif-verification.json.
7
+ const BASE_OIF = '0xe6ee2a573047598b2452a879f2188b31d0442dbf';
8
+ const BASE_EXECUTOR = '0x4dac9d1769b9b304cb04741dcdeb2fc14abdf110';
9
+ const BASE_OUTPUT_SETTLER = '0x75220b7600c300005038432a0000f308e0000068';
10
+ export const readLifiDestination = (parsed, data) => {
11
+ const receiver = accountFromBytes32(asString(data.recipient));
12
+ if (parsed.destinationChainId !== 8453n ||
13
+ accountFromBytes32(asString(data.dstCallReceiver)) !== BASE_OIF ||
14
+ accountFromBytes32(asString(data.outputSettler)) !== BASE_OUTPUT_SETTLER ||
15
+ asString(data.depositAndRefundAddress) !== receiver)
16
+ return undefined;
17
+ const swaps = asArray(data.dstCallSwapData);
18
+ // No generic call is trusted merely because ReceiverOIF/Executor will forward it.
19
+ if (swaps.length !== 1 || !parsed.outputToken || parsed.minOutputAmount === undefined)
20
+ return undefined;
21
+ const swap = asRecord(swaps[0]);
22
+ const input = asString(swap.sendingAssetId), amount = asBigint(swap.fromAmount);
23
+ const output = asString(swap.receivingAssetId), callTo = asString(swap.callTo), approval = asString(swap.approveTo);
24
+ if (input !== parsed.outputToken || isNative(input) || amount <= 0n || amount > parsed.minOutputAmount)
25
+ return undefined;
26
+ const callData = asString(swap.callData);
27
+ if (callTo === input && approval === BASE_EXECUTOR && output === input && readSelector(callData) === '0xa9059cbb') {
28
+ const transfer = decodeCall([{ name: 'to', kind: 'address' }, { name: 'amount', kind: 'uint' }], callData);
29
+ if (asString(transfer.to) !== receiver || asBigint(transfer.amount) !== amount)
30
+ return undefined;
31
+ // Executor sends any remaining input to the same receiver after this transfer.
32
+ return { receiver, outputToken: input, minOutputAmount: parsed.minOutputAmount };
33
+ }
34
+ if (callTo !== ALLOWANCE_HOLDER || approval !== ALLOWANCE_HOLDER)
35
+ return undefined;
36
+ const result = readSettlerSwap(callData, parsed.destinationChainId, BASE_EXECUTOR);
37
+ if (!result || result.inputToken !== input || result.inputAmount !== amount || result.outputToken !== output)
38
+ return undefined;
39
+ return { receiver, outputToken: output, minOutputAmount: result.minOutputAmount };
40
+ };
@@ -19,6 +19,7 @@ import * as relay from './relay/index.js';
19
19
  import * as squid from './squid/index.js';
20
20
  import * as stargate from './stargate/index.js';
21
21
  import * as swap from './swap/index.js';
22
+ import { isNative } from '../protocols.js';
22
23
  const ROUTES = [
23
24
  across,
24
25
  allbridge,
@@ -56,5 +57,6 @@ export const validateLifi = (data, value, expectation, chainId = '') => {
56
57
  parsed: hit.parsed,
57
58
  };
58
59
  }
59
- return finishCheck('lifi', hit.parsed, route.check(hit.parsed), expectation, chainId, route.compare(hit.parsed));
60
+ return finishCheck('lifi', hit.parsed, hit.parsed.method.endsWith('ViaLayerSwap') && value !== (isNative(hit.parsed.sourceToken ?? '') ? hit.parsed.sourceAmount : 0n)
61
+ ? BRIDGE_ERRORS.LAYERSWAP_SOURCE_VALUE_MISMATCH : route.check(hit.parsed), expectation, chainId, route.compare(hit.parsed));
60
62
  };
@@ -1,8 +1,7 @@
1
1
  import { type AbiParam, type AbiValue } from '../abi.js';
2
2
  import type { ParsedBridgeCall } from '../types.js';
3
+ export { applyFirstSwap, swapDataArrayParam, swapDataParam } from './swapData.js';
3
4
  export declare const bridgeDataParam: AbiParam;
4
- export declare const swapDataArrayParam: AbiParam;
5
- export declare const swapDataParam: AbiParam;
6
5
  export declare const acrossDataParam: AbiParam;
7
6
  export declare const stargateDataParam: AbiParam;
8
7
  export declare const relayDepositoryDataParam: AbiParam;
@@ -11,7 +10,6 @@ export declare const gasZipDataParam: AbiParam;
11
10
  export declare const squidDataParam: AbiParam;
12
11
  export declare const acrossPackedParam: AbiParam;
13
12
  export declare const applyBridgeData: (method: string, raw: AbiValue) => ParsedBridgeCall;
14
- export declare const applyFirstSwap: (parsed: ParsedBridgeCall, raw: AbiValue) => void;
15
13
  export declare const applyAcrossData: (parsed: ParsedBridgeCall, raw: AbiValue) => void;
16
14
  export declare const applyStargateData: (parsed: ParsedBridgeCall, raw: AbiValue) => void;
17
15
  export declare const applyRelayDepositoryData: (parsed: ParsedBridgeCall, raw: AbiValue) => void;