From 8633cee5a30321505a563aac88ae2ae54a44b5c7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:42:19 +0200 Subject: [PATCH 1/3] fix(api): quote and project b2b swaps on the sepolia quoterv2 The keeper only quoted on Ethereum mainnet, so on the Sepolia sandbox no projection defer ran and below-floor or over-cap swaps reverted on chain and backed off instead of waiting with a partner-visible reason. Pin the QuoterV2 per chain id and keep the unquoted first-route fallback only for chains without one. --- .../src/api/services/monerium-b2b/chain.ts | 21 +++++-- .../monerium-b2b/conversion-executor.test.ts | 19 +++++-- .../monerium-b2b/conversion-executor.ts | 15 +++-- .../services/monerium-b2b/monitoring.test.ts | 57 ++++++++++++++++++- .../api/services/monerium-b2b/monitoring.ts | 12 ++-- 5 files changed, 101 insertions(+), 23 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index aa00fbeec..2abdfb442 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -200,8 +200,19 @@ export const chainlinkAbi = parseAbi([ "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)" ]); -/** Uniswap V3 QuoterV2 on Ethereum mainnet (the pinned quoting contract, PRD §7.4). */ -export const MAINNET_QUOTER_V2: Address = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"; +/** + * Pinned Uniswap V3 QuoterV2 per chain id (PRD §7.4): Ethereum mainnet and the Sepolia + * sandbox, each on the same Uniswap v3 deployment as the chain's pools and router. + */ +const QUOTER_V2_BY_CHAIN_ID: Record = { + 1: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", + 11155111: "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3" +}; + +/** The pinned QuoterV2 for `chainId`; null on a chain without one (no quoting there). */ +export function quoterV2ForChainId(chainId: number): Address | null { + return QUOTER_V2_BY_CHAIN_ID[chainId] ?? null; +} export const quoterV2Abi = parseAbi([ "function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)" @@ -419,11 +430,11 @@ export async function readEnabledRoutes(factory: Address): Promise route.enabled).map(({ index, path }) => ({ index, path })); } -/** Static QuoterV2 quote for `amountIn` over a packed path. Mainnet only (MAINNET_QUOTER_V2 pin). */ -export async function quoteRouteOutput(path: Hex, amountIn: bigint): Promise { +/** Static quote for `amountIn` over a packed path on `quoter` (from quoterV2ForChainId). */ +export async function quoteRouteOutput(quoter: Address, path: Hex, amountIn: bigint): Promise { const { result } = await getPublicClient().simulateContract({ abi: quoterV2Abi, - address: MAINNET_QUOTER_V2, + address: quoter, args: [path, amountIn], functionName: "quoteExactInput" }); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 265d84436..23734e35f 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -644,7 +644,7 @@ describe("pricePlannedSwap", () => { spyOn(chain, "readEnabledRoutes").mockResolvedValue(overrides.routes ?? routes); spyOn(chain, "readSubsidyVaultState").mockResolvedValue(vault); const quotes = overrides.quotes ?? { "0xaa": 1_138_400_000n, "0xbb": 1_139_000_000n }; - spyOn(chain, "quoteRouteOutput").mockImplementation(async path => { + const quoteSpy = spyOn(chain, "quoteRouteOutput").mockImplementation(async (_quoter, path) => { const quote = quotes[path]; if (quote instanceof Error) throw quote; return quote; @@ -656,6 +656,7 @@ describe("pricePlannedSwap", () => { } else { fetchSpy.mockResolvedValue(fetched); } + return { quoteSpy }; } const price = (maxSubsidyBps = 50) => pricePlannedSwap(FORWARDER, FACTORY, 1_000n * EUR, maxSubsidyBps); @@ -688,9 +689,16 @@ describe("pricePlannedSwap", () => { expect(await price()).toEqual({ code: "no_route", kind: "defer", reason: "the factory has no enabled swap route" }); }); - it("uses the first enabled route unprojected off mainnet, still carrying the tier cap", async () => { - arrange({ chainId: 11_155_111 }); + it("quotes on the Sepolia QuoterV2 and defers on its projection like mainnet", async () => { + const { quoteSpy } = arrange({ chainId: 11_155_111, quotes: { "0xaa": 1_130n * USDC, "0xbb": new Error("no pool") } }); + expect(await price(100)).toMatchObject({ code: "below_floor", kind: "defer", reason: expect.stringContaining("per-swap cap") }); + expect(quoteSpy).toHaveBeenCalledWith("0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3", "0xaa", 1_000n * EUR); + }); + + it("uses the first enabled route unquoted on a chain without a known quoter, still carrying the tier cap", async () => { + const { quoteSpy } = arrange({ chainId: 31_337 }); expect(await price()).toEqual({ kind: "ready", maxSubsidyRaw: 5_700_000n, reference, routeIndex: 0 }); + expect(quoteSpy).not.toHaveBeenCalled(); }); it("defers when no route can be quoted", async () => { @@ -698,14 +706,15 @@ describe("pricePlannedSwap", () => { expect(await price()).toEqual({ code: "no_route", kind: "defer", reason: "no enabled swap route could be quoted" }); }); - it("picks the route with the highest quote", async () => { - arrange(); + it("picks the route with the highest quote on the mainnet QuoterV2", async () => { + const { quoteSpy } = arrange(); expect(await price()).toEqual({ kind: "ready", maxSubsidyRaw: 5_700_000n, // 50 bps of the 1140 USDC reference value reference, routeIndex: 1 }); + expect(quoteSpy).toHaveBeenCalledWith("0x61fFE014bA17989E743c5F6cB21bF9697530B21e", "0xbb", 1_000n * EUR); }); it("defers with the route, quote and shortfall when the projection defers", async () => { diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index 087b1617f..7ceabe910 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -30,6 +30,7 @@ import { getKeeperWalletClient, getPublicClient, quoteRouteOutput, + quoterV2ForChainId, readEnabledRoutes, readSubsidyVaultState, recoveredEvent, @@ -833,15 +834,16 @@ function deferSwap(code: DepositWaitingReason, reason: string): PlannedSwap { return { code, kind: "defer", reason }; } -/** Quotes every enabled route on the mainnet QuoterV2; a route that cannot be quoted is skipped with a warning. */ +/** Quotes every enabled route on the chain's QuoterV2; a route that cannot be quoted is skipped with a warning. */ async function quoteRoutes( + quoter: Address, routes: Array<{ index: number; path: Hex }>, amountIn: bigint ): Promise> { const quotes: Array<{ index: number; quotedOut: bigint }> = []; for (const route of routes) { try { - quotes.push({ index: route.index, quotedOut: await quoteRouteOutput(route.path, amountIn) }); + quotes.push({ index: route.index, quotedOut: await quoteRouteOutput(quoter, route.path, amountIn) }); } catch (error) { logger.warn(`monerium-b2b: route ${route.index} could not be quoted: ${errorText(error)}`); } @@ -853,8 +855,8 @@ async function quoteRoutes( * Reference, route, tier cap and projection for a swap of `amountIn` * (docs/architecture-monerium-b2b-onramp.md, fees section). `maxSubsidyBps` is the * keeper's tier for the chunk's waiting time; the cap it yields is passed into the swap - * and binds on chain. Outside Ethereum mainnet there is no quoter pin: the first enabled - * route is used unprojected and the contract's own checks remain the only gate. + * and binds on chain. On a chain without a pinned QuoterV2 (quoterV2ForChainId) the first + * enabled route is used unprojected and the contract's own checks remain the only gate. */ export async function pricePlannedSwap( forwarder: Address, @@ -895,10 +897,11 @@ export async function pricePlannedSwap( if (routes.length === 0) { return deferSwap("no_route", "the factory has no enabled swap route"); } - if ((await getChainId()) !== 1) { + const quoter = quoterV2ForChainId(await getChainId()); + if (!quoter) { return { kind: "ready", maxSubsidyRaw, reference, routeIndex: routes[0].index }; } - const quotes = await quoteRoutes(routes, amountIn); + const quotes = await quoteRoutes(quoter, routes, amountIn); if (quotes.length === 0) { return deferSwap("no_route", "no enabled swap route could be quoted"); } diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts index 9a472292f..c56ab81a2 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { Hex } from "viem"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import * as chain from "./chain"; import { classifyExecutableDepth, classifyRefundQueue, @@ -8,7 +11,8 @@ import { detectConfigDrift, diffAssociation, eip1167RuntimeCode, - normalizeIban + normalizeIban, + runExecutableDepthCheck } from "./monitoring"; // Pure monitoring logic (implementation plan D3): quote-impact math against the T6 @@ -246,3 +250,52 @@ describe("eip1167RuntimeCode", () => { ); }); }); + +describe("runExecutableDepthCheck", () => { + afterEach(() => mock.restore()); + + function arrange(chainId: number) { + spyOn(chain, "getChainId").mockResolvedValue(chainId); + const findAll = spyOn(MoneriumAccount, "findAll").mockResolvedValue([ + { forwarderAddress: "0x1111111111111111111111111111111111111111" } as MoneriumAccount + ]); + const reads: Record = { + latestRoundData: [1n, 114_000_000n, 0n, 0n, 1n], + minSwapAmount: 1n * EUR, + perSwapCap: 10_000n * EUR + }; + spyOn(chain, "getPublicClient").mockReturnValue({ + readContract: async ({ functionName }: { functionName: string }) => reads[functionName] + } as unknown as ReturnType); + spyOn(chain, "getForwarderImmutables").mockResolvedValue({ + factory: "0x2222222222222222222222222222222222222222", + oracle: "0x5555555555555555555555555555555555555555", + oracleDecimals: 8, + slippageBps: 60 + } as unknown as chain.ForwarderImmutables); + spyOn(chain, "readEnabledRoutes").mockResolvedValue([{ index: 0, path: "0xaa" as Hex }]); + const quoteSpy = spyOn(chain, "quoteRouteOutput").mockImplementation(async (_quoter, _path, amountIn) => { + return (amountIn * 114n) / (100n * 10n ** 12n); + }); + return { findAll, quoteSpy }; + } + + it("quotes on the Sepolia QuoterV2 on the sandbox chain", async () => { + const { quoteSpy } = arrange(11_155_111); + await runExecutableDepthCheck(); + expect(quoteSpy).toHaveBeenCalledWith("0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3", "0xaa", 1n * EUR); + }); + + it("quotes on the mainnet QuoterV2 on Ethereum", async () => { + const { quoteSpy } = arrange(1); + await runExecutableDepthCheck(); + expect(quoteSpy).toHaveBeenCalledWith("0x61fFE014bA17989E743c5F6cB21bF9697530B21e", "0xaa", 10_000n * EUR); + }); + + it("skips a chain without a known quoter", async () => { + const { findAll, quoteSpy } = arrange(31_337); + await runExecutableDepthCheck(); + expect(findAll).not.toHaveBeenCalled(); + expect(quoteSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 99ee289a1..4002c9821 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -15,6 +15,7 @@ import { getPublicClient, moneriumChainForChainId, quoteRouteOutput, + quoterV2ForChainId, readEnabledRoutes, readSubsidyVaultState, SubsidyVaultState @@ -32,7 +33,7 @@ import { refundAccountFor } from "./refund-wallet"; * EUR/USD rate. Raw impact of the best route above SLIPPAGE_BPS at minSwapAmount size * means every keeper swap draws a subsidy and the permissionless path would revert * (error-level DEPTH BELOW FLOOR line, triage per the runbook); at perSwapCap size it - * is an early warning. Mainnet-only (QuoterV2 pin). + * is an early warning. Only on chains with a pinned QuoterV2 (mainnet, Sepolia). * 2. Stranded-balance monitor: forwarders whose on-chain batch marker has been open * longer than RECOVERY_DELAY (the promised window, registry P3) warn — the deposit * should be forwarded or recovering by then; past TRIGGER_DELAY (the @@ -283,10 +284,11 @@ async function monitoredAccounts(statuses: MoneriumAccountStatus[]): Promise { - if ((await getChainId()) !== 1) { + const quoter = quoterV2ForChainId(await getChainId()); + if (!quoter) { return; } const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); @@ -321,8 +323,8 @@ export async function runExecutableDepthCheck(): Promise { for (const route of routes) { try { const [minOut, capOut] = await Promise.all([ - quoteRouteOutput(route.path, minSwapAmount), - quoteRouteOutput(route.path, perSwapCap) + quoteRouteOutput(quoter, route.path, minSwapAmount), + quoteRouteOutput(quoter, route.path, perSwapCap) ]); quoted.push({ capImpactBps: computeQuoteImpactBps(perSwapCap, capOut, answer, oracleDecimals), From 4bba9c46af6630e841e524f34231cdd52733d218 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:42:20 +0200 Subject: [PATCH 2/3] docs(api): describe the per-chain b2b quoter pin --- docs/architecture-monerium-b2b-onramp.md | 5 +++-- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index b3d080083..6bc25e218 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -452,8 +452,9 @@ contract settles every fill into three bands against that reference (decisions: - **Routes**: the factory holds a guardian-managed whitelist of packed Uniswap v3 paths, validated on chain to touch only EURe, EURC and USDC on the immutable router, with at most two hops on Uniswap's four fee tiers; entries are disabled, never removed, so - indices stay stable. The keeper quotes every enabled route on the mainnet QuoterV2 - and passes the best index. A poor pick costs Vortex fee or subsidy, never the client. + indices stay stable. The keeper quotes every enabled route on the chain's pinned + QuoterV2 (Ethereum mainnet and the Sepolia sandbox; elsewhere it takes the first + enabled route unprojected) and passes the best index. A poor pick costs Vortex fee or subsidy, never the client. - **Subsidy ladder and per-swap cap.** How much of a shortfall Vortex pays depends on how long the chunk has waited: `MONERIUM_B2B_SUBSIDY_LADDER` maps seconds waited to a maximum subsidy in bps of the reference value (launch: nothing for six minutes, then diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 377e03340..908f035be 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -49,7 +49,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-limited to one pass per 30 minutes) is detection-only; its invariants: 1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per the B2B operations runbook). -2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on every enabled factory route at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline); the best route decides. Settlement enforces `SLIPPAGE_BPS` on the client's net, so a raw impact above it means every keeper swap of that size draws a vault subsidy (deferring past the per-swap cap) and the permissionless path would revert; at `minSwapAmount` size that logs the error-level DEPTH BELOW FLOOR line, at `perSwapCap` size a warning (`classifyExecutableDepth`). The keeper's own `projectSwap` deferral remains the authoritative gate. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. +2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on every enabled factory route at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline); the best route decides. Settlement enforces `SLIPPAGE_BPS` on the client's net, so a raw impact above it means every keeper swap of that size draws a vault subsidy (deferring past the per-swap cap) and the permissionless path would revert; at `minSwapAmount` size that logs the error-level DEPTH BELOW FLOOR line, at `perSwapCap` size a warning (`classifyExecutableDepth`). The keeper's own `projectSwap` deferral remains the authoritative gate. Both this check and the keeper's route quoting use the QuoterV2 pinned per chain id (`quoterV2ForChainId`: Ethereum mainnet and the Sepolia sandbox, each on the chain's own Uniswap v3 deployment), so the sandbox projects and defers like production; on a chain without a pinned quoter the check is skipped and the keeper takes the first enabled route unprojected, leaving the contract's checks as the only gate. 3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe or any USDC whose on-chain batch marker has been open longer than `RECOVERY_DELAY` (2 h, registry P3) warn — the promised window was missed and the deposit should be forwarded or recovering; past `TRIGGER_DELAY` they error (the permissionless swap/forward path is then live — a keeper-outage signal, not a fund-risk signal). 4. **Association monitor (S1 detective control)** — per active account, re-reads linked addresses and IBANs scoped to the exact mapped profile and configured chain, then error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). The only address besides the forwarder that is expected on the profile is the client's own derived refund wallet (`refundAccountFor(profileId)`, linked at onboarding); any other address still alerts, and with no refund seed configured no wallet is excused. This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. 5. **Config reconciliation (R07)** — first requires the clone's immutable `FACTORY()` to equal the configured trusted factory, then reads `implementation()` and `isForwarder()` only from that trusted address. A mismatch is an error and no mutable fields are reconciled. For trusted clones, timelocked fee-policy changes are authorized transitions reconciled with a version bump; a destination change (the clone has no setter), proxy bytecode or registration drift is an incident. The standalone manifest verifier remains consistency evidence, not the trust root. From a6a443c134009cd753f7bdf5fdd4e29f62745cb5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:45:23 +0200 Subject: [PATCH 3/3] docs(api): note the depth alert now also fires on the sandbox --- docs/architecture-monerium-b2b-onramp.md | 5 ++++- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 6bc25e218..c19ecfe6b 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -499,7 +499,10 @@ read-only — no keys, no transactions: whitelabel credentials can change associations at Monerium: those changes cannot be prevented client-side, only detected fast. 2. **Executable-depth monitor.** QuoterV2 quotes on every enabled route vs Chainlink; - the best route's impact past the floor is an alert before clients feel it. + the best route's impact past the floor is an alert before clients feel it. It runs + on the Sepolia sandbox too, so a sandbox pool priced away from the Sepolia oracle logs + the same error-level line every pass (and the keeper defers sandbox swaps as below the + floor); rebalance the sandbox pool rather than treating it as a production incident. 3. **Stranded-balance monitor.** Forwarders holding EURe or USDC whose batch marker has been open longer than `RECOVERY_DELAY` warn (the promised window was missed: forward or recover) and longer than `TRIGGER_DELAY` error (the permissionless path is live — diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 908f035be..f4c161f5c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -49,7 +49,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-limited to one pass per 30 minutes) is detection-only; its invariants: 1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per the B2B operations runbook). -2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on every enabled factory route at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline); the best route decides. Settlement enforces `SLIPPAGE_BPS` on the client's net, so a raw impact above it means every keeper swap of that size draws a vault subsidy (deferring past the per-swap cap) and the permissionless path would revert; at `minSwapAmount` size that logs the error-level DEPTH BELOW FLOOR line, at `perSwapCap` size a warning (`classifyExecutableDepth`). The keeper's own `projectSwap` deferral remains the authoritative gate. Both this check and the keeper's route quoting use the QuoterV2 pinned per chain id (`quoterV2ForChainId`: Ethereum mainnet and the Sepolia sandbox, each on the chain's own Uniswap v3 deployment), so the sandbox projects and defers like production; on a chain without a pinned quoter the check is skipped and the keeper takes the first enabled route unprojected, leaving the contract's checks as the only gate. +2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on every enabled factory route at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline); the best route decides. Settlement enforces `SLIPPAGE_BPS` on the client's net, so a raw impact above it means every keeper swap of that size draws a vault subsidy (deferring past the per-swap cap) and the permissionless path would revert; at `minSwapAmount` size that logs the error-level DEPTH BELOW FLOOR line, at `perSwapCap` size a warning (`classifyExecutableDepth`). The keeper's own `projectSwap` deferral remains the authoritative gate. Both this check and the keeper's route quoting use the QuoterV2 pinned per chain id (`quoterV2ForChainId`: Ethereum mainnet and the Sepolia sandbox, each on the chain's own Uniswap v3 deployment), so the sandbox projects and defers like production; on a chain without a pinned quoter the check is skipped and the keeper takes the first enabled route unprojected, leaving the contract's checks as the only gate. On the sandbox the DEPTH BELOW FLOOR error therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident. 3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe or any USDC whose on-chain batch marker has been open longer than `RECOVERY_DELAY` (2 h, registry P3) warn — the promised window was missed and the deposit should be forwarded or recovering; past `TRIGGER_DELAY` they error (the permissionless swap/forward path is then live — a keeper-outage signal, not a fund-risk signal). 4. **Association monitor (S1 detective control)** — per active account, re-reads linked addresses and IBANs scoped to the exact mapped profile and configured chain, then error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). The only address besides the forwarder that is expected on the profile is the client's own derived refund wallet (`refundAccountFor(profileId)`, linked at onboarding); any other address still alerts, and with no refund seed configured no wallet is excused. This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. 5. **Config reconciliation (R07)** — first requires the clone's immutable `FACTORY()` to equal the configured trusted factory, then reads `implementation()` and `isForwarder()` only from that trusted address. A mismatch is an error and no mutable fields are reconciled. For trusted clones, timelocked fee-policy changes are authorized transitions reconciled with a version bump; a destination change (the clone has no setter), proxy bytecode or registration drift is an incident. The standalone manifest verifier remains consistency evidence, not the trust root.