Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions apps/api/src/api/services/monerium-b2b/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Address> = {
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)"
Expand Down Expand Up @@ -419,11 +430,11 @@ export async function readEnabledRoutes(factory: Address): Promise<Array<{ index
return routes.filter(route => 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<bigint> {
/** Static quote for `amountIn` over a packed path on `quoter` (from quoterV2ForChainId). */
export async function quoteRouteOutput(quoter: Address, path: Hex, amountIn: bigint): Promise<bigint> {
const { result } = await getPublicClient().simulateContract({
abi: quoterV2Abi,
address: MAINNET_QUOTER_V2,
address: quoter,
args: [path, amountIn],
functionName: "quoteExactInput"
});
Expand Down
19 changes: 14 additions & 5 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -656,6 +656,7 @@ describe("pricePlannedSwap", () => {
} else {
fetchSpy.mockResolvedValue(fetched);
}
return { quoteSpy };
}

const price = (maxSubsidyBps = 50) => pricePlannedSwap(FORWARDER, FACTORY, 1_000n * EUR, maxSubsidyBps);
Expand Down Expand Up @@ -688,24 +689,32 @@ 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 () => {
arrange({ quotes: { "0xaa": new Error("no pool"), "0xbb": new Error("no pool") } });
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 () => {
Expand Down
15 changes: 9 additions & 6 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
getKeeperWalletClient,
getPublicClient,
quoteRouteOutput,
quoterV2ForChainId,
readEnabledRoutes,
readSubsidyVaultState,
recoveredEvent,
Expand Down Expand Up @@ -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<Array<{ index: number; quotedOut: bigint }>> {
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)}`);
}
Expand All @@ -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,
Expand Down Expand Up @@ -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");
}
Expand Down
57 changes: 55 additions & 2 deletions apps/api/src/api/services/monerium-b2b/monitoring.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<string, unknown> = {
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<typeof chain.getPublicClient>);
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();
});
});
12 changes: 7 additions & 5 deletions apps/api/src/api/services/monerium-b2b/monitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getPublicClient,
moneriumChainForChainId,
quoteRouteOutput,
quoterV2ForChainId,
readEnabledRoutes,
readSubsidyVaultState,
SubsidyVaultState
Expand All @@ -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
Expand Down Expand Up @@ -283,10 +284,11 @@ async function monitoredAccounts(statuses: MoneriumAccountStatus[]): Promise<Mon
/**
* Executable-depth check (PRD §7.4): QuoterV2 static quotes at minSwapAmount and
* perSwapCap on every enabled factory route vs Chainlink; the best route decides.
* Runs only against Ethereum mainnet — MAINNET_QUOTER_V2 is a mainnet pin.
* Skipped on a chain without a pinned QuoterV2 (quoterV2ForChainId).
*/
export async function runExecutableDepthCheck(): Promise<void> {
if ((await getChainId()) !== 1) {
const quoter = quoterV2ForChainId(await getChainId());
if (!quoter) {
return;
}
const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]);
Expand Down Expand Up @@ -321,8 +323,8 @@ export async function runExecutableDepthCheck(): Promise<void> {
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),
Expand Down
10 changes: 7 additions & 3 deletions docs/architecture-monerium-b2b-onramp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -498,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 —
Expand Down
2 changes: 1 addition & 1 deletion docs/security-spec/05-integrations/monerium-b2b.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.
Expand Down
Loading