Skip to content

Commit b444210

Browse files
fix(swap): instrument bridge quote after intent verification (#752)
instrumentBridgeQuote ran before verifyLifiBridgeIntent in the bridge branch of prepareSwap, contradicting design §4.2 step 6. A bridge route later REFUSEd by verifyLifiBridgeIntent (destination chain or receiver mismatch) still incremented lifiBridgeSuspectedUnreachableCount, mildly inflating the exact metric PROD condition 2 uses to promote #745. Move the instrumentBridgeQuote call to after verifyLifiBridgeIntent, still gated on swapClass === "bridge".
1 parent e39013a commit b444210

2 files changed

Lines changed: 51 additions & 5 deletions

File tree

src/modules/swap/index.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -999,11 +999,11 @@ export async function prepareSwap(args: PrepareSwapArgs): Promise<UnsignedTx> {
999999
const sourceTokenAddr = quote.action.fromToken.address as string;
10001000
const swapClass = classifyLifiQuote(firstData as `0x${string}`);
10011001
if (swapClass === "bridge") {
1002-
// Status-quo bridge flow, now instrumented (read-only, never REFUSEs).
1003-
instrumentBridgeQuote(quote as unknown as LifiQuoteLike, sourceTokenAddr, {
1004-
fromChain: args.fromChain,
1005-
toChain: args.toChain,
1006-
});
1002+
// Status-quo bridge flow. Instrumentation is deferred to AFTER
1003+
// verifyLifiBridgeIntent below (design §4.2 step 6) so a route that
1004+
// guard REFUSEs is never counted toward lifiBridgeSuspectedUnreachableCount
1005+
// — that counter feeds PROD condition 2's promotion of #745, and counting
1006+
// an already-refused quote would inflate it. See #752.
10071007
} else if (swapClass === "generic") {
10081008
const v1 = vetGenericSwapQuote(quote as unknown as LifiQuoteLike, sourceTokenAddr);
10091009
if (v1.kind === "REFUSE") {
@@ -1071,6 +1071,16 @@ export async function prepareSwap(args: PrepareSwapArgs): Promise<UnsignedTx> {
10711071
// there — no false positives on the existing same-chain swap path.
10721072
verifyLifiBridgeIntent(args, txRequest.data as `0x${string}`);
10731073

1074+
if (swapClass === "bridge") {
1075+
// Bridge-class instrumentation (read-only, never REFUSEs), now placed
1076+
// AFTER verifyLifiBridgeIntent so a route that guard REFUSEs never
1077+
// reaches here — see #752.
1078+
instrumentBridgeQuote(quote as unknown as LifiQuoteLike, sourceTokenAddr, {
1079+
fromChain: args.fromChain,
1080+
toChain: args.toChain,
1081+
});
1082+
}
1083+
10741084
// Cross-check LiFi's reported token decimals against on-chain reads. A mismatch
10751085
// would mean either LiFi has stale metadata or the route targets a token different
10761086
// from what we asked for — in either case, the formatted expectedOut/minOut shown

test/swap-lifi-minout.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,42 @@ describe("#685 T6 — bridge with source-side skim SHIPS status quo + fires the
524524
});
525525
});
526526

527+
describe("#752 — bridge instrumentation runs AFTER verifyLifiBridgeIntent", () => {
528+
it("does not increment the counter when the bridge guard REFUSEs on a receiver mismatch", async () => {
529+
const swap = await import("../src/modules/swap/index.js");
530+
swap.resetLifiBridgeSuspectedUnreachableCount();
531+
532+
const gross = 15_000_000_000n;
533+
const ATTACKER = "0x9999999999999999999999999999999999999999" as `0x${string}`;
534+
// Same source-side skim shape as T6's first case — bridgeSuspectedUnreachable
535+
// would flag this quote if instrumentation ran before the guard below fires.
536+
const q = makeBridgeQuote({
537+
fromAmount: gross,
538+
toAmount: 15_000_000_000_000_000_000_000n,
539+
minAmount: gross, // exceeds the post-skim 14,962.5 USDC
540+
feeCosts: [
541+
{
542+
name: "integrator fee",
543+
token: { address: ETH_USDC, symbol: "USDC", decimals: 6, priceUSD: "1" },
544+
amount: "37500000",
545+
included: true,
546+
},
547+
],
548+
// != bridgeArgs.wallet — verifyLifiBridgeIntent's #798 receiver check REFUSEs.
549+
receiver: ATTACKER,
550+
});
551+
fetchQuoteMock.mockResolvedValueOnce(q);
552+
553+
await expect(swap.prepareSwap(bridgeArgs)).rejects.toThrow(/receiver mismatch/);
554+
// #752 — instrumentation is placed after verifyLifiBridgeIntent, so a route
555+
// the guard REFUSEs must never be counted toward
556+
// lifiBridgeSuspectedUnreachableCount (the exact metric PROD condition 2 uses
557+
// to promote #745). On unfixed code (instrumentBridgeQuote called before the
558+
// guard) this would already be 1 here.
559+
expect(swap.lifiBridgeSuspectedUnreachableCount).toBe(0);
560+
});
561+
});
562+
527563
describe("#685 — fixture-replay tally (classifier + gate + bridge signal)", () => {
528564
it("classifies and gates representative fixtures with the expected tally", () => {
529565
const gross = 15_000_000_000n;

0 commit comments

Comments
 (0)