@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.
- package/README.md +67 -39
- package/dist/calldata/across/validate.js +8 -4
- package/dist/calldata/destination.js +71 -8
- package/dist/calldata/destinationSwap.d.ts +14 -0
- package/dist/calldata/destinationSwap.js +80 -0
- package/dist/calldata/expect.d.ts +3 -1
- package/dist/calldata/expect.js +35 -6
- package/dist/calldata/index.js +8 -5
- package/dist/calldata/lifi/destination.d.ts +5 -0
- package/dist/calldata/lifi/destination.js +40 -0
- package/dist/calldata/lifi/index.js +3 -1
- package/dist/calldata/lifi/params.d.ts +1 -3
- package/dist/calldata/lifi/params.js +13 -36
- package/dist/calldata/lifi/swap/swapMethod.js +1 -1
- package/dist/calldata/lifi/swapData.d.ts +5 -0
- package/dist/calldata/lifi/swapData.js +33 -0
- package/dist/calldata/mayan/fastMctpBridge.js +6 -1
- package/dist/calldata/protocols.js +1 -0
- package/dist/calldata/relay/index.js +3 -1
- package/dist/calldata/socket/bridge.js +1 -1
- package/dist/calldata/socket/exec.js +1 -1
- package/dist/calldata/socket/protocols/index.js +10 -5
- package/dist/calldata/socket/protocols/oft.js +3 -1
- package/dist/calldata/socket/swapAndBridge.js +6 -2
- package/dist/calldata/socket/validate.js +3 -0
- package/dist/errors/constants.d.ts +15 -0
- package/dist/errors/constants.js +15 -0
- package/dist/errors/index.d.ts +7 -2
- package/dist/errors/index.js +1 -1
- package/dist/errors/sanitize.js +5 -6
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -1
- package/dist/quote.d.ts +14 -6
- package/dist/quote.js +88 -172
- package/package.json +1 -1
- package/dist/providers/index.d.ts +0 -7
- package/dist/providers/index.js +0 -340
- package/dist/providers/request.d.ts +0 -23
- package/dist/providers/request.js +0 -69
- package/dist/providers/types.d.ts +0 -36
- 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
|
-
-
|
|
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 [
|
|
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
|
-
##
|
|
21
|
+
## Validation rollout
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
validation
|
|
26
|
-
|
|
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
|
|
38
|
-
-
|
|
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
|
-
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
110
|
-
|
|
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
|
|
115
|
-
detailed decode result or exception into a report.
|
|
116
|
-
|
|
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 =
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
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 (
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/calldata/expect.js
CHANGED
|
@@ -39,7 +39,23 @@ const parseAmount = (amount) => {
|
|
|
39
39
|
return undefined;
|
|
40
40
|
}
|
|
41
41
|
};
|
|
42
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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] : []),
|
package/dist/calldata/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
|
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
|
-
|
|
86
|
+
const errors = [...inputErrors, ...check.errors].map((error) => sanitizeBridgeError({ ...error, ...context }));
|
|
87
|
+
return { ...check, ...(errors.length ? bridgeFailure(errors) : {}), errors };
|
|
85
88
|
};
|
|
@@ -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,
|
|
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;
|