From ce284de7fa5c964c602620e06d34a3d7eac04904 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:42:19 +0200 Subject: [PATCH 01/80] 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 91d79640d316034eb3c22a513a51d1694d8a6bb2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:42:20 +0200 Subject: [PATCH 02/80] 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 3437807486ead0e9c1ece8109708f0c343386bae Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:45:23 +0200 Subject: [PATCH 03/80] 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. From 68724d17525e59a77a736d97f8d12faa10222391 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:42:08 +0200 Subject: [PATCH 04/80] fix(api): poke the batch marker when a monerium cycle sends no swap The marker starts the RECOVERY_DELAY clock. A failed attempt or a retry backoff skip sent nothing, so a deposit whose every attempt reverted kept batchOpenedAt at zero until the deadline job marked it recovering, and its refund landed a whole extra delay late. --- .../monerium-b2b/conversion-executor.test.ts | 177 ++++++++++++++++++ .../monerium-b2b/conversion-executor.ts | 12 +- 2 files changed, 188 insertions(+), 1 deletion(-) 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 23734e35f..859c5b6d9 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 @@ -1008,6 +1008,183 @@ describe("runConversionExecutor activation gate", () => { }); }); +// The batch marker starts the RECOVERY_DELAY clock, so a cycle that sends no swap must +// still arm it: otherwise a deposit whose every attempt fails waits a whole extra delay +// for its refund. +describe("runConversionExecutor batch marker", () => { + const FACTORY = "0x2222222222222222222222222222222222222222" as Address; + const EURE = "0x4444444444444444444444444444444444444444" as Address; + const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl }; + + beforeAll(async () => { + config.moneriumB2b.rpcUrl = undefined; // provisioning skips the on-chain clone check + config.moneriumB2b.forwarderFactoryAddress = FACTORY; + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = saved.rpcUrl; + config.moneriumB2b.forwarderFactoryAddress = saved.factory; + }); + + beforeEach(() => resetTestDatabase()); + afterEach(() => mock.restore()); + + /** A funded clone with a ready-priced 100 EURe swap; returns every keeper write as `fn:nonce`. */ + function arrange(options: { batchOpenedAt: bigint; failPokeWrite?: boolean; swapReverts?: boolean }) { + const writes: string[] = []; + const reads: Record = { + batchOpenedAt: options.batchOpenedAt, + floorPpm: 1_500, + latestRoundData: [1n, 114_000_000n, 0n, 0n, 1n], + MIN_SWAP_FLOOR: 1n, + minSwapAmount: 25n * EUR, + perSwapCap: 10_000n * EUR, + subsidyVault: "0x0000000000000000000000000000000000000000", + targetPpm: 1_250 + }; + spyOn(chain, "getForwarderImmutables").mockResolvedValue({ + eure: EURE, + factory: FACTORY, + maxReferenceDeviationBps: 100, + oracle: "0x5555555555555555555555555555555555555555", + oracleDecimals: 8, + recoveryDelaySeconds: 7_200, + usdc: "0x6666666666666666666666666666666666666666" + } as unknown as chain.ForwarderImmutables); + spyOn(chain, "getChainId").mockResolvedValue(31_337); // no pinned quoter: the swap prices ready unprojected + spyOn(chain, "readEnabledRoutes").mockResolvedValue([{ index: 0, path: "0xaa" as Hex }]); + spyOn(referenceRate, "fetchCoinbaseReference").mockResolvedValue({ + price: "1.14000000", + rateRaw: 114_000_000n, + source: "test", + time: new Date(0) + }); + spyOn(chain, "getPublicClient").mockReturnValue({ + getBlockNumber: async () => 100n, + getTransactionCount: async () => 7, + readContract: async ({ address, functionName }: { address: Address; functionName: string }) => + functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName], + simulateContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "swap" && options.swapReverts) throw new Error("execution reverted"); + return {}; + }, + waitForTransactionReceipt: async () => { + throw new Error("receipt timeout"); + } + } as unknown as ReturnType); + spyOn(chain, "getKeeperWalletClient").mockReturnValue({ + account: { address: "0x9999999999999999999999999999999999999999" }, + writeContract: async ({ functionName, nonce }: { functionName: string; nonce?: number }) => { + writes.push(`${functionName}:${nonce ?? "auto"}`); + if (functionName === "poke" && options.failPokeWrite) throw new Error("poke rejected"); + return `0x${writes.length.toString(16).padStart(64, "0")}`; + } + } as unknown as ReturnType); + return writes; + } + + async function activeAccountWithDeposit() { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const { accountId } = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: "0x5555555555555555555555555555555555555555", + externalSubjectId: "client-1", + forwarderAddress: "0x1111111111111111111111111111111111111111", + managerProfileId: manager.id, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + const deposit = await MoneriumFiatDeposit.create({ + accountId, + amountRaw: (100n * EUR).toString(), + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + mintedAt: new Date(), + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xorder1" + }); + return { accountId, deposit }; + } + + const executions = (accountId: string) => MoneriumConversionExecution.findAll({ where: { accountId } }); + + async function failedAttempt(accountId: string, depositId: string) { + await MoneriumConversionExecution.create({ + accountId, + depositId, + destination: "0x5555555555555555555555555555555555555555", + eureInRaw: (100n * EUR).toString(), + error: "attempt 1: execution reverted", + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Failed + }); + } + + it("pokes once when the swap attempt fails before sending", async () => { + const writes = arrange({ batchOpenedAt: 0n, swapReverts: true }); + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["poke:auto"]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]); + }); + + it("pokes during the retry backoff without starting an attempt", async () => { + const writes = arrange({ batchOpenedAt: 0n }); + const { accountId, deposit } = await activeAccountWithDeposit(); + await failedAttempt(accountId, deposit.id); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["poke:auto"]); + expect(await executions(accountId)).toHaveLength(1); + }); + + it("never pokes an armed batch, on a failed attempt or during backoff", async () => { + const writes = arrange({ batchOpenedAt: 1n, swapReverts: true }); + const { accountId, deposit } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); // fails the attempt + await failedAttempt(accountId, deposit.id); + await runConversionExecutor(accountId); // backoff + + expect(writes).toEqual([]); + }); + + it("does not poke again after the sequenced poke was attempted", async () => { + const writes = arrange({ batchOpenedAt: 0n, failPokeWrite: true }); + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["poke:7"]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]); + }); + + it("keeps the poke-then-swap sequence on a sent attempt, with no extra poke", async () => { + const writes = arrange({ batchOpenedAt: 0n }); + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); // the receipt wait times out: the row stays pending + + expect(writes).toEqual(["poke:7", "swap:8"]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Pending]); + }); +}); + // A reservation whose broadcast never happened (the process died between `reserve` and // `send`) holds a nonce nobody will use; the keeper re-sends the identical call there. describe("runConversionExecutor reserved-nonce re-send", () => { 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 7ceabe910..fd73a557b 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1208,9 +1208,14 @@ export async function runConversionExecutor(accountId: string): Promise { }); if (slot.kind === "skip") { logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${slot.reason}`); + if (pokeNeeded) { + await sendPoke(forwarder); + } return; } const { attempt, execution } = slot; + // Cleared once the sequenced poke is attempted, so a failed attempt never pokes twice. + let pokeOutstanding = pokeNeeded; try { const keeper = getKeeperWalletClient(); @@ -1249,6 +1254,7 @@ export async function runConversionExecutor(accountId: string): Promise { }, send: nonce => writeCall(keeper, forwarder, call.request, nonce), sendPoke: async nonce => { + pokeOutstanding = false; await keeper.writeContract({ abi: forwarderAbi, account: keeper.account, @@ -1287,6 +1293,10 @@ export async function runConversionExecutor(accountId: string): Promise { status: MoneriumConversionExecutionStatus.Failed }); logger.error(`monerium-b2b: ${call.kind} for account ${account.id} failed (attempt ${attempt}):`, error); + // Nothing was sent: still arm the marker, or the recovery clock waits for the next cycle. + if (pokeOutstanding) { + await sendPoke(forwarder); + } } } @@ -1368,7 +1378,7 @@ function executionCall( } } -/** Standalone batch-marker poke for funds the keeper cannot act on yet. */ +/** Standalone batch-marker poke for funds the keeper does not act on this cycle. */ async function sendPoke(forwarder: Address): Promise { try { const client = getPublicClient(); From ee8b2cd47a3a00dc95c244dc9976013570278a04 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:42:10 +0200 Subject: [PATCH 05/80] docs(api): note the marker poke on cycles that send no swap --- 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 c19ecfe6b..79dc2d1a4 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -366,7 +366,10 @@ and sends at most one transaction per account per cycle: endpoint, or once automated by the missed window — is moved off the clone with `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the clone's fixed `recoveryAddress` (the client's refund wallet), and only once the clone's `batchOpenedAt` marker is older - than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive, is never + than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive (the keeper + sends the permissionless `poke()` ahead of the first swap, and on its own in any cycle + that sends no swap: nothing to do, a deferral, a retry backoff or a failed attempt, so + a deposit whose every attempt fails is still refundable on time), is never re-timed by a chunk swap, and is re-timed for whatever remains after a forward or a recovery, so a younger payment sharing the clone gets its own clock. The keeper recovers before it converts anything else, and still does so on suspended or dormant diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index f4c161f5c..f71259200 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -111,7 +111,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] The reference midpoint rejects a malformed ticker, an inverted or empty book and a spread above 50 bps (`reference-rate.test.ts`) - [ ] Keeper, guardian, and attestor private keys are three distinct keys in production, the deployer key differs from those and the float key (`vars.test.ts`); none logged - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) -- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` +- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; a cycle that sends no swap (deferral, retry backoff, failed attempt) still pokes an unarmed funded batch, at most once per cycle; no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` - [ ] Every execution carries `kind` and `deposit_id` (migration 080); the migration refuses an execution that spanned several deposits under the former allocation join instead of guessing - [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`) - [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch From f050f2ed43811829565b093e8d786ef7f712bdf6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:51 +0200 Subject: [PATCH 06/80] test(api): assert the armed-batch marker test takes the backoff --- .../src/api/services/monerium-b2b/conversion-executor.test.ts | 1 + 1 file changed, 1 insertion(+) 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 859c5b6d9..502c224bb 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 @@ -1162,6 +1162,7 @@ describe("runConversionExecutor batch marker", () => { await runConversionExecutor(accountId); // backoff expect(writes).toEqual([]); + expect(await executions(accountId)).toHaveLength(2); // no third attempt: the backoff held }); it("does not poke again after the sequenced poke was attempted", async () => { From 466abbf1f509fc8842d613b6747b88e29c5d8819 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:57 +0200 Subject: [PATCH 07/80] fix(api): poke only on the backoff when a monerium cycle skips A skip that waits on another execution leaves the poke to that execution's own sequence, so an implicit-nonce poke never races it. A backoff reached by the top-of-cycle pending check now falls through to the slot check, which pokes, instead of returning a cycle early. --- .../monerium-b2b/conversion-executor.test.ts | 41 +++++++++++++++++++ .../monerium-b2b/conversion-executor.ts | 16 +++++--- 2 files changed, 52 insertions(+), 5 deletions(-) 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 502c224bb..766acaeb2 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 @@ -1165,6 +1165,47 @@ describe("runConversionExecutor batch marker", () => { expect(await executions(accountId)).toHaveLength(2); // no third attempt: the backoff held }); + it("pokes when a crashed pre-send reservation expires into a backoff", async () => { + const writes = arrange({ batchOpenedAt: 0n }); + const { accountId, deposit } = await activeAccountWithDeposit(); + await MoneriumConversionExecution.create({ + accountId, + createdAt: new Date(Date.now() - 10 * 60_000), + depositId: deposit.id, + destination: "0x5555555555555555555555555555555555555555", + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Pending + }); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["poke:auto"]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]); + }); + + it("leaves the poke to another executor whose execution appears mid-cycle", async () => { + const writes = arrange({ batchOpenedAt: 0n }); + const { accountId, deposit } = await activeAccountWithDeposit(); + // Another process reserves its execution after this cycle's first pending check. + spyOn(referenceRate, "fetchCoinbaseReference").mockImplementation(async () => { + await MoneriumConversionExecution.create({ + accountId, + depositId: deposit.id, + destination: "0x5555555555555555555555555555555555555555", + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Pending + }); + return { price: "1.14000000", rateRaw: 114_000_000n, source: "test", time: new Date(0) }; + }); + + await runConversionExecutor(accountId); + + expect(writes).toEqual([]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Pending]); + }); + it("does not poke again after the sequenced poke was attempted", async () => { const writes = arrange({ batchOpenedAt: 0n, failPokeWrite: true }); const { accountId } = await activeAccountWithDeposit(); 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 fd73a557b..bc5cc9ec7 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -473,7 +473,12 @@ async function settleDeposit( // ------------------------------------------------------------------ pending resolution + backoff -type PreparationResult = { kind: "proceed"; attempt: number } | { kind: "skip"; reason: string }; +// "backoff" (no pending execution left, waiting out failures) is the one wait whose poke this +// executor owes; a "skip" waits on a pending execution, whose own sequence carries the poke. +type PreparationResult = + | { kind: "proceed"; attempt: number } + | { kind: "skip"; reason: string } + | { kind: "backoff"; reason: string }; export type HashlessPendingClassification = | { kind: "fail"; reason: string } @@ -812,7 +817,7 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans const backoffMs = Math.min(RETRY_BASE_MS * 2 ** (failedSince.length - 1), RETRY_MAX_MS); const nextAttemptAt = failedSince[0].updatedAt.getTime() + backoffMs; if (Date.now() < nextAttemptAt) { - return { kind: "skip", reason: `retry backoff until ${new Date(nextAttemptAt).toISOString()}` }; + return { kind: "backoff", reason: `retry backoff until ${new Date(nextAttemptAt).toISOString()}` }; } } return { attempt: failedSince.length + 1, kind: "proceed" }; @@ -1069,6 +1074,7 @@ export async function runConversionExecutor(accountId: string): Promise { const recovery = await withForwarderLock(account.forwarderAddress, transaction => prepareExecutionSlot(account, transaction) ); + // A backoff falls through: the slot check below pokes for it. if (recovery.kind === "skip") { logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${recovery.reason}`); return; @@ -1176,7 +1182,7 @@ export async function runConversionExecutor(accountId: string): Promise { // transactions, two concurrent executors could both pass the check and both broadcast. const slot = await withForwarderLock(account.forwarderAddress, async transaction => { const preparation = await prepareExecutionSlot(account, transaction); - if (preparation.kind === "skip") { + if (preparation.kind !== "proceed") { return preparation; } // Execution-before-send record: committed before any broadcast so a crash leaves an @@ -1206,9 +1212,9 @@ export async function runConversionExecutor(accountId: string): Promise { } return { attempt: preparation.attempt, execution, kind: "proceed" as const }; }); - if (slot.kind === "skip") { + if (slot.kind !== "proceed") { logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${slot.reason}`); - if (pokeNeeded) { + if (slot.kind === "backoff" && pokeNeeded) { await sendPoke(forwarder); } return; From 72e89546de1ae69dc9d30c3ddffd35b2d7421716 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:58 +0200 Subject: [PATCH 08/80] docs(api): note that a pending execution carries its own poke --- 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 79dc2d1a4..a24463ca4 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -367,9 +367,10 @@ and sends at most one transaction per account per cycle: `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the clone's fixed `recoveryAddress` (the client's refund wallet), and only once the clone's `batchOpenedAt` marker is older than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive (the keeper - sends the permissionless `poke()` ahead of the first swap, and on its own in any cycle + sends the permissionless `poke()` ahead of the first swap, and on its own in a cycle that sends no swap: nothing to do, a deferral, a retry backoff or a failed attempt, so - a deposit whose every attempt fails is still refundable on time), is never + a deposit whose every attempt fails is still refundable on time; a cycle waiting on a + pending execution leaves the poke to that execution's own sequence), is never re-timed by a chunk swap, and is re-timed for whatever remains after a forward or a recovery, so a younger payment sharing the clone gets its own clock. The keeper recovers before it converts anything else, and still does so on suspended or dormant diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index f71259200..20a65cc20 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -111,7 +111,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] The reference midpoint rejects a malformed ticker, an inverted or empty book and a spread above 50 bps (`reference-rate.test.ts`) - [ ] Keeper, guardian, and attestor private keys are three distinct keys in production, the deployer key differs from those and the float key (`vars.test.ts`); none logged - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) -- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; a cycle that sends no swap (deferral, retry backoff, failed attempt) still pokes an unarmed funded batch, at most once per cycle; no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` +- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; a cycle that sends no swap (deferral, retry backoff, failed attempt) still pokes an unarmed funded batch, at most once per cycle (a cycle waiting on a pending execution leaves it to that execution's sequence); no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` - [ ] Every execution carries `kind` and `deposit_id` (migration 080); the migration refuses an execution that spanned several deposits under the former allocation join instead of guessing - [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`) - [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch From 3ebc796f455af85773ca70a996e775ae55832185 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:43:04 +0200 Subject: [PATCH 09/80] fix(api): apply due fee-policy increases in the keeper cycle applyFeePolicy is permissionless and the contract expects the keeper to finalize an announced increase once its 24 h timelock elapsed, but no keeper code called it, so increases stayed pending forever. --- .../src/api/services/monerium-b2b/chain.ts | 10 +- .../monerium-b2b/conversion-executor.test.ts | 129 ++++++++++++++++++ .../monerium-b2b/conversion-executor.ts | 34 ++++- 3 files changed, 170 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index 2abdfb442..30cfb3822 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -129,6 +129,7 @@ export const forwarderAbi = [ type: "function" }, { inputs: [], name: "forwardAll", outputs: [], stateMutability: "nonpayable", type: "function" }, + { inputs: [], name: "applyFeePolicy", outputs: [], stateMutability: "nonpayable", type: "function" }, { inputs: [ { name: "eureAmount", type: "uint256" }, @@ -166,7 +167,14 @@ export const forwarderAbi = [ type: "function" }, { inputs: [], name: "targetPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, - { inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" } + { inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, + { + inputs: [], + name: "pendingFeePolicyEffectiveAt", + outputs: [{ name: "", type: "uint64" }], + stateMutability: "view", + type: "function" + } ] as const; export const factoryAbi = [ 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 766acaeb2..ff45e88ca 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 @@ -909,6 +909,7 @@ describe("runConversionExecutor activation gate", () => { latestRoundData: [1n, 0n, 0n, 0n, 1n], // Chainlink down: a planned swap defers before sending anything MIN_SWAP_FLOOR: 1n, minSwapAmount: 25n * EUR, + pendingFeePolicyEffectiveAt: 0n, perSwapCap: 10_000n * EUR, subsidyVault: "0x0000000000000000000000000000000000000000", targetPpm: 1_250 @@ -1039,6 +1040,7 @@ describe("runConversionExecutor batch marker", () => { latestRoundData: [1n, 114_000_000n, 0n, 0n, 1n], MIN_SWAP_FLOOR: 1n, minSwapAmount: 25n * EUR, + pendingFeePolicyEffectiveAt: 0n, perSwapCap: 10_000n * EUR, subsidyVault: "0x0000000000000000000000000000000000000000", targetPpm: 1_250 @@ -1525,3 +1527,130 @@ describe("runConversionExecutor reserved-nonce re-send", () => { expect(updates).toEqual([]); }); }); + +// P11: an announced fee increase only takes effect once someone calls the permissionless +// applyFeePolicy() after the timelock; the keeper does so in the account's cycle. +describe("runConversionExecutor fee-policy apply", () => { + const FACTORY = "0x2222222222222222222222222222222222222222" as Address; + const EURE = "0x4444444444444444444444444444444444444444" as Address; + const NOW = 1_800_000_000n; + const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl }; + let reads: Record; + let writes: string[]; + let writeError: Error | null; + + beforeAll(async () => { + config.moneriumB2b.rpcUrl = undefined; + config.moneriumB2b.forwarderFactoryAddress = FACTORY; + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = saved.rpcUrl; + config.moneriumB2b.forwarderFactoryAddress = saved.factory; + }); + + beforeEach(async () => { + await resetTestDatabase(); + writes = []; + writeError = null; + reads = { + batchOpenedAt: 1n, + floorPpm: 1_500, + latestRoundData: [1n, 0n, 0n, 0n, 1n], // Chainlink down: the planned swap defers, nothing else is sent + MIN_SWAP_FLOOR: 1n, + minSwapAmount: 25n * EUR, + pendingFeePolicyEffectiveAt: 0n, + perSwapCap: 10_000n * EUR, + subsidyVault: "0x0000000000000000000000000000000000000000", + targetPpm: 1_250 + }; + spyOn(chain, "getForwarderImmutables").mockResolvedValue({ + eure: EURE, + factory: FACTORY, + usdc: "0x6666666666666666666666666666666666666666", + recoveryDelaySeconds: 7_200 + } as unknown as chain.ForwarderImmutables); + spyOn(chain, "getPublicClient").mockReturnValue({ + getBlock: async () => ({ timestamp: NOW }), + readContract: async ({ address, functionName }: { address: Address; functionName: string }) => + functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName], + simulateContract: async () => ({}), + waitForTransactionReceipt: async () => ({ status: "success" }) + } as unknown as ReturnType); + spyOn(chain, "getKeeperWalletClient").mockReturnValue({ + account: { address: "0x9999999999999999999999999999999999999999" }, + writeContract: async ({ functionName }: { functionName: string }) => { + if (writeError) throw writeError; + writes.push(functionName); + return "0xapply"; + } + } as unknown as ReturnType); + }); + + afterEach(() => mock.restore()); + + async function activeAccountWithDeposit() { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const { accountId } = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: "0x5555555555555555555555555555555555555555", + externalSubjectId: "client-1", + forwarderAddress: "0x1111111111111111111111111111111111111111", + managerProfileId: manager.id, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + const deposit = await MoneriumFiatDeposit.create({ + accountId, + amountRaw: (100n * EUR).toString(), + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + mintedAt: new Date(), + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xorder1" + }); + return { accountId, deposit }; + } + + it("applies a due increase once, before the cycle prices its swap", async () => { + reads.pendingFeePolicyEffectiveAt = NOW; + const { accountId, deposit } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["applyFeePolicy"]); + expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); // the cycle went on + }); + + it("sends nothing while no increase is pending or its timelock still runs", async () => { + const { accountId } = await activeAccountWithDeposit(); + await runConversionExecutor(accountId); + reads.pendingFeePolicyEffectiveAt = NOW + 1n; + await runConversionExecutor(accountId); + expect(writes).toEqual([]); + }); + + it("logs a reverted apply and carries on with the cycle", async () => { + reads.pendingFeePolicyEffectiveAt = NOW - 60n; + writeError = new Error("NoPendingFeePolicy()"); + const warn = spyOn(logger, "warn"); + const { accountId, deposit } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(warn.mock.calls.some(([message]) => String(message).includes("applyFeePolicy"))).toBe(true); + expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); + }); +}); 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 bc5cc9ec7..fa54614d9 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1096,15 +1096,19 @@ export async function runConversionExecutor(accountId: string): Promise { ) { throw new Error(`Forwarder ${forwarder} is not bound to the configured trusted factory`); } - const [eureBalance, usdcBalance, batchOpenedAt, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([ + const [eureBalance, usdcBalance, batchOpenedAt, minSwapAmount, minSwapFloor, perSwapCap, feeIncreaseAt] = await Promise.all([ client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }), client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), - client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "pendingFeePolicyEffectiveAt" }) ]); + // Before pricing, so a swap this cycle is projected under the policy it will settle at. + await applyDueFeePolicy(forwarder, feeIncreaseAt); + // Arm the batch marker whenever funds are present, even below the (guardian-tunable) // minSwapAmount: the recovery and trigger clocks must run regardless of whether a swap // is currently possible. @@ -1408,6 +1412,32 @@ async function sendPoke(forwarder: Address): Promise { } } +/** + * P11: finalizes a guardian fee increase whose timelock has elapsed (`applyFeePolicy` is + * permissionless; nothing else would ever apply it). Waits for the receipt so the cycle + * then reads the applied policy. Best-effort like sendPoke: a revert (applied by someone + * else, clock skew) or a timeout is logged and the cycle goes on, since the contract always + * settles under its current on-chain policy. The monitor's config reconciliation mirrors + * the applied values into the account row. + */ +async function applyDueFeePolicy(forwarder: Address, effectiveAt: bigint): Promise { + if (effectiveAt === 0n) return; + try { + const client = getPublicClient(); + const { timestamp } = await client.getBlock(); + if (timestamp < effectiveAt) return; + const keeper = getKeeperWalletClient(); + const call = { abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "applyFeePolicy" } as const; + await client.simulateContract(call); + const hash = await withKeeperSendLock(() => keeper.writeContract({ ...call, chain: null })); + const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }); + if (receipt.status !== "success") throw new Error(`transaction ${hash} reverted`); + logger.info(`monerium-b2b: applied the pending fee policy on forwarder ${forwarder} (${hash})`); + } catch (error) { + logger.warn(`monerium-b2b: applyFeePolicy on forwarder ${forwarder} failed: ${errorText(error)}`); + } +} + /** * Marks a settling deposit for the refund path. Under the forwarder lock so it cannot * race a chunk swap being reserved; the keeper then sends `recover` once the clone's From bcc889760343c5510e4e5d4900cdcafe3f5af385 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:43:05 +0200 Subject: [PATCH 10/80] docs(api): document the keeper's fee-policy apply step --- docs/architecture-monerium-b2b-onramp.md | 3 +++ docs/security-spec/05-integrations/monerium-b2b.md | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index a24463ca4..53f6e8ca1 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -435,6 +435,9 @@ contract settles every fill into three bands against that reference (decisions: (permissionlessly) only after the 24 h `FEE_INCREASE_TIMELOCK`, so a client whose SEPA transfer is already in flight cannot be swapped under a silently worse policy; lowering is immediate (registry P11). Swaps always use the currently applied policy. + Once an increase is due, the keeper sends `applyFeePolicy()` in the account's next + cycle, before pricing, and waits for it; a revert or timeout is logged and the cycle + goes on. - **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded from the treasury. It pays only when called by a factory-registered clone, to the clone itself (the subsidy is forwarded with the payment), within a guardian-settable diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 20a65cc20..d7501ad53 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -35,14 +35,14 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CYCLE_SECONDS`, default 20: webhook inbox → onboarding → mint watcher → per-account conversion executor → dormancy gate → refund deadlines and orchestrator → partner registrations → manager events and monitoring) holds signing keys and submits transactions; the registration step runs after the money steps and isolated, so a slow Monerium or RPC read never holds back a conversion or refund. Its invariants: -1. **Three-way key separation, bounded fund movement** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`, `swap()`, `forward()` and `recover()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can redirect funds: `swap` only executes a factory-whitelisted, oracle-floored swap whose USDC stays on the clone, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `forward` only ever pays the clone's own `destination`; `recover` only ever pays the clone's `recoveryAddress`, the client's refund wallet fixed at deployment with no setter, and only once the clone's batch marker has been open for `RECOVERY_DELAY` (the contract, not the keeper, enforces the promised window); `setGuardianPaused` is protective-only by contract invariant (it blocks swaps and forwards, never a recovery); the attestor signs the fixed link statement. Besides the guardian, an address the guardian registers on the factory as a deployer (`setDeployer`) may call `deployForwarder` and no other factory or clone function, so deploying needs no guardian signature; the backend's deployer key (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, distinct from the other keys) holds that role for partner registrations (invariant 12). The role is not harmless, though: every clone a deployer deploys is registered (`isForwarder`), the trust anchor of the subsidy vault's `pay` and of the backend's mapping check, and a deployer may pick any destination and recovery address. A leaked deployer key can therefore create registered clones of its own, fund them itself and draw vault subsidies within the vault's per-swap cap and daily budget; it can touch no existing clone's funds or configuration. The guardian bounds it: `setDeployer(address, false)` stops new deployments but does not unregister clones already deployed, and `revokeForwarder(clone)` (guardian-only, one-way, emits `ForwarderRevoked`, reverts `UnknownForwarder` for an unregistered address) removes one from the registry, after which the vault refuses its subsidy calls, so a swap that needs a subsidy reverts, and the backend refuses to map it (the config reconciliation, R07, reports the revoked clone of an onboarding or active account as an error); its forward and recover paths and its funds are untouched. The factory binds each clone's CREATE2 address to its whole initialization, `keccak256(abi.encode(destination, recoveryAddress, targetPpm, floorPpm, salt))`, so `predictAddress` takes all five arguments (manual deploys too) and a deployer cannot squat an address predicted for other settings. Deployers survive a guardian transfer like keepers: a new guardian must review the `DeployerSet` events and reset them. The role takes the guardian key out of deployment only, not off the server: the dormancy pause (invariant 6) still signs with it. A clone still passes the account mapping's on-chain verification (trusted factory, `isForwarder`, destination, fee policy, derived refund wallet) before it is used. Vortex therefore holds a client's funds only on the refund path, only in that client's own refund wallet, and only for a payment the promised window was missed on (adr-0005, amendments 2026-09-17 and 2026-10-01). All keys are env-only and never logged. +1. **Three-way key separation, bounded fund movement** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`, `applyFeePolicy()`, `swap()`, `forward()` and `recover()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can redirect funds: `swap` only executes a factory-whitelisted, oracle-floored swap whose USDC stays on the clone, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `forward` only ever pays the clone's own `destination`; `recover` only ever pays the clone's `recoveryAddress`, the client's refund wallet fixed at deployment with no setter, and only once the clone's batch marker has been open for `RECOVERY_DELAY` (the contract, not the keeper, enforces the promised window); `setGuardianPaused` is protective-only by contract invariant (it blocks swaps and forwards, never a recovery); the attestor signs the fixed link statement. Besides the guardian, an address the guardian registers on the factory as a deployer (`setDeployer`) may call `deployForwarder` and no other factory or clone function, so deploying needs no guardian signature; the backend's deployer key (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, distinct from the other keys) holds that role for partner registrations (invariant 12). The role is not harmless, though: every clone a deployer deploys is registered (`isForwarder`), the trust anchor of the subsidy vault's `pay` and of the backend's mapping check, and a deployer may pick any destination and recovery address. A leaked deployer key can therefore create registered clones of its own, fund them itself and draw vault subsidies within the vault's per-swap cap and daily budget; it can touch no existing clone's funds or configuration. The guardian bounds it: `setDeployer(address, false)` stops new deployments but does not unregister clones already deployed, and `revokeForwarder(clone)` (guardian-only, one-way, emits `ForwarderRevoked`, reverts `UnknownForwarder` for an unregistered address) removes one from the registry, after which the vault refuses its subsidy calls, so a swap that needs a subsidy reverts, and the backend refuses to map it (the config reconciliation, R07, reports the revoked clone of an onboarding or active account as an error); its forward and recover paths and its funds are untouched. The factory binds each clone's CREATE2 address to its whole initialization, `keccak256(abi.encode(destination, recoveryAddress, targetPpm, floorPpm, salt))`, so `predictAddress` takes all five arguments (manual deploys too) and a deployer cannot squat an address predicted for other settings. Deployers survive a guardian transfer like keepers: a new guardian must review the `DeployerSet` events and reset them. The role takes the guardian key out of deployment only, not off the server: the dormancy pause (invariant 6) still signs with it. A clone still passes the account mapping's on-chain verification (trusted factory, `isForwarder`, destination, fee policy, derived refund wallet) before it is used. Vortex therefore holds a client's funds only on the refund path, only in that client's own refund wallet, and only for a payment the promised window was missed on (adr-0005, amendments 2026-09-17 and 2026-10-01). All keys are env-only and never logged. 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. ## Monitoring @@ -113,6 +113,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) - [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; a cycle that sends no swap (deferral, retry backoff, failed attempt) still pokes an unarmed funded batch, at most once per cycle (a cycle waiting on a pending execution leaves it to that execution's sequence); no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` - [ ] Every execution carries `kind` and `deposit_id` (migration 080); the migration refuses an execution that spanned several deposits under the former allocation join instead of guessing +- [ ] A due fee increase is applied once per cycle before pricing, nothing is sent while none is pending or its timelock runs, and a reverted apply is logged without stopping the cycle (`conversion-executor.test.ts`) - [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`) - [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch - [ ] `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` is the factory queried during provisioning/monitoring, and a self-reported mismatch is rejected before local persistence From 62d293ff64bbb2941224fcd0c5967c550e47b2b4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:21 +0200 Subject: [PATCH 11/80] fix(api): skip the fee-policy apply while a keeper send is unmined A timed-out apply left the increase looking pending, so every later cycle re-sent it, stacked keeper nonces and held the worker loop for another receipt timeout. The test also pins that the apply lands before the swap reads the fee policy. --- .../monerium-b2b/conversion-executor.test.ts | 33 ++++++++++++++++--- .../monerium-b2b/conversion-executor.ts | 18 +++++++--- 2 files changed, 42 insertions(+), 9 deletions(-) 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 ff45e88ca..7d68c51aa 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 @@ -1538,6 +1538,8 @@ describe("runConversionExecutor fee-policy apply", () => { let reads: Record; let writes: string[]; let writeError: Error | null; + let nonces: { latest: number; pending: number }; + let receiptTimesOut: boolean; beforeAll(async () => { config.moneriumB2b.rpcUrl = undefined; @@ -1554,6 +1556,8 @@ describe("runConversionExecutor fee-policy apply", () => { await resetTestDatabase(); writes = []; writeError = null; + nonces = { latest: 0, pending: 0 }; + receiptTimesOut = false; reads = { batchOpenedAt: 1n, floorPpm: 1_500, @@ -1573,16 +1577,24 @@ describe("runConversionExecutor fee-policy apply", () => { } as unknown as chain.ForwarderImmutables); spyOn(chain, "getPublicClient").mockReturnValue({ getBlock: async () => ({ timestamp: NOW }), - readContract: async ({ address, functionName }: { address: Address; functionName: string }) => - functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName], + getTransactionCount: async ({ blockTag }: { blockTag: "latest" | "pending" }) => nonces[blockTag], + readContract: async ({ address, functionName }: { address: Address; functionName: string }) => { + if (functionName === "targetPpm") writes.push("read targetPpm"); + return functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName]; + }, simulateContract: async () => ({}), - waitForTransactionReceipt: async () => ({ status: "success" }) + waitForTransactionReceipt: async () => { + if (receiptTimesOut) throw new Error("WaitForTransactionReceiptTimeoutError"); + nonces.latest = nonces.pending; + return { status: "success" }; + } } as unknown as ReturnType); spyOn(chain, "getKeeperWalletClient").mockReturnValue({ account: { address: "0x9999999999999999999999999999999999999999" }, writeContract: async ({ functionName }: { functionName: string }) => { if (writeError) throw writeError; writes.push(functionName); + nonces.pending += 1; return "0xapply"; } } as unknown as ReturnType); @@ -1630,16 +1642,27 @@ describe("runConversionExecutor fee-policy apply", () => { await runConversionExecutor(accountId); - expect(writes).toEqual(["applyFeePolicy"]); + expect(writes).toEqual(["applyFeePolicy", "read targetPpm"]); expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); // the cycle went on }); + it("does not re-send while an earlier apply is still unmined", async () => { + reads.pendingFeePolicyEffectiveAt = NOW; + receiptTimesOut = true; + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); // sent, receipt wait times out + await runConversionExecutor(accountId); // still looks pending on chain + + expect(writes.filter(write => write === "applyFeePolicy")).toEqual(["applyFeePolicy"]); + }); + it("sends nothing while no increase is pending or its timelock still runs", async () => { const { accountId } = await activeAccountWithDeposit(); await runConversionExecutor(accountId); reads.pendingFeePolicyEffectiveAt = NOW + 1n; await runConversionExecutor(accountId); - expect(writes).toEqual([]); + expect(writes.filter(write => write === "applyFeePolicy")).toEqual([]); }); it("logs a reverted apply and carries on with the cycle", 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 fa54614d9..60f3e5a2c 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1415,9 +1415,10 @@ async function sendPoke(forwarder: Address): Promise { /** * P11: finalizes a guardian fee increase whose timelock has elapsed (`applyFeePolicy` is * permissionless; nothing else would ever apply it). Waits for the receipt so the cycle - * then reads the applied policy. Best-effort like sendPoke: a revert (applied by someone - * else, clock skew) or a timeout is logged and the cycle goes on, since the contract always - * settles under its current on-chain policy. The monitor's config reconciliation mirrors + * then reads the applied policy, and skips while any keeper transaction is still unmined so + * a timed-out apply is not re-sent every cycle. Best-effort like sendPoke: a revert (applied + * by someone else, clock skew) or a timeout is logged and the cycle goes on, since the + * contract always settles under its current on-chain policy. The monitor's config reconciliation mirrors * the applied values into the account row. */ async function applyDueFeePolicy(forwarder: Address, effectiveAt: bigint): Promise { @@ -1429,7 +1430,16 @@ async function applyDueFeePolicy(forwarder: Address, effectiveAt: bigint): Promi const keeper = getKeeperWalletClient(); const call = { abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "applyFeePolicy" } as const; await client.simulateContract(call); - const hash = await withKeeperSendLock(() => keeper.writeContract({ ...call, chain: null })); + const hash = await withKeeperSendLock(async () => { + const [latest, pending] = await Promise.all([ + client.getTransactionCount({ address: keeper.account.address, blockTag: "latest" }), + client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }) + ]); + // An earlier apply (or any keeper send) still unmined: re-sending would only stack nonces. + if (pending !== latest) return null; + return keeper.writeContract({ ...call, chain: null }); + }); + if (hash === null) return; const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }); if (receipt.status !== "success") throw new Error(`transaction ${hash} reverted`); logger.info(`monerium-b2b: applied the pending fee policy on forwarder ${forwarder} (${hash})`); From 81517e2a91146e2339f86d504f029760d3ea2cc7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:22 +0200 Subject: [PATCH 12/80] test(api): stub the fee-policy read in the recovery executor fixture Without it the fixture fell into the apply path and logged a spurious getBlock failure on every run. --- apps/api/src/api/services/monerium-b2b/recovery.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/api/services/monerium-b2b/recovery.test.ts b/apps/api/src/api/services/monerium-b2b/recovery.test.ts index 73ed78e0c..f5346f004 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.test.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.test.ts @@ -1229,6 +1229,7 @@ describe("refund deadlines and orchestration", () => { balanceOf: 100n * EUR, batchOpenedAt: 1_000n, // long past the recovery delay minSwapAmount: 25n * EUR, + pendingFeePolicyEffectiveAt: 0n, perSwapCap: 10_000n * EUR }; spies.push( From 67e2e19ccb177d8b5a9b4aa85c58c630dc578bb0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:22 +0200 Subject: [PATCH 13/80] docs(api): note the unmined guard and idle-clone timing of fee applies --- docs/architecture-monerium-b2b-onramp.md | 4 +++- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 53f6e8ca1..112c2a230 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -437,7 +437,9 @@ contract settles every fill into three bands against that reference (decisions: lowering is immediate (registry P11). Swaps always use the currently applied policy. Once an increase is due, the keeper sends `applyFeePolicy()` in the account's next cycle, before pricing, and waits for it; a revert or timeout is logged and the cycle - goes on. + goes on, and no apply is re-sent while a keeper transaction is still unmined. The + executor only runs for accounts with a settling deposit, so an idle clone's increase + is applied before its next swap. - **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded from the treasury. It pays only when called by a factory-registered clone, to the clone itself (the subsidy is forwarded with the payment), within a guardian-settable diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index d7501ad53..f31fa65e3 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -42,7 +42,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; it skips the send while any keeper transaction is still unmined (latest and pending nonce differ), so a timed-out apply is not re-sent and stacked every cycle; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. ## Monitoring From c5a8736432dda8334bd2f94a9bb5ad964ba46b54 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:48:34 +0200 Subject: [PATCH 14/80] feat(shared): add a below_minimum deposit waiting reason --- docs/api/openapi/vortex.openapi.d.ts | 4 ++-- docs/api/openapi/vortex.openapi.json | 5 +++-- docs/api/pages/07-webhooks.md | 2 +- docs/api/wire-contract.snapshot.md | 10 +++++----- packages/shared/src/endpoints/webhook.endpoints.ts | 9 ++++++--- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 8c7081310..5fdfe1419 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -3054,10 +3054,10 @@ export interface components { txHash: string | null; /** @description Sum of the confirmed chunks' net USDC in 6-decimal base units: what the deposit's single transfer delivers once forwarded. */ usdcNetRaw: string; - /** @description Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise. */ + /** @description Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise. */ waiting: { /** @enum {string} */ - reason: "monerium_pending" | "account_not_active" | "oracle_unavailable" | "reference_unavailable" | "reference_out_of_band" | "no_route" | "below_floor"; + reason: "monerium_pending" | "account_not_active" | "oracle_unavailable" | "reference_unavailable" | "reference_out_of_band" | "no_route" | "below_floor" | "below_minimum"; /** Format: date-time */ since: string; } | null; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 456311b7b..70c2632ce 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2787,7 +2787,7 @@ "type": "string" }, "waiting": { - "description": "Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise.", + "description": "Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise.", "properties": { "reason": { "enum": [ @@ -2797,7 +2797,8 @@ "reference_unavailable", "reference_out_of_band", "no_route", - "below_floor" + "below_floor", + "below_minimum" ], "type": "string" }, diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 21f7783cd..bddc7e09c 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -265,7 +265,7 @@ Fired whenever anything about a deposit changes: the payment arrives at the EUR - **IDs:** `depositId`, `accountId`, `profileId` (your managed child, the `X-Managed-Profile-Id` value), `externalSubjectId` (your own client reference), and `moneriumProfileId` and `moneriumOrderId` (the EUR provider's IDs for the client and for the incoming payment). Every transaction hash is included: the mint (`txHash`), each chunk, the forward, and on a refund the recovery transaction and the provider's redemption order. - **Amounts:** `amount` is the EUR amount to the cent and `amountRaw` the same in 18-decimal base units. Each chunk carries the EURe it converted and its net USDC; the top-level `usdcNetRaw` is the sum of the confirmed chunks, which the single transfer delivers. - **Timestamps:** `receivedAt`, `mintedAt` (the conversion window counts from here), each chunk's `sentAt` and `confirmedAt`, `deliveredAt`, and on a refund `refund.startedAt` and `refund.refundedAt`. -- **Hold status:** `waiting` is set while the deposit waits. `monerium_pending` means the EUR provider has not minted it yet: it is minting or under a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can, and a payment still waiting when the promised conversion window ends is refunded), otherwise the reason Vortex is holding the next chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor`, when the market is below the client's floor by more than the subsidy currently allows. +- **Hold status:** `waiting` is set while the deposit waits. `monerium_pending` means the EUR provider has not minted it yet: it is minting or under a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor`, when the market is below the client's floor by more than the subsidy currently allows. - **Failure status:** `rejectedReason` holds the provider's reason when it returned the payment before minting (`status` is then `returned`). `refund.reason` says why a deposit is refunded: `window_missed`, `compliance`, `incident`, or `operator`. `eventId` is unique per snapshot. If retries deliver an older snapshot after a newer one, keep the one with the later `timestamp`. diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 3cc932995..87df99279 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -600,7 +600,7 @@ DepositSnapshot: { txHash: null | string; usdcNetRaw: string; waiting: null | { - reason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + reason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; since: string; }; } @@ -652,14 +652,14 @@ DepositUpdatedWebhookPayload: { txHash: null | string; usdcNetRaw: string; waiting: null | { - reason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + reason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; since: string; }; }; timestamp: string; } -DepositWaitingReason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable" +DepositWaitingReason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable" DepositWebhookPayloadBase: { accountId: string; @@ -2684,7 +2684,7 @@ WebhookDeliveryAttempt: { txHash: null | string; usdcNetRaw: string; waiting: null | { - reason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + reason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; since: string; }; }; @@ -2841,7 +2841,7 @@ WebhookPayload: { txHash: null | string; usdcNetRaw: string; waiting: null | { - reason: "account_not_active" | "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + reason: "account_not_active" | "below_floor" | "below_minimum" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; since: string; }; }; diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 084e12926..fd44b2e4c 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -188,8 +188,10 @@ export interface DepositReturnedWebhookPayload { /** * Why a deposit is waiting: `monerium_pending` until Monerium mints it (minting or a * compliance review, which Monerium does not tell apart), `account_not_active` while the - * account cannot convert (not activated yet, suspended, or paused for dormancy), otherwise - * the reason the keeper is holding the next conversion chunk. + * account cannot convert (not activated yet, suspended, or paused for dormancy), + * `below_minimum` while the unconverted amount is below the minimum swap (it waits for the + * refund path and holds back no later deposit), otherwise the reason the keeper is holding + * the next conversion chunk. */ export type DepositWaitingReason = | "monerium_pending" @@ -198,7 +200,8 @@ export type DepositWaitingReason = | "reference_unavailable" | "reference_out_of_band" | "no_route" - | "below_floor"; + | "below_floor" + | "below_minimum"; /** Why a deposit entered the refund path. */ export type DepositRefundReason = "window_missed" | "compliance" | "incident" | "operator"; From fb677bfcaa22d701e7769d43ed36ce734235babe Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:48:35 +0200 Subject: [PATCH 15/80] fix(api): keep sub-minimum deposits from stalling the keeper A payment below MIN_SWAP_FLOOR never arms the forwarder's batch marker, so recover() cannot move it and the oldest-first planner held every later deposit of the account until the refund deadline. The planner now passes over it with a below_minimum waiting reason, and the stranded balance monitor flags a marked refund the contract cannot recover so the operator refunds it by hand. --- .../monerium-b2b/conversion-executor.test.ts | 32 +++++- .../monerium-b2b/conversion-executor.ts | 59 ++++++---- .../services/monerium-b2b/monitoring.test.ts | 104 +++++++++++++++++- .../api/services/monerium-b2b/monitoring.ts | 30 +++++ docs/api/pages/14-managed-profiles.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 9 +- .../05-integrations/monerium-b2b.md | 4 +- 7 files changed, 207 insertions(+), 33 deletions(-) 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 7d68c51aa..11da103b6 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 @@ -217,11 +217,18 @@ describe("planAction", () => { it("does nothing for a sub-minimum remainder or an empty queue", () => { expect(planAction([withSwaps(deposit("a", MoneriumFiatDepositStatus.Minted, 10n * EUR), [])], base)).toMatchObject({ - kind: "none", - reason: expect.stringContaining("below the minimum swap") + kind: "none" }); expect(planAction([], base)).toMatchObject({ kind: "none" }); }); + + it("never lets a deposit below the minimum hold back a younger one", () => { + // The contract arms no batch below MIN_SWAP_FLOOR, so the small payment may never reach + // `recover`: waiting for it would stall every later payment of the account. + const small = withSwaps(deposit("small", MoneriumFiatDepositStatus.Minted, 10n * EUR), []); + const young = withSwaps(deposit("young", MoneriumFiatDepositStatus.Minted, 100n * EUR), []); + expect(planAction([small, young], base)).toMatchObject({ amountIn: 100n * EUR, deposit: young.deposit, kind: "swap" }); + }); }); describe("conversionAmountsFromSwapEvent", () => { @@ -975,13 +982,15 @@ describe("runConversionExecutor activation gate", () => { await runConversionExecutor(accountId); await deposit.reload(); expect(deposit.waitingReason).toBe("account_not_active"); - expect(deposit.waitingSince).toBeInstanceOf(Date); + const activationWait = deposit.waitingSince; + expect(activationWait).toBeInstanceOf(Date); await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); await runConversionExecutor(accountId); await deposit.reload(); - expect(deposit.waitingReason).toBeNull(); - expect(deposit.waitingSince).toBeNull(); + // The activation wait is over; what holds this small deposit now is the minimum swap. + expect(deposit.waitingReason).toBe("below_minimum"); + expect(deposit.waitingSince?.getTime()).not.toBe(activationWait?.getTime()); }); it("clears the reason on every queued deposit once the account converts, not only the next one", async () => { @@ -998,6 +1007,19 @@ describe("runConversionExecutor activation gate", () => { expect((await queued.reload()).waitingReason).toBeNull(); }); + it("tells the partner a payment below the minimum swap waits, and converts the next payment meanwhile", async () => { + const { accountId, deposits } = await accountWithDeposit(null, [10n, 100n]); + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + + await runConversionExecutor(accountId); + const [small, next] = deposits; + await small.reload(); + expect(small.waitingReason).toBe("below_minimum"); + expect(small.waitingSince).toBeInstanceOf(Date); + // The later payment is priced (and defers only because Chainlink is down here). + expect((await next.reload()).waitingReason).toBe("oracle_unavailable"); + }); + it("replaces an earlier hold reason and keeps when the wait started", async () => { const { accountId, deposit, since } = await accountWithDeposit("oracle_unavailable"); await MoneriumAccount.update({ status: MoneriumAccountStatus.Suspended }, { where: { id: accountId } }); 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 60f3e5a2c..b010d9ede 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -960,6 +960,7 @@ export function canConvert(account: Pick, @@ -1028,29 +1030,26 @@ export function planAction( if (!input.convertible) { return { kind: "none", reason: "account is not convertible" }; } - const next = deposits.find(({ deposit }) => deposit.status !== MoneriumFiatDepositStatus.Recovering); - if (!next) { - return { kind: "none", reason: "no settling deposit" }; - } - if (next.state.remainingEureRaw === 0n) { - if (next.state.usdcNetRaw === 0n) { - return { kind: "none", reason: `deposit ${next.deposit.id} has nothing to forward` }; + for (const { deposit, state } of deposits) { + if (deposit.status === MoneriumFiatDepositStatus.Recovering) continue; + if (state.remainingEureRaw === 0n) { + if (state.usdcNetRaw === 0n) { + return { kind: "none", reason: `deposit ${deposit.id} has nothing to forward` }; + } + return { deposit, kind: "forward", usdcRaw: state.usdcNetRaw }; } - return { deposit: next.deposit, kind: "forward", usdcRaw: next.state.usdcNetRaw }; - } - const amountIn = planChunk(next.state.remainingEureRaw, input.minSwapAmount, input.perSwapCap); - if (amountIn === null) { + const amountIn = planChunk(state.remainingEureRaw, input.minSwapAmount, input.perSwapCap); + // Below the minimum swap: it waits for the refund path and holds back no younger deposit + // (below MIN_SWAP_FLOOR the contract may never let the keeper recover it). + if (amountIn === null) continue; return { - kind: "none", - reason: `deposit ${next.deposit.id} has ${next.state.remainingEureRaw} raw EURe left, below the minimum swap` + amountIn, + deposit, + elapsedSeconds: chunkElapsedSeconds(deposit, state.lastSwapAt, input.nowMs), + kind: "swap" }; } - return { - amountIn, - deposit: next.deposit, - elapsedSeconds: chunkElapsedSeconds(next.deposit, next.state.lastSwapAt, input.nowMs), - kind: "swap" - }; + return { kind: "none", reason: "no deposit to convert" }; } // ------------------------------------------------------------------ executor @@ -1135,6 +1134,26 @@ export async function runConversionExecutor(accountId: string): Promise { if (recovered > 0) continue; withState.push({ deposit, state }); } + // Partner-visible: what planAction passes over below the minimum swap (never shadowing NOT_ACTIVE). + const belowMinimum = withState + .filter( + ({ deposit, state }) => + convertible && + deposit.status !== MoneriumFiatDepositStatus.Recovering && + deposit.waitingReason !== BELOW_MINIMUM && + state.remainingEureRaw > 0n && + state.remainingEureRaw < minSwapAmount + ) + .map(({ deposit }) => deposit.id); + if (belowMinimum.length > 0) { + await MoneriumFiatDeposit.update( + { + waitingReason: BELOW_MINIMUM, + waitingSince: sequelize.fn("COALESCE", sequelize.col("waiting_since"), sequelize.fn("NOW")) + }, + { transaction, where: { id: { [Op.in]: belowMinimum } } } + ); + } return planAction(withState, { batchOpenedAtSec: batchOpenedAt, convertible, 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 c56ab81a2..60ff79f23 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -1,6 +1,13 @@ -import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; -import { Hex } from "viem"; -import MoneriumAccount from "../../../models/moneriumAccount.model"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { Address, Hex } from "viem"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import { provisionMoneriumB2bAccount } from "./account-provisioning"; import * as chain from "./chain"; import { classifyExecutableDepth, @@ -12,7 +19,8 @@ import { diffAssociation, eip1167RuntimeCode, normalizeIban, - runExecutableDepthCheck + runExecutableDepthCheck, + runStrandedBalanceMonitor } from "./monitoring"; // Pure monitoring logic (implementation plan D3): quote-impact math against the T6 @@ -299,3 +307,91 @@ describe("runExecutableDepthCheck", () => { expect(quoteSpy).not.toHaveBeenCalled(); }); }); + +// A refund the keeper can never send: the clone holds less than MIN_SWAP_FLOOR, so the +// contract arms no batch and `recover` reverts. Only the operator can refund it. +describe("runStrandedBalanceMonitor below the swap floor", () => { + const FACTORY = "0x2222222222222222222222222222222222222222" as Address; + const EURE = "0x4444444444444444444444444444444444444444" as Address; + const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl }; + let errors: string[]; + + beforeAll(async () => { + config.moneriumB2b.rpcUrl = undefined; // provisioning skips the on-chain clone check + config.moneriumB2b.forwarderFactoryAddress = FACTORY; + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = saved.rpcUrl; + config.moneriumB2b.forwarderFactoryAddress = saved.factory; + }); + + beforeEach(async () => { + await resetTestDatabase(); + errors = []; + const reads: Record = { batchOpenedAt: 0n, MIN_SWAP_FLOOR: 1n * EUR, TRIGGER_DELAY: 86_400n }; + spyOn(chain, "getForwarderImmutables").mockResolvedValue({ + eure: EURE, + factory: FACTORY, + recoveryDelaySeconds: 7_200, + usdc: "0x6666666666666666666666666666666666666666" + } as unknown as chain.ForwarderImmutables); + spyOn(chain, "getPublicClient").mockReturnValue({ + readContract: async ({ address, functionName }: { address: Address; functionName: string }) => + functionName === "balanceOf" ? (address === EURE ? EUR / 2n : 0n) : reads[functionName] + } as unknown as ReturnType); + spyOn(logger, "error").mockImplementation(((message: string) => { + errors.push(message); + }) as unknown as typeof logger.error); + }); + + afterEach(() => mock.restore()); + + async function accountWithDeposit(status: MoneriumFiatDepositStatus) { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const { accountId } = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: "0x5555555555555555555555555555555555555555", + externalSubjectId: "client-1", + forwarderAddress: "0x1111111111111111111111111111111111111111", + managerProfileId: manager.id, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + await MoneriumAccount.update({ status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + return MoneriumFiatDeposit.create({ + accountId, + amountRaw: (EUR / 2n).toString(), + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + mintedAt: new Date(), + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status, + txHash: "0xorder1" + }); + } + + it("asks the operator to refund a payment marked for recovery that the contract cannot recover", async () => { + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + await runStrandedBalanceMonitor(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(deposit.id); + expect(errors[0]).toContain("refund them by hand"); + }); + + it("stays quiet while such a payment is not marked for recovery yet", async () => { + await accountWithDeposit(MoneriumFiatDepositStatus.Minted); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 4002c9821..39811a356 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -3,6 +3,11 @@ import { Address, formatUnits, Hex, parseAbi } from "viem"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; import { chainlinkAbi, @@ -38,6 +43,8 @@ import { refundAccountFor } from "./refund-wallet"; * longer than RECOVERY_DELAY (the promised window, registry P3) warn — the deposit * should be forwarded or recovering by then; past TRIGGER_DELAY (the * permissionless-trigger delay, registry P4) they error — a keeper-outage signal. + * A payment marked for recovery on a clone below MIN_SWAP_FLOOR errors too: the + * contract cannot recover it, so the operator refunds it by hand. * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault * (docs/architecture-monerium-b2b-onramp.md, fees section); a vault that cannot cover a * below-floor swap makes the keeper defer, so runway problems surface here first. @@ -385,6 +392,29 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }) ]); if (eureBalance < minSwapFloor && usdcBalance === 0n) { + // Below MIN_SWAP_FLOOR the contract arms no batch, so `recover` can never run for a + // payment marked for the refund path (one already recovered is the refund monitor's). + const marked = await MoneriumFiatDeposit.findAll({ + attributes: ["id"], + where: { accountId: account.id, status: MoneriumFiatDepositStatus.Recovering } + }); + if (marked.length === 0) continue; + const recovered = await MoneriumConversionExecution.findAll({ + attributes: ["depositId"], + where: { + depositId: { [Op.in]: marked.map(deposit => deposit.id) }, + kind: MoneriumConversionExecutionKind.Recover, + status: { [Op.in]: [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed] } + } + }); + const stuck = marked.filter(deposit => !recovered.some(execution => execution.depositId === deposit.id)); + if (stuck.length > 0) { + logger.error( + `monerium-b2b: REFUND NEEDS OPERATOR — deposit(s) ${stuck.map(deposit => deposit.id).join(", ")} on forwarder ` + + `${forwarder} (account ${account.id}) are marked for recovery, but the clone holds ${eureBalance} EURe, below ` + + "MIN_SWAP_FLOOR, so the contract cannot recover them: refund them by hand and close them as refunded (runbook §2.7)" + ); + } continue; } const severity = classifyStranding(batchOpenedAt, BigInt(recoveryDelaySeconds), triggerDelay, now); diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index f77f4ce19..5be30ac1e 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -199,7 +199,7 @@ X-Managed-Profile-Id: **Sandbox specifics** - **Network.** Sandbox converts and delivers on Ethereum Sepolia; production uses Ethereum mainnet. The destination must be an Ethereum address the client controls, and it is fixed for the life of the account. -- **Minimum.** Payments from EUR 1 are converted; a smaller payment is refunded after the refund window. +- **Minimum.** Payments from EUR 1 are converted. A smaller payment is not converted and never holds back later payments; its `waiting.reason` is `below_minimum`. After the refund window it enters the refund path (`recovering`), but the forwarding contract cannot move an amount below EUR 1 on its own, so operations usually refund it by hand and it can take longer than other refunds. - **Refund window.** A payment that is not converted within 15 minutes of its mint (two hours by default in production), for example because the account was not active yet, is refunded in full to the account it came from (operations process the refund by hand where automatic refunds are off). The EUR provider pays refunds out only for profiles it has approved. - **Webhook signatures.** Sandbox signs deliveries with its own key: verify them against `https://api-sandbox.vortexfinance.co/v1/public-key`, not the production key. See [Webhooks](https://api-docs.vortexfinance.co/webhooks). diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 112c2a230..650b316ff 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -361,7 +361,14 @@ and sends at most one transaction per account per cycle: per deposit after the forward is deep enough, with `conversions[]` per chunk and `forwardTxHash`. - **A remainder below `minSwapAmount`** (registry P6) cannot be swapped; it waits for - the refund path rather than merging with the next deposit. + the refund path (`waiting.reason` `below_minimum`) rather than merging with the next + deposit, and the keeper passes over it, so it holds back no younger deposit. A payment + below the immutable `MIN_SWAP_FLOOR` never arms the batch marker (`poke` refuses, by + design against dust), so `recover` cannot move it unless a larger balance keeps the + marker open for `RECOVERY_DELAY`; once marked `recovering` on a clone below the floor, + the stranded-balance monitor raises REFUND NEEDS OPERATOR and operations refund it by + hand (internal B2B runbook §2.7). A remainder left after chunk swaps is recoverable as + usual: its USDC keeps the marker armed. - **The refund path.** A deposit marked `recovering` — by an operator through the admin endpoint, or once automated by the missed window — is moved off the clone with `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index f31fa65e3..a8e0dcb8c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -39,7 +39,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. -5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; it skips the send while any keeper transaction is still unmined (latest and pending nonce differ), so a timed-out apply is not re-sent and stacked every cycle; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. 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). +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover` raises the error-level REFUND NEEDS OPERATOR line, and the operator refunds it by hand and closes it `refunded`. 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From 5101ff3e9d3ff0159368f139a678fde31bee3159 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:58:08 +0200 Subject: [PATCH 16/80] fix(api): ask for a manual refund only while funds are on the clone A recovering deposit whose funds left the clone through an unrecorded path (permissionless swap/forwardAll) would have been flagged for a manual refund, paying the client twice. The alert now also names the memo that keeps a later keeper recover exactly-once. --- .../services/monerium-b2b/monitoring.test.ts | 41 ++++++++++++++++-- .../api/services/monerium-b2b/monitoring.ts | 43 ++++++++++++++----- 2 files changed, 70 insertions(+), 14 deletions(-) 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 60ff79f23..511f0f092 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -4,6 +4,10 @@ import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; import { createTestUser } from "../../../test-utils/factories"; @@ -315,6 +319,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { const EURE = "0x4444444444444444444444444444444444444444" as Address; const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl }; let errors: string[]; + let warnings: string[]; beforeAll(async () => { config.moneriumB2b.rpcUrl = undefined; // provisioning skips the on-chain clone check @@ -330,6 +335,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { beforeEach(async () => { await resetTestDatabase(); errors = []; + warnings = []; const reads: Record = { batchOpenedAt: 0n, MIN_SWAP_FLOOR: 1n * EUR, TRIGGER_DELAY: 86_400n }; spyOn(chain, "getForwarderImmutables").mockResolvedValue({ eure: EURE, @@ -344,11 +350,14 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { spyOn(logger, "error").mockImplementation(((message: string) => { errors.push(message); }) as unknown as typeof logger.error); + spyOn(logger, "warn").mockImplementation(((message: string) => { + warnings.push(message); + }) as unknown as typeof logger.warn); }); afterEach(() => mock.restore()); - async function accountWithDeposit(status: MoneriumFiatDepositStatus) { + async function accountWithDeposit(status: MoneriumFiatDepositStatus, amountRaw = EUR / 2n) { const manager = await createTestUser(); await ManagedProfileManager.create({ allowedCorridors: ["EU"], @@ -367,7 +376,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { await MoneriumAccount.update({ status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); return MoneriumFiatDeposit.create({ accountId, - amountRaw: (EUR / 2n).toString(), + amountRaw: amountRaw.toString(), blockNumber: 100, chainId: 11155111, currency: "eur", @@ -386,7 +395,33 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { await runStrandedBalanceMonitor(); expect(errors).toHaveLength(1); expect(errors[0]).toContain(deposit.id); - expect(errors[0]).toContain("refund them by hand"); + expect(errors[0]).toContain("refund each by hand"); + expect(errors[0]).toContain("vortex-refund:"); + }); + + for (const status of [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) { + it(`stays quiet while the keeper's recover is ${status} (the automatic refund is under way)`, async () => { + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + await MoneriumConversionExecution.create({ + accountId: deposit.accountId, + depositId: deposit.id, + destination: "0x5555555555555555555555555555555555555555", + eureInRaw: deposit.amountRaw, + kind: MoneriumConversionExecutionKind.Recover, + status, + usdcNetRaw: "0" + }); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + }); + } + + it("never asks to refund a payment whose funds already left the clone", async () => { + // A 100 EUR payment converted and forwarded on the permissionless path, unseen by the ledger. + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, 100n * EUR); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + expect(warnings.some(message => message.includes(deposit.id) && message.includes("do not refund"))).toBe(true); }); it("stays quiet while such a payment is not marked for recovery yet", async () => { diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 39811a356..4e9f6311b 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -43,8 +43,9 @@ import { refundAccountFor } from "./refund-wallet"; * longer than RECOVERY_DELAY (the promised window, registry P3) warn — the deposit * should be forwarded or recovering by then; past TRIGGER_DELAY (the * permissionless-trigger delay, registry P4) they error — a keeper-outage signal. - * A payment marked for recovery on a clone below MIN_SWAP_FLOOR errors too: the - * contract cannot recover it, so the operator refunds it by hand. + * An unswapped payment marked for recovery whose EURe is still on a clone below + * MIN_SWAP_FLOOR errors too: the contract cannot recover it, so the operator refunds + * it by hand (one whose funds already left the clone only warns: reconcile). * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault * (docs/architecture-monerium-b2b-onramp.md, fees section); a vault that cannot cover a * below-floor swap makes the keeper defer, so runway problems surface here first. @@ -395,24 +396,44 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi // Below MIN_SWAP_FLOOR the contract arms no batch, so `recover` can never run for a // payment marked for the refund path (one already recovered is the refund monitor's). const marked = await MoneriumFiatDeposit.findAll({ - attributes: ["id"], + attributes: ["id", "amountRaw"], where: { accountId: account.id, status: MoneriumFiatDepositStatus.Recovering } }); if (marked.length === 0) continue; - const recovered = await MoneriumConversionExecution.findAll({ - attributes: ["depositId"], + const executions = await MoneriumConversionExecution.findAll({ + attributes: ["depositId", "kind"], where: { depositId: { [Op.in]: marked.map(deposit => deposit.id) }, - kind: MoneriumConversionExecutionKind.Recover, - status: { [Op.in]: [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed] } + [Op.or]: [ + { + kind: MoneriumConversionExecutionKind.Recover, + status: { [Op.in]: [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed] } + }, + { kind: MoneriumConversionExecutionKind.Swap, status: MoneriumConversionExecutionStatus.Confirmed } + ] } }); - const stuck = marked.filter(deposit => !recovered.some(execution => execution.depositId === deposit.id)); - if (stuck.length > 0) { + const stuck = marked.filter( + deposit => !executions.some(e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Recover) + ); + // Only an unswapped payment whose EURe is provably still on the clone is refunded by hand; + // anything else left the clone by a path the ledger does not record (the permissionless + // swap/forwardAll), so refunding it would pay twice. + const unswapped = stuck.filter(deposit => !executions.some(e => e.depositId === deposit.id)); + const onClone = unswapped.reduce((sum, deposit) => sum + BigInt(deposit.amountRaw), 0n) <= eureBalance ? unswapped : []; + const elsewhere = stuck.filter(deposit => !onClone.includes(deposit)); + if (onClone.length > 0) { logger.error( - `monerium-b2b: REFUND NEEDS OPERATOR — deposit(s) ${stuck.map(deposit => deposit.id).join(", ")} on forwarder ` + + `monerium-b2b: REFUND NEEDS OPERATOR — deposit(s) ${onClone.map(deposit => deposit.id).join(", ")} on forwarder ` + `${forwarder} (account ${account.id}) are marked for recovery, but the clone holds ${eureBalance} EURe, below ` + - "MIN_SWAP_FLOOR, so the contract cannot recover them: refund them by hand and close them as refunded (runbook §2.7)" + "MIN_SWAP_FLOOR, so the contract cannot recover them: refund each by hand as a redeem from the client's refund " + + "wallet with memo vortex-refund:, then close it as refunded at once (runbook §2.7)" + ); + } + if (elsewhere.length > 0) { + logger.warn( + `monerium-b2b: deposit(s) ${elsewhere.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + + `(account ${account.id}) are marked for recovery but their funds are no longer on the clone: reconcile, do not refund` ); } continue; From cd104cfd00359ff6d236a7e4ee49af0de89ffcab Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:58:08 +0200 Subject: [PATCH 17/80] docs(api): describe the manual refund alert and marker re-timing --- docs/architecture-monerium-b2b-onramp.md | 15 ++++++++++++--- .../security-spec/05-integrations/monerium-b2b.md | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 650b316ff..f17ad56e4 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -367,8 +367,13 @@ and sends at most one transaction per account per cycle: design against dust), so `recover` cannot move it unless a larger balance keeps the marker open for `RECOVERY_DELAY`; once marked `recovering` on a clone below the floor, the stranded-balance monitor raises REFUND NEEDS OPERATOR and operations refund it by - hand (internal B2B runbook §2.7). A remainder left after chunk swaps is recoverable as - usual: its USDC keeps the marker armed. + hand from the client's refund wallet under the memo `vortex-refund:` (so a + later keeper `recover` cannot pay it twice) and close it `refunded` (internal B2B + runbook §2.7). A remainder left after chunk swaps is recoverable as usual: its USDC + keeps the marker armed. Every younger deposit's `forward` re-times the marker, so a + passed-over remainder at or above the floor (possible only once `minSwapAmount` is + raised above `MIN_SWAP_FLOOR`) becomes recoverable only after a forward-free + `RECOVERY_DELAY`. - **The refund path.** A deposit marked `recovering` — by an operator through the admin endpoint, or once automated by the missed window — is moved off the clone with `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the @@ -522,7 +527,11 @@ read-only — no keys, no transactions: 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 — - a keeper-outage signal; funds are never at risk). + a keeper-outage signal; funds are never at risk). A deposit marked `recovering` on a + clone below `MIN_SWAP_FLOOR` without a pending or confirmed `recover`, unswapped and + with its EURe still on the clone, raises REFUND NEEDS OPERATOR (manual refund under + its memo, then close it `refunded`, internal B2B runbook §2.7); one whose funds left + the clone by an unrecorded path warns "reconcile, do not refund". 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized fee-policy changes (timelocked) are reconciled into the DB with a version bump; a destination change (no setter exists), bytecode or registration drift is a diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index a8e0dcb8c..ea9b712fd 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -39,7 +39,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. -5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; it skips the send while any keeper transaction is still unmined (latest and pending nonce differ), so a timed-out apply is not re-sent and stacked every cycle; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. 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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover` raises the error-level REFUND NEEDS OPERATOR line, and the operator refunds it by hand and closes it `refunded`. +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap and whose EURe (summed over such deposits) is still on the clone, raises the error-level REFUND NEEDS OPERATOR line, and the operator refunds it by hand as a redeem from the client's refund wallet with memo `vortex-refund:` (the orchestrator's exactly-once identity, so a later keeper `recover` cannot pay twice) and closes it `refunded`; any other such deposit's funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "reconcile, do not refund". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From 3e290f22ca313156ea8ff438172fe1981e8cbd80 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 19:07:07 +0200 Subject: [PATCH 18/80] fix(api): weigh the whole ledger before a manual refund alert The check compared only the stuck deposits' EURe with the clone balance, so EURe of a younger settling deposit or left by an earlier manual refund could stand in for funds that already left on the permissionless path, prompting a double refund. --- .../services/monerium-b2b/monitoring.test.ts | 56 +++++++++++++++---- .../api/services/monerium-b2b/monitoring.ts | 52 +++++++++++++---- docs/architecture-monerium-b2b-onramp.md | 12 ++-- .../05-integrations/monerium-b2b.md | 2 +- 4 files changed, 92 insertions(+), 30 deletions(-) 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 511f0f092..7b4e329c3 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -374,19 +374,37 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" }); await MoneriumAccount.update({ status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + return addDeposit(accountId, status, amountRaw); + } + + let orders = 0; + function addDeposit(accountId: string, status: MoneriumFiatDepositStatus, amountRaw: bigint) { + orders += 1; return MoneriumFiatDeposit.create({ accountId, amountRaw: amountRaw.toString(), blockNumber: 100, chainId: 11155111, currency: "eur", - logIndex: 1, + logIndex: orders, mintedAt: new Date(), - moneriumOrderId: "order-1", + moneriumOrderId: `order-${orders}`, payerIban: "DE89370400440532013000", payerName: "Payer GmbH", status, - txHash: "0xorder1" + txHash: `0xorder${orders}` + }); + } + + function execution(deposit: MoneriumFiatDeposit, kind: MoneriumConversionExecutionKind, status: MoneriumConversionExecutionStatus) { + return MoneriumConversionExecution.create({ + accountId: deposit.accountId, + depositId: deposit.id, + destination: "0x5555555555555555555555555555555555555555", + eureInRaw: deposit.amountRaw, + kind, + status, + usdcNetRaw: "0" }); } @@ -402,15 +420,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { for (const status of [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) { it(`stays quiet while the keeper's recover is ${status} (the automatic refund is under way)`, async () => { const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); - await MoneriumConversionExecution.create({ - accountId: deposit.accountId, - depositId: deposit.id, - destination: "0x5555555555555555555555555555555555555555", - eureInRaw: deposit.amountRaw, - kind: MoneriumConversionExecutionKind.Recover, - status, - usdcNetRaw: "0" - }); + await execution(deposit, MoneriumConversionExecutionKind.Recover, status); await runStrandedBalanceMonitor(); expect(errors).toEqual([]); }); @@ -424,6 +434,28 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(warnings.some(message => message.includes(deposit.id) && message.includes("do not refund"))).toBe(true); }); + // EURe the ledger places on the clone for other payments: a younger one still settling, or + // one refunded by hand (its EURe never left the clone). + for (const other of [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Refunded]) { + it(`does not count a ${other} payment's EURe as the marked payment's`, async () => { + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, (EUR * 3n) / 10n); + await addDeposit(deposit.accountId, other, (EUR * 4n) / 10n); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + expect(warnings.some(message => message.includes(deposit.id) && message.includes("do not refund"))).toBe(true); + }); + } + + it("still asks to refund when the clone holds the marked payment and the others' EURe", async () => { + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, (EUR * 2n) / 10n); + await addDeposit(deposit.accountId, MoneriumFiatDepositStatus.Minted, (EUR * 3n) / 10n); + const recovered = await addDeposit(deposit.accountId, MoneriumFiatDepositStatus.Refunded, EUR); + await execution(recovered, MoneriumConversionExecutionKind.Recover, MoneriumConversionExecutionStatus.Confirmed); + await runStrandedBalanceMonitor(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(deposit.id); + }); + it("stays quiet while such a payment is not marked for recovery yet", async () => { await accountWithDeposit(MoneriumFiatDepositStatus.Minted); await runStrandedBalanceMonitor(); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 4e9f6311b..c20b1ae32 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -25,6 +25,7 @@ import { readSubsidyVaultState, SubsidyVaultState } from "./chain"; +import { settlementState } from "./conversion-executor"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; import { refundAccountFor } from "./refund-wallet"; @@ -395,15 +396,30 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi if (eureBalance < minSwapFloor && usdcBalance === 0n) { // Below MIN_SWAP_FLOOR the contract arms no batch, so `recover` can never run for a // payment marked for the refund path (one already recovered is the refund monitor's). - const marked = await MoneriumFiatDeposit.findAll({ - attributes: ["id", "amountRaw"], + const marked = await MoneriumFiatDeposit.count({ where: { accountId: account.id, status: MoneriumFiatDepositStatus.Recovering } }); - if (marked.length === 0) continue; + if (marked === 0) continue; + // Every deposit whose EURe the ledger may still place on the clone: settling, marked, or + // refunded (a manual refund leaves its EURe there; a confirmed `recover` took it away). + const deposits = await MoneriumFiatDeposit.findAll({ + attributes: ["id", "amountRaw", "status"], + where: { + accountId: account.id, + status: { + [Op.in]: [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Converting, + MoneriumFiatDepositStatus.Recovering, + MoneriumFiatDepositStatus.Refunded + ] + } + } + }); const executions = await MoneriumConversionExecution.findAll({ - attributes: ["depositId", "kind"], + attributes: ["depositId", "eureInRaw", "kind", "status"], where: { - depositId: { [Op.in]: marked.map(deposit => deposit.id) }, + depositId: { [Op.in]: deposits.map(deposit => deposit.id) }, [Op.or]: [ { kind: MoneriumConversionExecutionKind.Recover, @@ -413,14 +429,26 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi ] } }); - const stuck = marked.filter( - deposit => !executions.some(e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Recover) + const swapsOf = (deposit: MoneriumFiatDeposit) => + executions.filter(e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Swap); + const recoverOf = (deposit: MoneriumFiatDeposit, statuses: MoneriumConversionExecutionStatus[]) => + executions.some( + e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Recover && statuses.includes(e.status) + ); + const stuck = deposits.filter( + deposit => + deposit.status === MoneriumFiatDepositStatus.Recovering && + !recoverOf(deposit, [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) ); - // Only an unswapped payment whose EURe is provably still on the clone is refunded by hand; - // anything else left the clone by a path the ledger does not record (the permissionless - // swap/forwardAll), so refunding it would pay twice. - const unswapped = stuck.filter(deposit => !executions.some(e => e.depositId === deposit.id)); - const onClone = unswapped.reduce((sum, deposit) => sum + BigInt(deposit.amountRaw), 0n) <= eureBalance ? unswapped : []; + // Only an unswapped payment is refunded by hand, and only while the clone still holds + // all the EURe the ledger places there (every payment's unswapped, unrecovered rest); + // anything less means EURe left by a path the ledger does not record (the permissionless + // swap/forwardAll), so refunding could pay twice. + const unswapped = stuck.filter(deposit => swapsOf(deposit).length === 0); + const ledgerEure = deposits + .filter(deposit => !recoverOf(deposit, [MoneriumConversionExecutionStatus.Confirmed])) + .reduce((sum, deposit) => sum + settlementState(deposit, swapsOf(deposit)).remainingEureRaw, 0n); + const onClone = ledgerEure <= eureBalance ? unswapped : []; const elsewhere = stuck.filter(deposit => !onClone.includes(deposit)); if (onClone.length > 0) { logger.error( diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index f17ad56e4..4b69246af 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -527,11 +527,13 @@ read-only — no keys, no transactions: 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 — - a keeper-outage signal; funds are never at risk). A deposit marked `recovering` on a - clone below `MIN_SWAP_FLOOR` without a pending or confirmed `recover`, unswapped and - with its EURe still on the clone, raises REFUND NEEDS OPERATOR (manual refund under - its memo, then close it `refunded`, internal B2B runbook §2.7); one whose funds left - the clone by an unrecorded path warns "reconcile, do not refund". + a keeper-outage signal; funds are never at risk). An unswapped deposit marked + `recovering` without a pending or confirmed `recover`, on a clone below + `MIN_SWAP_FLOOR` that still holds all the EURe the ledger places there (every + deposit's unswapped, unrecovered rest, a manual refund's EURe included), raises REFUND + NEEDS OPERATOR (manual refund under its memo, then close it `refunded`, internal B2B + runbook §2.7); otherwise its funds may have left the clone by an unrecorded path, so + it warns "reconcile, do not refund". 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized fee-policy changes (timelocked) are reconciled into the DB with a version bump; a destination change (no setter exists), bytecode or registration drift is a diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index ea9b712fd..aadfe3a3c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. 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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap and whose EURe (summed over such deposits) is still on the clone, raises the error-level REFUND NEEDS OPERATOR line, and the operator refunds it by hand as a redeem from the client's refund wallet with memo `vortex-refund:` (the orchestrator's exactly-once identity, so a later keeper `recover` cannot pay twice) and closes it `refunded`; any other such deposit's funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "reconcile, do not refund". +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line, and the operator refunds it by hand as a redeem from the client's refund wallet with memo `vortex-refund:` (the orchestrator's exactly-once identity, so a later keeper `recover` cannot pay twice) and closes it `refunded`. It fires only while the clone holds at least all the EURe the ledger places there: every minted, converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a manual refund leaves its EURe on the clone). A swapped deposit, or any shortfall, means funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "reconcile, do not refund". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From 9af2c0f3617495bf9ac758550af7aa9712cf8f24 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 19:07:08 +0200 Subject: [PATCH 19/80] test(api): cover the swapped-deposit guard of the manual refund alert --- .../api/src/api/services/monerium-b2b/monitoring.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 7b4e329c3..85eaf452f 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -434,6 +434,15 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(warnings.some(message => message.includes(deposit.id) && message.includes("do not refund"))).toBe(true); }); + it("never asks to refund a swapped payment, even when the clone holds as much EURe", async () => { + // Its USDC left on the unrecorded forwardAll; the 0.5 EURe on the clone is someone else's. + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, (EUR * 4n) / 10n); + await execution(deposit, MoneriumConversionExecutionKind.Swap, MoneriumConversionExecutionStatus.Confirmed); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + expect(warnings.some(message => message.includes(deposit.id) && message.includes("do not refund"))).toBe(true); + }); + // EURe the ledger places on the clone for other payments: a younger one still settling, or // one refunded by hand (its EURe never left the clone). for (const other of [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Refunded]) { From e7b00e75265431dfd2e404762cf0f6190c699865 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 19:36:25 +0200 Subject: [PATCH 20/80] docs(api): count poke and fee applies apart from the cycle's send --- apps/api/src/api/services/monerium-b2b/conversion-executor.ts | 3 ++- docs/architecture-monerium-b2b-onramp.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 b010d9ede..1557121f4 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1055,7 +1055,8 @@ export function planAction( // ------------------------------------------------------------------ executor /** - * Runs one keeper cycle for an account: at most one transaction. Safe to call for + * Runs one keeper cycle for an account: at most one value-moving transaction (plus the + * permissionless poke and applyFeePolicy). Safe to call for * accounts with nothing to do (cheap chain reads, then returns). */ export async function runConversionExecutor(accountId: string): Promise { diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 4b69246af..a6840014d 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -344,7 +344,8 @@ protective stranding marker still arms). ## Chunking, forwarding and the refund path The keeper serves **one deposit at a time** per account, oldest chain-indexed mint first, -and sends at most one transaction per account per cycle: +and sends at most one value-moving transaction per account per cycle (plus the +permissionless `poke` and `applyFeePolicy`): - **A large deposit is chunked; the client still gets one transfer.** `swap` takes an explicit `amountIn`: at most `perSwapCap`, and never leaving a sub-minimum dust From 6e68fdf63d60fcb2608378da2fdb4f2c46b378c3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 19:47:46 +0200 Subject: [PATCH 21/80] fix(api): clear below_minimum once a deposit meets a lowered minimum --- .../monerium-b2b/conversion-executor.test.ts | 21 ++++++++++++++++++- .../monerium-b2b/conversion-executor.ts | 10 +++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) 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 11da103b6..49790c572 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 @@ -906,11 +906,13 @@ describe("runConversionExecutor activation gate", () => { config.moneriumB2b.forwarderFactoryAddress = saved.factory; }); + let reads: Record; + beforeEach(async () => { await resetTestDatabase(); // A clone holding a deposit below the minimum swap: nothing is sent either way, so the // cycle exercises only the gate's partner-visible reason. - const reads: Record = { + reads = { batchOpenedAt: 1n, floorPpm: 1_500, latestRoundData: [1n, 0n, 0n, 0n, 1n], // Chainlink down: a planned swap defers before sending anything @@ -1020,6 +1022,23 @@ describe("runConversionExecutor activation gate", () => { expect((await next.reload()).waitingReason).toBe("oracle_unavailable"); }); + it("clears below_minimum on a queued deposit once the guardian lowers the minimum swap", async () => { + const { accountId, deposits } = await accountWithDeposit(null, [100n, 10n]); + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + const [older, small] = deposits; + + await runConversionExecutor(accountId); + expect((await small.reload()).waitingReason).toBe("below_minimum"); + + reads.minSwapAmount = 5n * EUR; + await runConversionExecutor(accountId); + // The older deposit is still the one planned; the small one no longer waits for the refund path. + expect((await older.reload()).waitingReason).toBe("oracle_unavailable"); + await small.reload(); + expect(small.waitingReason).toBeNull(); + expect(small.waitingSince).toBeNull(); + }); + it("replaces an earlier hold reason and keeps when the wait started", async () => { const { accountId, deposit, since } = await accountWithDeposit("oracle_unavailable"); await MoneriumAccount.update({ status: MoneriumAccountStatus.Suspended }, { where: { id: accountId } }); 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 1557121f4..90bb11e63 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1155,6 +1155,16 @@ export async function runConversionExecutor(accountId: string): Promise { { transaction, where: { id: { [Op.in]: belowMinimum } } } ); } + // A lowered minimum ends that wait even for a deposit queued behind the one planned. + const nowAboveMinimum = withState + .filter(({ deposit, state }) => deposit.waitingReason === BELOW_MINIMUM && state.remainingEureRaw >= minSwapAmount) + .map(({ deposit }) => deposit.id); + if (nowAboveMinimum.length > 0) { + await MoneriumFiatDeposit.update( + { waitingReason: null, waitingSince: null }, + { transaction, where: { id: { [Op.in]: nowAboveMinimum } } } + ); + } return planAction(withState, { batchOpenedAtSec: batchOpenedAt, convertible, From d85a0ac715051bc842d9ad669b07023c34c69299 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 10:54:41 +0200 Subject: [PATCH 22/80] fix(api): prescribe an eure top-up for refunds below the swap floor A hand refund of a payment stuck below MIN_SWAP_FLOOR left its EURe on the clone, bypassed the one-refund-per-client gate, and left no recovery record. Topping the clone up to one wei above the floor lets poke arm the marker, so the keeper's normal recover and refund pipeline refund the payer's own EURe after RECOVERY_DELAY; the top-up stays on the clone below the floor and the odd wei keeps it unattributed. The alert now names the clone's balance in decimal EURe, the exact top-up (decimal and raw) and the token, and the funds-left warning no longer claims that all funds left a partly swapped deposit. --- .../services/monerium-b2b/monitoring.test.ts | 51 +++++++++++++++++-- .../api/services/monerium-b2b/monitoring.ts | 26 +++++++--- docs/architecture-monerium-b2b-onramp.md | 31 ++++++++--- .../05-integrations/monerium-b2b.md | 4 +- 4 files changed, 89 insertions(+), 23 deletions(-) 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 85eaf452f..fc836ea27 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -320,6 +320,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl }; let errors: string[]; let warnings: string[]; + let eureOnClone: bigint; beforeAll(async () => { config.moneriumB2b.rpcUrl = undefined; // provisioning skips the on-chain clone check @@ -336,6 +337,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { await resetTestDatabase(); errors = []; warnings = []; + eureOnClone = EUR / 2n; const reads: Record = { batchOpenedAt: 0n, MIN_SWAP_FLOOR: 1n * EUR, TRIGGER_DELAY: 86_400n }; spyOn(chain, "getForwarderImmutables").mockResolvedValue({ eure: EURE, @@ -345,7 +347,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { } as unknown as chain.ForwarderImmutables); spyOn(chain, "getPublicClient").mockReturnValue({ readContract: async ({ address, functionName }: { address: Address; functionName: string }) => - functionName === "balanceOf" ? (address === EURE ? EUR / 2n : 0n) : reads[functionName] + functionName === "balanceOf" ? (address === EURE ? eureOnClone : 0n) : reads[functionName] } as unknown as ReturnType); spyOn(logger, "error").mockImplementation(((message: string) => { errors.push(message); @@ -408,13 +410,48 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { }); } - it("asks the operator to refund a payment marked for recovery that the contract cannot recover", async () => { + it("asks the operator for the exact top-up that lets the keeper recover a payment below the floor", async () => { const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); await runStrandedBalanceMonitor(); expect(errors).toHaveLength(1); + expect(errors[0]).toContain("REFUND NEEDS OPERATOR"); expect(errors[0]).toContain(deposit.id); - expect(errors[0]).toContain("refund each by hand"); - expect(errors[0]).toContain("vortex-refund:"); + expect(errors[0]).toContain("0x1111111111111111111111111111111111111111"); + expect(errors[0]).toContain("the clone holds 0.5 EURe"); + // MIN_SWAP_FLOOR - balance + 1 wei: arms the marker, and never matches an order by amount. + expect(errors[0]).toContain("send exactly 0.500000000000000001 EURe (500000000000000001 raw)"); + expect(errors[0]).toContain(EURE); + expect(errors[0]).toContain("never refund this deposit by hand"); + expect(errors[0]).toContain("the internal B2B runbook §2.7"); + }); + + it("asks for the top-up when the keeper's recover failed, since the funds stayed on the clone", async () => { + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + await execution(deposit, MoneriumConversionExecutionKind.Recover, MoneriumConversionExecutionStatus.Failed); + await runStrandedBalanceMonitor(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(deposit.id); + }); + + it("goes quiet once the top-up landed, without calling the clone's EURe unrecorded", async () => { + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + // The mint watcher records the top-up as an unattributed Minted row. + await MoneriumFiatDeposit.create({ + accountId: deposit.accountId, + amountRaw: (EUR / 2n + 1n).toString(), + blockNumber: 101, + chainId: 11155111, + currency: "eur", + logIndex: 99, + mintedAt: new Date(), + moneriumOrderId: "unattr:topup", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xtopup" + }); + eureOnClone = EUR + 1n; + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + expect(warnings).toEqual([]); }); for (const status of [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) { @@ -440,7 +477,11 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { await execution(deposit, MoneriumConversionExecutionKind.Swap, MoneriumConversionExecutionStatus.Confirmed); await runStrandedBalanceMonitor(); expect(errors).toEqual([]); - expect(warnings.some(message => message.includes(deposit.id) && message.includes("do not refund"))).toBe(true); + // Its EURe remainder may still be on the clone: the warning must not claim all of it left. + const warning = warnings.find(message => message.includes(deposit.id)); + expect(warning).toContain("part or all of their funds left the clone: reconcile before refunding"); + expect(warning).toContain("do not top up the clone"); + expect(warning).not.toContain("are no longer on the clone"); }); // EURe the ledger places on the clone for other payments: a younger one still settling, or diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index c20b1ae32..124d535ce 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -45,8 +45,8 @@ import { refundAccountFor } from "./refund-wallet"; * should be forwarded or recovering by then; past TRIGGER_DELAY (the * permissionless-trigger delay, registry P4) they error — a keeper-outage signal. * An unswapped payment marked for recovery whose EURe is still on a clone below - * MIN_SWAP_FLOOR errors too: the contract cannot recover it, so the operator refunds - * it by hand (one whose funds already left the clone only warns: reconcile). + * MIN_SWAP_FLOOR errors too: the contract cannot recover it until the operator tops the + * clone up to the floor (one whose funds already left the clone only warns: reconcile). * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault * (docs/architecture-monerium-b2b-onramp.md, fees section); a vault that cannot cover a * below-floor swap makes the keeper defer, so runway problems surface here first. @@ -402,6 +402,7 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi if (marked === 0) continue; // Every deposit whose EURe the ledger may still place on the clone: settling, marked, or // refunded (a manual refund leaves its EURe there; a confirmed `recover` took it away). + // An operator top-up is an unattributed Minted row and stays on the clone too. const deposits = await MoneriumFiatDeposit.findAll({ attributes: ["id", "amountRaw", "status"], where: { @@ -440,10 +441,11 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi deposit.status === MoneriumFiatDepositStatus.Recovering && !recoverOf(deposit, [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) ); - // Only an unswapped payment is refunded by hand, and only while the clone still holds + // Only an unswapped payment gets the top-up remedy, and only while the clone still holds // all the EURe the ledger places there (every payment's unswapped, unrecovered rest); // anything less means EURe left by a path the ledger does not record (the permissionless - // swap/forwardAll), so refunding could pay twice. + // swap/forwardAll), so refunding could pay twice. A top-up that landed lifts the clone to + // the floor, so this branch is skipped until the keeper's `recover` took the payer's EURe. const unswapped = stuck.filter(deposit => swapsOf(deposit).length === 0); const ledgerEure = deposits .filter(deposit => !recoverOf(deposit, [MoneriumConversionExecutionStatus.Confirmed])) @@ -451,17 +453,25 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi const onClone = ledgerEure <= eureBalance ? unswapped : []; const elsewhere = stuck.filter(deposit => !onClone.includes(deposit)); if (onClone.length > 0) { + // A top-up to one wei above the floor lets `poke` arm the marker, and the keeper's + // normal `recover` then refunds the payer's own EURe; the top-up stays on the clone + // below the floor. The odd wei keeps the mint watcher from matching it to an order. + const topUpRaw = minSwapFloor - eureBalance + 1n; logger.error( `monerium-b2b: REFUND NEEDS OPERATOR — deposit(s) ${onClone.map(deposit => deposit.id).join(", ")} on forwarder ` + - `${forwarder} (account ${account.id}) are marked for recovery, but the clone holds ${eureBalance} EURe, below ` + - "MIN_SWAP_FLOOR, so the contract cannot recover them: refund each by hand as a redeem from the client's refund " + - "wallet with memo vortex-refund:, then close it as refunded at once (runbook §2.7)" + `${forwarder} (account ${account.id}) are marked for recovery, but the clone holds ${formatUnits(eureBalance, 18)} ` + + `EURe, below MIN_SWAP_FLOOR, so the contract cannot recover them: send exactly ${formatUnits(topUpRaw, 18)} EURe ` + + `(${topUpRaw} raw) of EURe ${eure} to the clone from any wallet (e.g. the float). The keeper then arms the marker ` + + "and refunds through the normal recover path after RECOVERY_DELAY, one deposit at a time (this alert returns " + + "with a new amount for any left below the floor); the top-up stays on the clone as Vortex EURe; expect one " + + "unattributed-mint warn; never refund this deposit by hand (the internal B2B runbook §2.7)" ); } if (elsewhere.length > 0) { logger.warn( `monerium-b2b: deposit(s) ${elsewhere.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + - `(account ${account.id}) are marked for recovery but their funds are no longer on the clone: reconcile, do not refund` + `(account ${account.id}) are marked for recovery, but part or all of their funds left the clone: reconcile ` + + "before refunding; do not refund and do not top up the clone" ); } continue; diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index a6840014d..fa333c4f7 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -367,10 +367,23 @@ permissionless `poke` and `applyFeePolicy`): below the immutable `MIN_SWAP_FLOOR` never arms the batch marker (`poke` refuses, by design against dust), so `recover` cannot move it unless a larger balance keeps the marker open for `RECOVERY_DELAY`; once marked `recovering` on a clone below the floor, - the stranded-balance monitor raises REFUND NEEDS OPERATOR and operations refund it by - hand from the client's refund wallet under the memo `vortex-refund:` (so a - later keeper `recover` cannot pay it twice) and close it `refunded` (internal B2B - runbook §2.7). A remainder left after chunk swaps is recoverable as usual: its USDC + the stranded-balance monitor raises REFUND NEEDS OPERATOR with the exact top-up: + `MIN_SWAP_FLOOR - clone EURe + 1 wei` of EURe, which operations send to the clone from + any wallet (e.g. the float; internal B2B runbook §2.7). The clone then holds the floor, + `poke` arms the marker, and after `RECOVERY_DELAY` the keeper's normal + `recover(remaining EURe, 0)` and refund pipeline below refund the payer exactly, from + the payer's own EURe, with the full record (`recover` transaction, redeem order, + `refundedAt`); the deposit is never refunded by hand. The top-up stays on the clone + below the floor, so `recover` switches the marker off by itself: no follow-up step. The + odd wei makes it a non-whole-cent amount the mint watcher can never match to a + Monerium order, so it is recorded as an `unattr:` Minted row (one warn) that the keeper + never converts. Leftover top-ups are Vortex EURe and stay below EUR 1 per clone; a + later sub-floor payment on that clone that reaches the floor with them arms the marker + and is refunded with no operator step. The top-up may also be sent early, as soon as + the payment shows `below_minimum`: the marker then arms at once and the refund lands at + about the normal deadline instead of a `RECOVERY_DELAY` after the alert. Monerium's + minimum redeem order is EUR 0.01, so a sub-EUR-1 refund is allowed (not yet rehearsed + end to end). A remainder left after chunk swaps is recoverable as usual: its USDC keeps the marker armed. Every younger deposit's `forward` re-times the marker, so a passed-over remainder at or above the floor (possible only once `minSwapAmount` is raised above `MIN_SWAP_FLOOR`) becomes recoverable only after a forward-free @@ -531,10 +544,12 @@ read-only — no keys, no transactions: a keeper-outage signal; funds are never at risk). An unswapped deposit marked `recovering` without a pending or confirmed `recover`, on a clone below `MIN_SWAP_FLOOR` that still holds all the EURe the ledger places there (every - deposit's unswapped, unrecovered rest, a manual refund's EURe included), raises REFUND - NEEDS OPERATOR (manual refund under its memo, then close it `refunded`, internal B2B - runbook §2.7); otherwise its funds may have left the clone by an unrecorded path, so - it warns "reconcile, do not refund". + deposit's unswapped, unrecovered rest, a legacy hand refund's EURe and `unattr:` + top-ups included), raises REFUND NEEDS OPERATOR with the exact EURe top-up that lets + the keeper recover it (the refund path above, internal B2B runbook §2.7); a landed + top-up lifts the clone to the floor and quiets it. Otherwise part or all of its funds + may have left the clone by an unrecorded path, so it warns "reconcile before + refunding; do not refund and do not top up the clone". 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized fee-policy changes (timelocked) are reconciled into the DB with a version bump; a destination change (no setter exists), bytecode or registration drift is a diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index aadfe3a3c..363132079 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -39,7 +39,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. -5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; on a clone that holds less than `MIN_SWAP_FLOOR` EURe and no USDC the marker never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; it skips the send while any keeper transaction is still unmined (latest and pending nonce differ), so a timed-out apply is not re-sent and stacked every cycle; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. 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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line, and the operator refunds it by hand as a redeem from the client's refund wallet with memo `vortex-refund:` (the orchestrator's exactly-once identity, so a later keeper `recover` cannot pay twice) and closes it `refunded`. It fires only while the clone holds at least all the EURe the ledger places there: every minted, converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a manual refund leaves its EURe on the clone). A swapped deposit, or any shortfall, means funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "reconcile, do not refund". +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (decimal and raw) of the EURe token, which the operator sends to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The odd wei makes the amount a non-whole-cent value that the mint watcher can never match to a Monerium order by amount, so it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From 3bf2881cd4dc23ae9d8607fb7f1dc89829661e98 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 10:55:18 +0200 Subject: [PATCH 23/80] fix(api): skip the sub-floor refund check while a recover is pending A recover that is mined but whose execution row is still Pending has already taken its EURe, yet the ledger still counted it, so a sibling stuck deposit got "reconcile, do not refund" instead of the operator alert. The keeper confirms the row on its next cycle; waiting one monitoring pass gives the right verdict. --- .../src/api/services/monerium-b2b/monitoring.test.ts | 10 ++++++++++ apps/api/src/api/services/monerium-b2b/monitoring.ts | 3 +++ docs/architecture-monerium-b2b-onramp.md | 4 +++- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) 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 fc836ea27..0d06e5c23 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -463,6 +463,16 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { }); } + it("waits for a sibling's mined recover to confirm before judging the clone's EURe", async () => { + // D2's recover is mined (the clone holds only D1's 0.5 EURe) but its row is still Pending. + const stuck = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + const recovering = await addDeposit(stuck.accountId, MoneriumFiatDepositStatus.Recovering, (EUR * 3n) / 10n); + await execution(recovering, MoneriumConversionExecutionKind.Recover, MoneriumConversionExecutionStatus.Pending); + await runStrandedBalanceMonitor(); + expect(warnings.filter(message => message.includes(stuck.id))).toEqual([]); + expect(errors).toEqual([]); + }); + it("never asks to refund a payment whose funds already left the clone", async () => { // A 100 EUR payment converted and forwarded on the permissionless path, unseen by the ledger. const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, 100n * EUR); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 124d535ce..3739264a2 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -436,6 +436,9 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi executions.some( e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Recover && statuses.includes(e.status) ); + // A mined `recover` whose row is still Pending already took its EURe, which the ledger + // below would still count: wait for the keeper to confirm it before judging the rest. + if (deposits.some(deposit => recoverOf(deposit, [MoneriumConversionExecutionStatus.Pending]))) continue; const stuck = deposits.filter( deposit => deposit.status === MoneriumFiatDepositStatus.Recovering && diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index fa333c4f7..bc76d60bb 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -549,7 +549,9 @@ read-only — no keys, no transactions: the keeper recover it (the refund path above, internal B2B runbook §2.7); a landed top-up lifts the clone to the floor and quiets it. Otherwise part or all of its funds may have left the clone by an unrecorded path, so it warns "reconcile before - refunding; do not refund and do not top up the clone". + refunding; do not refund and do not top up the clone". The pass skips that account + while any of its `recover` executions is still pending: a mined `recover` not yet + confirmed would make the ledger overstate the clone. 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized fee-policy changes (timelocked) are reconciled into the DB with a version bump; a destination change (no setter exists), bytecode or registration drift is a diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 363132079..6479357cc 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. 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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (decimal and raw) of the EURe token, which the operator sends to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The odd wei makes the amount a non-whole-cent value that the mint watcher can never match to a Monerium order by amount, so it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (decimal and raw) of the EURe token, which the operator sends to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The odd wei makes the amount a non-whole-cent value that the mint watcher can never match to a Monerium order by amount, so it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From 2cf793ac02ab7603e39d75e86dad297b88205c24 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 10:56:44 +0200 Subject: [PATCH 24/80] fix(api): alert on marked deposits the keeper never recovers A deposit marked recovering on a clone at or above the floor can stay ineligible for recover indefinitely when younger forwards keep re-timing the batch marker, and no monitor reported it. Warn once it has waited RECOVERY_DELAY plus an hour without a pending or confirmed recover, and error past TRIGGER_DELAY; the sub-floor case keeps its own operator alert. --- .../services/monerium-b2b/monitoring.test.ts | 75 ++++++++++++++++++- .../api/services/monerium-b2b/monitoring.ts | 36 +++++++++ docs/architecture-monerium-b2b-onramp.md | 8 +- .../05-integrations/monerium-b2b.md | 4 +- 4 files changed, 117 insertions(+), 6 deletions(-) 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 0d06e5c23..22e1b28ec 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -321,6 +321,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { let errors: string[]; let warnings: string[]; let eureOnClone: bigint; + let batchOpenedAt: bigint; beforeAll(async () => { config.moneriumB2b.rpcUrl = undefined; // provisioning skips the on-chain clone check @@ -338,7 +339,8 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { errors = []; warnings = []; eureOnClone = EUR / 2n; - const reads: Record = { batchOpenedAt: 0n, MIN_SWAP_FLOOR: 1n * EUR, TRIGGER_DELAY: 86_400n }; + batchOpenedAt = 0n; + const reads: Record = { MIN_SWAP_FLOOR: 1n * EUR, TRIGGER_DELAY: 86_400n }; spyOn(chain, "getForwarderImmutables").mockResolvedValue({ eure: EURE, factory: FACTORY, @@ -347,7 +349,13 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { } as unknown as chain.ForwarderImmutables); spyOn(chain, "getPublicClient").mockReturnValue({ readContract: async ({ address, functionName }: { address: Address; functionName: string }) => - functionName === "balanceOf" ? (address === EURE ? eureOnClone : 0n) : reads[functionName] + functionName === "balanceOf" + ? address === EURE + ? eureOnClone + : 0n + : functionName === "batchOpenedAt" + ? batchOpenedAt + : reads[functionName] } as unknown as ReturnType); spyOn(logger, "error").mockImplementation(((message: string) => { errors.push(message); @@ -521,4 +529,67 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { await runStrandedBalanceMonitor(); expect(errors).toEqual([]); }); + + // At or above the floor the contract can recover, but younger forwards may keep re-timing + // the marker so the deposit never becomes eligible (minSwapAmount raised above the floor). + describe("a marked deposit the keeper never recovers", () => { + const now = 1_800_000_000_000; + const HOUR = 3_600_000; + + async function markedHoursAgo(hours: number) { + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, 10n * EUR); + await deposit.update({ refundStartedAt: new Date(now - hours * HOUR) }); + return deposit; + } + + beforeEach(() => { + eureOnClone = 10n * EUR; + batchOpenedAt = BigInt(Math.floor((now - 10 * 60_000) / 1000)); // freshly re-timed by a forward + }); + + it("warns once RECOVERY_DELAY plus the margin has passed since it was marked", async () => { + const deposit = await markedHoursAgo(4); + await runStrandedBalanceMonitor(now); + expect(errors).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("REFUND OVERDUE"); + expect(warnings[0]).toContain(deposit.id); + expect(warnings[0]).toContain("eure=10,"); + }); + + it("errors once it has waited past TRIGGER_DELAY", async () => { + const deposit = await markedHoursAgo(25); + await runStrandedBalanceMonitor(now); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("REFUND OVERDUE"); + expect(errors[0]).toContain(deposit.id); + }); + + it("stays quiet before the bound", async () => { + await markedHoursAgo(2); + await runStrandedBalanceMonitor(now); + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + }); + + for (const status of [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) { + it(`stays quiet while its recover is ${status}`, async () => { + const deposit = await markedHoursAgo(4); + await execution(deposit, MoneriumConversionExecutionKind.Recover, status); + await runStrandedBalanceMonitor(now); + expect(warnings).toEqual([]); + }); + } + + it("leaves a clone below the floor to REFUND NEEDS OPERATOR alone", async () => { + eureOnClone = EUR / 2n; + batchOpenedAt = 0n; + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + await deposit.update({ refundStartedAt: new Date(now - 4 * HOUR) }); + await runStrandedBalanceMonitor(now); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("REFUND NEEDS OPERATOR"); + expect([...errors, ...warnings].some(message => message.includes("REFUND OVERDUE"))).toBe(false); + }); + }); }); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 3739264a2..ac9f78071 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -47,6 +47,8 @@ import { refundAccountFor } from "./refund-wallet"; * An unswapped payment marked for recovery whose EURe is still on a clone below * MIN_SWAP_FLOOR errors too: the contract cannot recover it until the operator tops the * clone up to the floor (one whose funds already left the clone only warns: reconcile). + * A marked deposit left unrecovered RECOVERY_DELAY plus an hour after marking warns + * (REFUND OVERDUE), and errors past TRIGGER_DELAY. * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault * (docs/architecture-monerium-b2b-onramp.md, fees section); a vault that cannot cover a * below-floor swap makes the keeper defer, so runway problems surface here first. @@ -479,6 +481,40 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi } continue; } + // A marked deposit still unrecovered RECOVERY_DELAY plus the refund monitor's linger + // margin after it was marked: e.g. younger forwards keep re-timing the marker. The + // sub-floor case never gets here (REFUND NEEDS OPERATOR above). + const overdue = await MoneriumFiatDeposit.findAll({ + attributes: ["id", "refundStartedAt"], + where: { + accountId: account.id, + refundStartedAt: { [Op.lt]: new Date(now - recoveryDelaySeconds * 1000 - RECOVERY_LINGER_MS) }, + status: MoneriumFiatDepositStatus.Recovering + } + }); + if (overdue.length > 0) { + const recovers = await MoneriumConversionExecution.findAll({ + attributes: ["depositId"], + where: { + depositId: { [Op.in]: overdue.map(deposit => deposit.id) }, + kind: MoneriumConversionExecutionKind.Recover, + status: { [Op.in]: [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed] } + } + }); + const late = overdue.filter(deposit => !recovers.some(e => e.depositId === deposit.id)); + if (late.length > 0) { + const ageMs = now - Math.min(...late.map(deposit => (deposit.refundStartedAt as Date).getTime())); + const message = + `monerium-b2b: REFUND OVERDUE — deposit(s) ${late.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + + `(account ${account.id}) were marked for recovery up to ${Math.floor(ageMs / 3_600_000)}h ago and no recover ` + + `has been sent (eure=${formatUnits(eureBalance, 18)}, usdc=${formatUnits(usdcBalance, 6)}, ` + + `batchOpenedAt=${batchOpenedAt}): check whether younger forwards keep re-timing the batch marker or another ` + + "refund of this client is stuck; after an operator top-up this clears once the keeper recovers (the internal " + + "B2B runbook §2.7)"; + if (ageMs >= Number(triggerDelay) * 1000) logger.error(message); + else logger.warn(message); + } + } const severity = classifyStranding(batchOpenedAt, BigInt(recoveryDelaySeconds), triggerDelay, now); if (severity === "ok") { continue; diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index bc76d60bb..26c2cf057 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -387,7 +387,7 @@ permissionless `poke` and `applyFeePolicy`): keeps the marker armed. Every younger deposit's `forward` re-times the marker, so a passed-over remainder at or above the floor (possible only once `minSwapAmount` is raised above `MIN_SWAP_FLOOR`) becomes recoverable only after a forward-free - `RECOVERY_DELAY`. + `RECOVERY_DELAY`; the stranded-balance monitor reports it as REFUND OVERDUE. - **The refund path.** A deposit marked `recovering` — by an operator through the admin endpoint, or once automated by the missed window — is moved off the clone with `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the @@ -551,7 +551,11 @@ read-only — no keys, no transactions: may have left the clone by an unrecorded path, so it warns "reconcile before refunding; do not refund and do not top up the clone". The pass skips that account while any of its `recover` executions is still pending: a mined `recover` not yet - confirmed would make the ledger overstate the clone. + confirmed would make the ledger overstate the clone. On a clone at or above the floor, a + `recovering` deposit with no pending or confirmed `recover` more than + `RECOVERY_DELAY` plus an hour after it was marked warns REFUND OVERDUE (an error past + `TRIGGER_DELAY`): younger forwards keep re-timing its marker, or another refund of the + client is stuck. 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized fee-policy changes (timelocked) are reconciled into the DB with a version bump; a destination change (no setter exists), bytecode or registration drift is a diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 6479357cc..38a96b84c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -39,7 +39,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. -5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; on a clone that holds less than `MIN_SWAP_FLOOR` EURe and no USDC the marker never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; on a clone that holds less than `MIN_SWAP_FLOOR` EURe and no USDC the marker never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; it skips the send while any keeper transaction is still unmined (latest and pending nonce differ), so a timed-out apply is not re-sent and stacked every cycle; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. 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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (decimal and raw) of the EURe token, which the operator sends to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The odd wei makes the amount a non-whole-cent value that the mint watcher can never match to a Monerium order by amount, so it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (decimal and raw) of the EURe token, which the operator sends to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The odd wei makes the amount a non-whole-cent value that the mint watcher can never match to a Monerium order by amount, so it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From 81c7c80579faf2e794257f1e152060599a5d5318 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 10:57:13 +0200 Subject: [PATCH 25/80] fix(api): log the sandbox depth alert at warn level On Sepolia the pool routinely drifts from the oracle, so DEPTH BELOW FLOOR fired as an error every pass with production triage text, which contradicts the rule that an error is an incident trigger. Off mainnet it now warns with a hint to re-centre the sandbox pool; mainnet is unchanged. --- .../services/monerium-b2b/monitoring.test.ts | 23 +++++++++++++++++++ .../api/services/monerium-b2b/monitoring.ts | 15 ++++++++---- docs/architecture-monerium-b2b-onramp.md | 5 ++-- .../05-integrations/monerium-b2b.md | 2 +- 4 files changed, 38 insertions(+), 7 deletions(-) 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 22e1b28ec..f746e2073 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -304,6 +304,29 @@ describe("runExecutableDepthCheck", () => { expect(quoteSpy).toHaveBeenCalledWith("0x61fFE014bA17989E743c5F6cB21bF9697530B21e", "0xaa", 10_000n * EUR); }); + for (const [chainId, level] of [ + [1, "error"], + [11_155_111, "warn"] + ] as const) { + it(`logs DEPTH BELOW FLOOR at ${level} on chain ${chainId}`, async () => { + const { quoteSpy } = arrange(chainId); + quoteSpy.mockImplementation(async (_quoter, _path, amountIn) => (amountIn * 100n) / (100n * 10n ** 12n)); // ~12% impact + const lines: Record = { error: [], warn: [] }; + for (const name of ["error", "warn"] as const) { + spyOn(logger, name).mockImplementation(((message: string) => { + lines[name].push(message); + }) as unknown as typeof logger.error); + } + await runExecutableDepthCheck(); + const other = level === "error" ? "warn" : "error"; + expect(lines[level]).toHaveLength(1); + expect(lines[level][0]).toContain("DEPTH BELOW FLOOR"); + expect(lines[other]).toEqual([]); + if (level === "warn") expect(lines.warn[0]).toContain("re-centre the sandbox pool"); + else expect(lines.error[0]).toContain("triage per the B2B operations runbook §3"); + }); + } + it("skips a chain without a known quoter", async () => { const { findAll, quoteSpy } = arrange(31_337); await runExecutableDepthCheck(); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index ac9f78071..058ea5186 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -38,8 +38,8 @@ import { refundAccountFor } from "./refund-wallet"; * every enabled factory route at perSwapCap and minSwapAmount sizes vs the Chainlink * 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. Only on chains with a pinned QuoterV2 (mainnet, Sepolia). + * (error-level DEPTH BELOW FLOOR line, triage per the runbook; a warning off mainnet); + * at perSwapCap size it 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 @@ -298,7 +298,8 @@ async function monitoredAccounts(statuses: MoneriumAccountStatus[]): Promise { - const quoter = quoterV2ForChainId(await getChainId()); + const chainId = await getChainId(); + const quoter = quoterV2ForChainId(chainId); if (!quoter) { return; } @@ -356,8 +357,14 @@ export async function runExecutableDepthCheck(): Promise { quoted.map(route => `#${route.index} min=${route.minImpactBps}bps cap=${route.capImpactBps}bps`).join(", "); const verdict = classifyExecutableDepth(best.minImpactBps, best.capImpactBps, slippageBps); - if (verdict.severity === "error") { + if (verdict.severity === "error" && chainId === 1) { logger.error(`monerium-b2b: DEPTH BELOW FLOOR — ${verdict.reason}; triage per the B2B operations runbook §3. ${detail}`); + } else if (verdict.severity === "error") { + // Off mainnet a thin pool drifting from the oracle is a calibration signal, not an incident. + logger.warn( + `monerium-b2b: DEPTH BELOW FLOOR on sandbox chain ${chainId} — ${verdict.reason}; re-centre the sandbox pool on ` + + `the oracle rate (not a production incident). ${detail}` + ); } else if (verdict.severity === "warn") { logger.warn(`monerium-b2b: ${verdict.reason}. ${detail}`); } else { diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 26c2cf057..a0ed439fd 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -536,8 +536,9 @@ read-only — no keys, no transactions: 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. 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. + the same line every pass (and the keeper defers sandbox swaps as below the floor), but + off mainnet at warn level with a sandbox hint: re-centre 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 38a96b84c..7ef03560d 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. 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. +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 line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. 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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (decimal and raw) of the EURe token, which the operator sends to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The odd wei makes the amount a non-whole-cent value that the mint watcher can never match to a Monerium order by amount, so it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". 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 00d03e62a5ebea76127db0a2d967bc423815a3fa Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 10:58:07 +0200 Subject: [PATCH 26/80] docs(api): record how payments below the swap floor are refunded The ADR still listed sub-minimum remainders as refunded through the refund path, although below MIN_SWAP_FLOOR the keeper cannot recover them without the operator's top-up. Partners saw the EUR 1 minimum only under sandbox specifics; production now states it too, with when a smaller payment comes back. --- docs/adr-0005-monerium-b2b-onramp.md | 18 ++++++++++++++++++ docs/api/pages/14-managed-profiles.md | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index bb92e518e..cffbc0c20 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -285,6 +285,24 @@ the promised window. Decisions: share a wallet. `MONERIUM_B2B_RECOVERY_PRIVATE_KEY` is replaced by `MONERIUM_B2B_REFUND_SEED`, required whenever the module is enabled. +## Amendment 2026-10-10: refunds below the swap floor + +- **Payments below `MIN_SWAP_FLOOR` are refunded through an operator top-up, not + automatically.** The 2026-09-17 refund triggers include remainders below + `minSwapAmount`, but a clone holding less than `MIN_SWAP_FLOOR` (€1) EURe and no USDC + never arms its batch marker, so `recover` reverts for such a payment and the keeper + cannot refund it on its own. Once it is marked `recovering`, the stranded-balance + monitor raises REFUND NEEDS OPERATOR with the exact top-up (`MIN_SWAP_FLOOR - clone + EURe + 1 wei`), which an operator sends to the clone from any wallet, e.g. the float + (internal B2B runbook §2.7). The marker then arms and the keeper's normal `recover` + and refund pipeline refund the payer's own EURe after `RECOVERY_DELAY`, with the full + refund record and the one-refund-per-client gate; such a payment is never refunded by + hand. The top-up stays on the clone below the floor as Vortex EURe (the odd wei keeps + the mint watcher from attributing it to an order), and a later sub-floor payment that + reaches the floor with it is refunded with no operator step. Sending the top-up as + soon as the payment shows `below_minimum` lands the refund at about the normal + deadline; otherwise it lands `RECOVERY_DELAY` after the top-up. + ## Final parameters (decided 2026-08-26 unless noted) | ID | Parameter | Value | diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 5be30ac1e..9bd08b62c 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -199,7 +199,7 @@ X-Managed-Profile-Id: **Sandbox specifics** - **Network.** Sandbox converts and delivers on Ethereum Sepolia; production uses Ethereum mainnet. The destination must be an Ethereum address the client controls, and it is fixed for the life of the account. -- **Minimum.** Payments from EUR 1 are converted. A smaller payment is not converted and never holds back later payments; its `waiting.reason` is `below_minimum`. After the refund window it enters the refund path (`recovering`), but the forwarding contract cannot move an amount below EUR 1 on its own, so operations usually refund it by hand and it can take longer than other refunds. +- **Minimum.** As in production, payments from EUR 1 are converted; a smaller payment is refunded later than the normal window, after a step by Vortex operations (see [Register A Business EUR Client](#register-a-business-eur-client)). - **Refund window.** A payment that is not converted within 15 minutes of its mint (two hours by default in production), for example because the account was not active yet, is refunded in full to the account it came from (operations process the refund by hand where automatic refunds are off). The EUR provider pays refunds out only for profiles it has approved. - **Webhook signatures.** Sandbox signs deliveries with its own key: verify them against `https://api-sandbox.vortexfinance.co/v1/public-key`, not the production key. See [Webhooks](https://api-docs.vortexfinance.co/webhooks). @@ -260,6 +260,8 @@ The destination is fixed for the life of the account, so an identical replay (sa An account converts payments only once it is `active`, and its IBAN can be issued before that: `iban` is set while `status` is still `onboarding`. A payment that reaches the IBAN earlier is not converted. The deposit waits with `waiting.reason` `account_not_active` and is refunded once the promised conversion window has passed (two hours by default, 15 minutes in sandbox; operations process the refund by hand where automatic refunds are off). A `suspended` account, or one paused for dormancy, converts nothing either, and Vortex suspends an account whose destination check fails even if it was never active. So hand a client its IBAN only once an `ACCOUNT_UPDATED` event or `GET /v1/monerium-b2b/account` reports `status: "active"`. The gate is Vortex's policy, applied by its conversion service rather than locked on chain, so treat the IBAN as unusable until then. In sandbox, accounts activate on their own once the IBAN is issued; in production, Vortex operations activate an account after checking its destination. +Vortex converts payments from EUR 1. A smaller payment is not converted and never holds back later payments; its `waiting.reason` is `below_minimum`. It is refunded in full to the account it came from, but later than the normal window: the conversion contract cannot return an amount below EUR 1 on its own, so Vortex operations take one step first, after which the refund runs like any other and `DEPOSIT_RETURNED` reports it. Expect it a few hours after the window rather than at it. + ## Common Errors | Response | Meaning | From 6930971ffb52ef1209ca0445dba794b1d6de9348 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:06:37 +0200 Subject: [PATCH 27/80] fix(api): start a fresh wait for a deposit cleared from below_minimum The below_minimum clear is a bulk update, so the deposit instance planAction picks still held the old reason and wait start. When pricing deferred it in the same cycle, Sequelize dropped the unchanged waitingSince from the UPDATE and the row kept a NULL wait start, which partners saw as waiting since createdAt. Keeping the instances in step makes the deferral start a fresh wait. --- .../monerium-b2b/conversion-executor.test.ts | 16 ++++++++++++++++ .../services/monerium-b2b/conversion-executor.ts | 6 ++++-- 2 files changed, 20 insertions(+), 2 deletions(-) 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 49790c572..3ee4ef406 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 @@ -1039,6 +1039,22 @@ describe("runConversionExecutor activation gate", () => { expect(small.waitingSince).toBeNull(); }); + it("starts a fresh wait when a deposit cleared from below_minimum defers in the same cycle", async () => { + const { accountId, deposit } = await accountWithDeposit(null, [10n]); + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + await runConversionExecutor(accountId); + expect((await deposit.reload()).waitingReason).toBe("below_minimum"); + const belowMinimumSince = new Date(Date.now() - 60 * 60_000); + await MoneriumFiatDeposit.update({ waitingSince: belowMinimumSince }, { where: { id: deposit.id } }); + + reads.minSwapAmount = 5n * EUR; + await runConversionExecutor(accountId); + await deposit.reload(); + expect(deposit.waitingReason).toBe("oracle_unavailable"); + expect(deposit.waitingSince).toBeInstanceOf(Date); + expect(deposit.waitingSince?.getTime()).toBeGreaterThan(belowMinimumSince.getTime()); + }); + it("replaces an earlier hold reason and keeps when the wait started", async () => { const { accountId, deposit, since } = await accountWithDeposit("oracle_unavailable"); await MoneriumAccount.update({ status: MoneriumAccountStatus.Suspended }, { where: { id: accountId } }); 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 90bb11e63..945f90594 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1158,12 +1158,14 @@ export async function runConversionExecutor(accountId: string): Promise { // A lowered minimum ends that wait even for a deposit queued behind the one planned. const nowAboveMinimum = withState .filter(({ deposit, state }) => deposit.waitingReason === BELOW_MINIMUM && state.remainingEureRaw >= minSwapAmount) - .map(({ deposit }) => deposit.id); + .map(({ deposit }) => deposit); if (nowAboveMinimum.length > 0) { await MoneriumFiatDeposit.update( { waitingReason: null, waitingSince: null }, - { transaction, where: { id: { [Op.in]: nowAboveMinimum } } } + { transaction, where: { id: { [Op.in]: nowAboveMinimum.map(deposit => deposit.id) } } } ); + // In step with the row, so a deferral below starts a fresh wait instead of writing back the old start. + for (const deposit of nowAboveMinimum) deposit.set({ waitingReason: null, waitingSince: null }); } return planAction(withState, { batchOpenedAtSec: batchOpenedAt, From c13fc2af7ba9e57f1d58f474a9cda7a3815a8884 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:07:27 +0200 Subject: [PATCH 28/80] fix(api): back a planned recover off only on failed recovers The account-wide retry backoff counted failed rows of every kind, so a deposit whose swaps kept failing had its recover held back for up to an hour after it became eligible, missing the promised window the poke fix was meant to keep. Swaps and forwards still back off on failures of any kind. --- .../monerium-b2b/conversion-executor.test.ts | 48 +++++++++++++++++-- .../monerium-b2b/conversion-executor.ts | 12 +++-- docs/architecture-monerium-b2b-onramp.md | 10 ++-- .../05-integrations/monerium-b2b.md | 2 +- 4 files changed, 62 insertions(+), 10 deletions(-) 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 3ee4ef406..2bc70f661 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 @@ -1179,18 +1179,60 @@ describe("runConversionExecutor batch marker", () => { const executions = (accountId: string) => MoneriumConversionExecution.findAll({ where: { accountId } }); - async function failedAttempt(accountId: string, depositId: string) { + async function failedAttempt(accountId: string, depositId: string, kind = MoneriumConversionExecutionKind.Swap) { await MoneriumConversionExecution.create({ accountId, depositId, destination: "0x5555555555555555555555555555555555555555", eureInRaw: (100n * EUR).toString(), error: "attempt 1: execution reverted", - kind: MoneriumConversionExecutionKind.Swap, - status: MoneriumConversionExecutionStatus.Failed + kind, + status: MoneriumConversionExecutionStatus.Failed, + usdcNetRaw: kind === MoneriumConversionExecutionKind.Recover ? "0" : null }); } + /** Marks the deposit recovering on a batch armed long enough ago for `recover`. */ + async function eligibleRecovery(depositId: string) { + await MoneriumFiatDeposit.update( + { refundStartedAt: new Date(), status: MoneriumFiatDepositStatus.Recovering }, + { where: { id: depositId } } + ); + return BigInt(Math.floor(Date.now() / 1000) - 7_200 - 120); + } + + it("holds a swap back on failed executions of any kind", async () => { + const writes = arrange({ batchOpenedAt: 1n }); + const { accountId, deposit } = await activeAccountWithDeposit(); + await failedAttempt(accountId, deposit.id, MoneriumConversionExecutionKind.Recover); + + await runConversionExecutor(accountId); + + expect(writes).toEqual([]); + expect(await executions(accountId)).toHaveLength(1); + }); + + it("recovers an eligible deposit on time however often its swaps failed", async () => { + const { accountId, deposit } = await activeAccountWithDeposit(); + for (let i = 0; i < 5; i++) await failedAttempt(accountId, deposit.id); + const writes = arrange({ batchOpenedAt: await eligibleRecovery(deposit.id) }); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["recover:7"]); + }); + + it("still backs a recover off on its own failed recovers", async () => { + const { accountId, deposit } = await activeAccountWithDeposit(); + await failedAttempt(accountId, deposit.id, MoneriumConversionExecutionKind.Recover); + const writes = arrange({ batchOpenedAt: await eligibleRecovery(deposit.id) }); + + await runConversionExecutor(accountId); + + expect(writes).toEqual([]); + expect(await executions(accountId)).toHaveLength(1); + }); + it("pokes once when the swap attempt fails before sending", async () => { const writes = arrange({ batchOpenedAt: 0n, swapReverts: true }); const { accountId } = await activeAccountWithDeposit(); 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 945f90594..ae5d7a423 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -722,9 +722,14 @@ async function findMatchingTxHashes( /** * Under the forwarder lock: resolve leftover pending executions (crash/timeout - * recovery), then decide whether a new execution may start (retry backoff). + * recovery), then decide whether a new execution may start (retry backoff). A planned + * recover backs off only on failed recovers: failed swaps must not delay the refund. */ -async function prepareExecutionSlot(account: MoneriumAccount, transaction: Transaction): Promise { +async function prepareExecutionSlot( + account: MoneriumAccount, + transaction: Transaction, + plannedKind?: MoneriumConversionExecutionKind +): Promise { const pendings = await MoneriumConversionExecution.findAll({ order: [["created_at", "ASC"]], transaction, @@ -810,6 +815,7 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Failed, + ...(plannedKind === MoneriumConversionExecutionKind.Recover ? { kind: plannedKind } : {}), ...(lastConfirmed ? { createdAt: { [Op.gt]: lastConfirmed.createdAt } } : {}) } }); @@ -1217,7 +1223,7 @@ export async function runConversionExecutor(accountId: string): Promise { // Pending-check and execution-row create under ONE lock acquisition: split across two // transactions, two concurrent executors could both pass the check and both broadcast. const slot = await withForwarderLock(account.forwarderAddress, async transaction => { - const preparation = await prepareExecutionSlot(account, transaction); + const preparation = await prepareExecutionSlot(account, transaction, call.kind); if (preparation.kind !== "proceed") { return preparation; } diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index a0ed439fd..6cd8ccbe7 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -395,12 +395,16 @@ permissionless `poke` and `applyFeePolicy`): than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive (the keeper sends the permissionless `poke()` ahead of the first swap, and on its own in a cycle that sends no swap: nothing to do, a deferral, a retry backoff or a failed attempt, so - a deposit whose every attempt fails is still refundable on time; a cycle waiting on a - pending execution leaves the poke to that execution's own sequence), is never + the marker arms on time however the swaps fare; a cycle waiting on a pending execution + leaves the poke to that execution's own sequence), is never re-timed by a chunk swap, and is re-timed for whatever remains after a forward or a recovery, so a younger payment sharing the clone gets its own clock. The keeper recovers before it converts anything else, and still does so on suspended or dormant - accounts (`recover` ignores the guardian pause). Off the clone, `recovery.ts` drives + accounts (`recover` ignores the guardian pause). The account's retry backoff (1 min, + doubling per failure since its last confirmed execution, at most 60 min) holds a swap + or forward back on failures of any kind, but a planned `recover` only on failed + recovers, so a deposit whose every swap attempt fails is still recovered as soon as + its marker is old enough. Off the clone, `recovery.ts` drives the refund when `MONERIUM_B2B_AUTO_RECOVERY=auto` (`alert` only reports deposits past the window; `off` leaves everything to runbook §2.7): once the `recover` is confirmed a `monerium_recoveries` row walks `moved → swapping → swapped → topping_up → diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 7ef03560d..4ff5d14c0 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -37,7 +37,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 1. **Three-way key separation, bounded fund movement** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`, `applyFeePolicy()`, `swap()`, `forward()` and `recover()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can redirect funds: `swap` only executes a factory-whitelisted, oracle-floored swap whose USDC stays on the clone, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `forward` only ever pays the clone's own `destination`; `recover` only ever pays the clone's `recoveryAddress`, the client's refund wallet fixed at deployment with no setter, and only once the clone's batch marker has been open for `RECOVERY_DELAY` (the contract, not the keeper, enforces the promised window); `setGuardianPaused` is protective-only by contract invariant (it blocks swaps and forwards, never a recovery); the attestor signs the fixed link statement. Besides the guardian, an address the guardian registers on the factory as a deployer (`setDeployer`) may call `deployForwarder` and no other factory or clone function, so deploying needs no guardian signature; the backend's deployer key (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, distinct from the other keys) holds that role for partner registrations (invariant 12). The role is not harmless, though: every clone a deployer deploys is registered (`isForwarder`), the trust anchor of the subsidy vault's `pay` and of the backend's mapping check, and a deployer may pick any destination and recovery address. A leaked deployer key can therefore create registered clones of its own, fund them itself and draw vault subsidies within the vault's per-swap cap and daily budget; it can touch no existing clone's funds or configuration. The guardian bounds it: `setDeployer(address, false)` stops new deployments but does not unregister clones already deployed, and `revokeForwarder(clone)` (guardian-only, one-way, emits `ForwarderRevoked`, reverts `UnknownForwarder` for an unregistered address) removes one from the registry, after which the vault refuses its subsidy calls, so a swap that needs a subsidy reverts, and the backend refuses to map it (the config reconciliation, R07, reports the revoked clone of an onboarding or active account as an error); its forward and recover paths and its funds are untouched. The factory binds each clone's CREATE2 address to its whole initialization, `keccak256(abi.encode(destination, recoveryAddress, targetPpm, floorPpm, salt))`, so `predictAddress` takes all five arguments (manual deploys too) and a deployer cannot squat an address predicted for other settings. Deployers survive a guardian transfer like keepers: a new guardian must review the `DeployerSet` events and reset them. The role takes the guardian key out of deployment only, not off the server: the dormancy pause (invariant 6) still signs with it. A clone still passes the account mapping's on-chain verification (trusted factory, `isForwarder`, destination, fee policy, derived refund wallet) before it is used. Vortex therefore holds a client's funds only on the refund path, only in that client's own refund wallet, and only for a payment the promised window was missed on (adr-0005, amendments 2026-09-17 and 2026-10-01). All keys are env-only and never logged. 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. -3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. +3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. The retry backoff (1 min, doubling per failed row since the account's last confirmed execution, at most 60 min) holds a swap or forward back on failures of any kind, but a planned `recover` only on failed recovers, so failing swaps never delay a refund past its on-chain eligibility. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; on a clone that holds less than `MIN_SWAP_FLOOR` EURe and no USDC the marker never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. From 950f2f0a1bff0c2702eaca9abcc06d945615de84 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:08:21 +0200 Subject: [PATCH 29/80] fix(api): read the pending fee policy inside the best-effort apply The pendingFeePolicyEffectiveAt read sat in the cycle's critical state read, so one failing read aborted the whole cycle, recover and poke included. The apply is best-effort and the contract always settles under its applied policy, so its read now fails with it. --- .../monerium-b2b/conversion-executor.test.ts | 14 ++++++++++++++ .../monerium-b2b/conversion-executor.ts | 19 ++++++++++++------- docs/architecture-monerium-b2b-onramp.md | 4 ++-- .../05-integrations/monerium-b2b.md | 2 +- 4 files changed, 29 insertions(+), 10 deletions(-) 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 2bc70f661..fbe984710 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 @@ -1764,6 +1764,20 @@ describe("runConversionExecutor fee-policy apply", () => { expect(writes.filter(write => write === "applyFeePolicy")).toEqual([]); }); + it("carries on with the cycle when the pending fee policy cannot be read", async () => { + Object.defineProperty(reads, "pendingFeePolicyEffectiveAt", { + get: () => Promise.reject(new Error("execution reverted")) + }); + const warn = spyOn(logger, "warn"); + const { accountId, deposit } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(warn.mock.calls.some(([message]) => String(message).includes("applyFeePolicy"))).toBe(true); + expect(writes).toEqual(["read targetPpm"]); // the swap was still planned and priced + expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); + }); + it("logs a reverted apply and carries on with the cycle", async () => { reads.pendingFeePolicyEffectiveAt = NOW - 60n; writeError = new Error("NoPendingFeePolicy()"); 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 ae5d7a423..3d54343e0 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1102,18 +1102,17 @@ export async function runConversionExecutor(accountId: string): Promise { ) { throw new Error(`Forwarder ${forwarder} is not bound to the configured trusted factory`); } - const [eureBalance, usdcBalance, batchOpenedAt, minSwapAmount, minSwapFloor, perSwapCap, feeIncreaseAt] = await Promise.all([ + const [eureBalance, usdcBalance, batchOpenedAt, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([ client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }), client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), - client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }), - client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "pendingFeePolicyEffectiveAt" }) + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) ]); // Before pricing, so a swap this cycle is projected under the policy it will settle at. - await applyDueFeePolicy(forwarder, feeIncreaseAt); + await applyDueFeePolicy(forwarder); // Arm the batch marker whenever funds are present, even below the (guardian-tunable) // minSwapAmount: the recovery and trigger clocks must run regardless of whether a swap @@ -1454,15 +1453,21 @@ async function sendPoke(forwarder: Address): Promise { * P11: finalizes a guardian fee increase whose timelock has elapsed (`applyFeePolicy` is * permissionless; nothing else would ever apply it). Waits for the receipt so the cycle * then reads the applied policy, and skips while any keeper transaction is still unmined so - * a timed-out apply is not re-sent every cycle. Best-effort like sendPoke: a revert (applied + * a timed-out apply is not re-sent every cycle. Best-effort like sendPoke: a failed read, a revert (applied * by someone else, clock skew) or a timeout is logged and the cycle goes on, since the * contract always settles under its current on-chain policy. The monitor's config reconciliation mirrors * the applied values into the account row. */ -async function applyDueFeePolicy(forwarder: Address, effectiveAt: bigint): Promise { - if (effectiveAt === 0n) return; +async function applyDueFeePolicy(forwarder: Address): Promise { try { const client = getPublicClient(); + // Read here, not with the cycle's state: a failed read must not stop the cycle's recover or poke. + const effectiveAt = await client.readContract({ + abi: forwarderAbi, + address: forwarder, + functionName: "pendingFeePolicyEffectiveAt" + }); + if (effectiveAt === 0n) return; const { timestamp } = await client.getBlock(); if (timestamp < effectiveAt) return; const keeper = getKeeperWalletClient(); diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 6cd8ccbe7..55c7cfa14 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -466,8 +466,8 @@ contract settles every fill into three bands against that reference (decisions: SEPA transfer is already in flight cannot be swapped under a silently worse policy; lowering is immediate (registry P11). Swaps always use the currently applied policy. Once an increase is due, the keeper sends `applyFeePolicy()` in the account's next - cycle, before pricing, and waits for it; a revert or timeout is logged and the cycle - goes on, and no apply is re-sent while a keeper transaction is still unmined. The + cycle, before pricing, and waits for it; a failed read of the pending policy, a revert + or a timeout is logged and the cycle goes on, and no apply is re-sent while a keeper transaction is still unmined. The executor only runs for accounts with a settling deposit, so an idle clone's increase is applied before its next swap. - **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 4ff5d14c0..348e84278 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -42,7 +42,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; on a clone that holds less than `MIN_SWAP_FLOOR` EURe and no USDC the marker never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; it skips the send while any keeper transaction is still unmined (latest and pending nonce differ), so a timed-out apply is not re-sent and stacked every cycle; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; it skips the send while any keeper transaction is still unmined (latest and pending nonce differ), so a timed-out apply is not re-sent and stacked every cycle; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. ## Monitoring From 94eb7aa1ca4667ce1261605eba3a2dc9b297ee16 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:10:11 +0200 Subject: [PATCH 30/80] fix(api): remember the last fee apply so a private one is not re-sent The unmined-apply guard compared public latest and pending nonces, but on mainnet keeper writes go through the private relay, whose pending transactions the public counts never show. A timed-out apply was then re-sent every cycle, stacking nonces behind it. The keeper now remembers each forwarder's last apply and sends nothing while it has no receipt and is younger than a relay's lifetime, and logs each skip reason once so a blocked increase leaves a trace. The public-nonce check stays for the public pool; it counts any keeper send, of any account, and the docs now say so. --- .../monerium-b2b/conversion-executor.test.ts | 88 ++++++++++++++++++- .../monerium-b2b/conversion-executor.ts | 63 +++++++++++-- docs/architecture-monerium-b2b-onramp.md | 9 +- .../05-integrations/monerium-b2b.md | 4 +- 4 files changed, 147 insertions(+), 17 deletions(-) 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 fbe984710..109bc60a1 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 @@ -1639,6 +1639,12 @@ describe("runConversionExecutor fee-policy apply", () => { let writeError: Error | null; let nonces: { latest: number; pending: number }; let receiptTimesOut: boolean; + /** Keeper writes go through a private relay: the public pending nonce never sees them. */ + let privateTransport: boolean; + let receiptStatus: "success" | "reverted"; + let simulateError: Error | null; + /** This test's apply hashes (unique per write: the keeper remembers applies across cycles). */ + let sentHashes: Set; beforeAll(async () => { config.moneriumB2b.rpcUrl = undefined; @@ -1657,6 +1663,10 @@ describe("runConversionExecutor fee-policy apply", () => { writeError = null; nonces = { latest: 0, pending: 0 }; receiptTimesOut = false; + privateTransport = false; + receiptStatus = "success"; + simulateError = null; + sentHashes = new Set(); reads = { batchOpenedAt: 1n, floorPpm: 1_500, @@ -1681,11 +1691,18 @@ describe("runConversionExecutor fee-policy apply", () => { if (functionName === "targetPpm") writes.push("read targetPpm"); return functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName]; }, - simulateContract: async () => ({}), + getTransactionReceipt: async ({ hash }: { hash: Hex }) => { + if (receiptTimesOut && sentHashes.has(hash)) throw new TransactionReceiptNotFoundError({ hash }); + return { status: receiptStatus }; + }, + simulateContract: async () => { + if (simulateError) throw simulateError; + return {}; + }, waitForTransactionReceipt: async () => { if (receiptTimesOut) throw new Error("WaitForTransactionReceiptTimeoutError"); nonces.latest = nonces.pending; - return { status: "success" }; + return { status: receiptStatus }; } } as unknown as ReturnType); spyOn(chain, "getKeeperWalletClient").mockReturnValue({ @@ -1693,8 +1710,10 @@ describe("runConversionExecutor fee-policy apply", () => { writeContract: async ({ functionName }: { functionName: string }) => { if (writeError) throw writeError; writes.push(functionName); - nonces.pending += 1; - return "0xapply"; + if (!privateTransport) nonces.pending += 1; + const hash = `0x${crypto.randomUUID().replaceAll("-", "")}${"0".repeat(32)}`; + sentHashes.add(hash); + return hash; } } as unknown as ReturnType); }); @@ -1756,6 +1775,67 @@ describe("runConversionExecutor fee-policy apply", () => { expect(writes.filter(write => write === "applyFeePolicy")).toEqual(["applyFeePolicy"]); }); + it("does not re-send an unmined apply the public pool cannot see (private transport) until the relay bound", async () => { + reads.pendingFeePolicyEffectiveAt = NOW - 60n; + receiptTimesOut = true; + privateTransport = true; + const info = spyOn(logger, "info"); + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); // sent, receipt wait times out + await runConversionExecutor(accountId); // public latest == pending: only the remembered apply holds it + await runConversionExecutor(accountId); + expect(writes.filter(write => write === "applyFeePolicy")).toEqual(["applyFeePolicy"]); + const skips = info.mock.calls.filter(([message]) => String(message).includes("still unmined")); + expect(skips).toHaveLength(1); // logged once, not every cycle + expect(String(skips[0][0])).toContain("is due for 60s but not sent"); + + const later = Date.now() + 16 * 60_000; + spyOn(Date, "now").mockReturnValue(later); + await runConversionExecutor(accountId); // past the bound: the relay dropped it, send again + expect(writes.filter(write => write === "applyFeePolicy")).toEqual(["applyFeePolicy", "applyFeePolicy"]); + }); + + it("does not apply while any keeper transaction is unmined in the public pool, and says so once", async () => { + reads.pendingFeePolicyEffectiveAt = NOW; + nonces = { latest: 3, pending: 4 }; // e.g. another account's swap + const info = spyOn(logger, "info"); + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + await runConversionExecutor(accountId); + + expect(writes.filter(write => write === "applyFeePolicy")).toEqual([]); + expect(info.mock.calls.filter(([message]) => String(message).includes("of any account"))).toHaveLength(1); + }); + + it("sends nothing and carries on when the apply's simulation reverts", async () => { + reads.pendingFeePolicyEffectiveAt = NOW; + simulateError = new Error("NoPendingFeePolicy()"); + const warn = spyOn(logger, "warn"); + const { accountId, deposit } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["read targetPpm"]); + expect(warn.mock.calls.some(([message]) => String(message).includes("NoPendingFeePolicy"))).toBe(true); + expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); + }); + + it("logs a mined but reverted apply as failed, never as applied", async () => { + reads.pendingFeePolicyEffectiveAt = NOW; + receiptStatus = "reverted"; + const info = spyOn(logger, "info"); + const warn = spyOn(logger, "warn"); + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(writes.filter(write => write === "applyFeePolicy")).toEqual(["applyFeePolicy"]); + expect(warn.mock.calls.some(([message]) => /applyFeePolicy .* failed: transaction 0x[0-9a-f]+ reverted/.test(String(message)))).toBe(true); + expect(info.mock.calls.some(([message]) => String(message).includes("applied the pending fee policy"))).toBe(false); + }); + it("sends nothing while no increase is pending or its timelock still runs", async () => { const { accountId } = await activeAccountWithDeposit(); await runConversionExecutor(accountId); 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 3d54343e0..745b32179 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1449,16 +1449,34 @@ async function sendPoke(forwarder: Address): Promise { } } +/** + * How long an unmined applyFeePolicy is left alone before it is sent again: longer than a + * private relay keeps a transaction. A dropped apply only delays a fee increase, which never + * costs the client. + */ +const FEE_APPLY_RESEND_AFTER_MS = 15 * 60_000; + +/** The last applyFeePolicy sent per forwarder (lower-cased address). */ +// ponytail: in-process memory; a restart forgets an unmined apply and may send it once more (the +// duplicate reverts NoPendingFeePolicy or is dropped). Persist it if that ever costs more than gas. +const sentFeeApplies = new Map(); +/** The skip reason last logged per forwarder, so a skip is logged once, not every cycle. */ +const loggedFeeApplySkips = new Map(); + /** * P11: finalizes a guardian fee increase whose timelock has elapsed (`applyFeePolicy` is * permissionless; nothing else would ever apply it). Waits for the receipt so the cycle - * then reads the applied policy, and skips while any keeper transaction is still unmined so - * a timed-out apply is not re-sent every cycle. Best-effort like sendPoke: a failed read, a revert (applied - * by someone else, clock skew) or a timeout is logged and the cycle goes on, since the - * contract always settles under its current on-chain policy. The monitor's config reconciliation mirrors - * the applied values into the account row. + * then reads the applied policy. A timed-out apply is not re-sent every cycle: the forwarder's + * last apply is remembered and nothing is sent while it has no receipt and is younger than + * FEE_APPLY_RESEND_AFTER_MS (keeper writes go through the private transport on mainnet, which + * the public nonce counts never show), nor while the public pool holds any keeper transaction, + * of any account. A skip is logged once per reason. Best-effort like sendPoke: a failed read, a + * revert (applied by someone else, clock skew) or a timeout is logged and the cycle goes on, + * since the contract always settles under its current on-chain policy. The monitor's config + * reconciliation mirrors the applied values into the account row. */ async function applyDueFeePolicy(forwarder: Address): Promise { + const key = forwarder.toLowerCase(); try { const client = getPublicClient(); // Read here, not with the cycle's state: a failed read must not stop the cycle's recover or poke. @@ -1467,9 +1485,33 @@ async function applyDueFeePolicy(forwarder: Address): Promise { address: forwarder, functionName: "pendingFeePolicyEffectiveAt" }); - if (effectiveAt === 0n) return; + if (effectiveAt === 0n) { + loggedFeeApplySkips.delete(key); + return; + } const { timestamp } = await client.getBlock(); if (timestamp < effectiveAt) return; + const skip = (reason: string) => { + if (loggedFeeApplySkips.get(key) === reason) return; + loggedFeeApplySkips.set(key, reason); + logger.info( + `monerium-b2b: applyFeePolicy on forwarder ${forwarder} is due for ${timestamp - effectiveAt}s but not sent: ${reason}` + ); + }; + const earlier = sentFeeApplies.get(key); + if (earlier && Date.now() - earlier.sentAtMs < FEE_APPLY_RESEND_AFTER_MS) { + const mined = await client.getTransactionReceipt({ hash: earlier.hash }).then( + () => true, + (error: unknown) => { + if (error instanceof TransactionReceiptNotFoundError) return false; + throw error; + } + ); + if (!mined) { + skip(`its earlier apply ${earlier.hash} is still unmined`); + return; + } + } const keeper = getKeeperWalletClient(); const call = { abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "applyFeePolicy" } as const; await client.simulateContract(call); @@ -1478,11 +1520,16 @@ async function applyDueFeePolicy(forwarder: Address): Promise { client.getTransactionCount({ address: keeper.account.address, blockTag: "latest" }), client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }) ]); - // An earlier apply (or any keeper send) still unmined: re-sending would only stack nonces. + // Any keeper send still unmined in the public pool: an apply now would only stack behind it. if (pending !== latest) return null; return keeper.writeContract({ ...call, chain: null }); }); - if (hash === null) return; + if (hash === null) { + skip("a keeper transaction (of any account) is still unmined in the public pool"); + return; + } + sentFeeApplies.set(key, { hash, sentAtMs: Date.now() }); + loggedFeeApplySkips.delete(key); const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }); if (receipt.status !== "success") throw new Error(`transaction ${hash} reverted`); logger.info(`monerium-b2b: applied the pending fee policy on forwarder ${forwarder} (${hash})`); diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 55c7cfa14..6ddd6c4d6 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -467,9 +467,12 @@ contract settles every fill into three bands against that reference (decisions: lowering is immediate (registry P11). Swaps always use the currently applied policy. Once an increase is due, the keeper sends `applyFeePolicy()` in the account's next cycle, before pricing, and waits for it; a failed read of the pending policy, a revert - or a timeout is logged and the cycle goes on, and no apply is re-sent while a keeper transaction is still unmined. The - executor only runs for accounts with a settling deposit, so an idle clone's increase - is applied before its next swap. + or a timeout is logged and the cycle goes on. A timed-out apply is not re-sent while it + has no receipt and is younger than 15 minutes (remembered per forwarder in process + memory, since a private relay's pending transactions are invisible to the public nonce + counts), and no apply is sent while the public pool holds any keeper transaction, of + any account; each skip reason is logged once. The executor only runs for accounts + with a settling deposit, so an idle clone's increase is applied before its next swap. - **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded from the treasury. It pays only when called by a factory-registered clone, to the clone itself (the subsidy is forwarded with the payment), within a guardian-settable diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 348e84278..8f1520887 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -36,13 +36,13 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CYCLE_SECONDS`, default 20: webhook inbox → onboarding → mint watcher → per-account conversion executor → dormancy gate → refund deadlines and orchestrator → partner registrations → manager events and monitoring) holds signing keys and submits transactions; the registration step runs after the money steps and isolated, so a slow Monerium or RPC read never holds back a conversion or refund. Its invariants: 1. **Three-way key separation, bounded fund movement** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`, `applyFeePolicy()`, `swap()`, `forward()` and `recover()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can redirect funds: `swap` only executes a factory-whitelisted, oracle-floored swap whose USDC stays on the clone, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `forward` only ever pays the clone's own `destination`; `recover` only ever pays the clone's `recoveryAddress`, the client's refund wallet fixed at deployment with no setter, and only once the clone's batch marker has been open for `RECOVERY_DELAY` (the contract, not the keeper, enforces the promised window); `setGuardianPaused` is protective-only by contract invariant (it blocks swaps and forwards, never a recovery); the attestor signs the fixed link statement. Besides the guardian, an address the guardian registers on the factory as a deployer (`setDeployer`) may call `deployForwarder` and no other factory or clone function, so deploying needs no guardian signature; the backend's deployer key (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, distinct from the other keys) holds that role for partner registrations (invariant 12). The role is not harmless, though: every clone a deployer deploys is registered (`isForwarder`), the trust anchor of the subsidy vault's `pay` and of the backend's mapping check, and a deployer may pick any destination and recovery address. A leaked deployer key can therefore create registered clones of its own, fund them itself and draw vault subsidies within the vault's per-swap cap and daily budget; it can touch no existing clone's funds or configuration. The guardian bounds it: `setDeployer(address, false)` stops new deployments but does not unregister clones already deployed, and `revokeForwarder(clone)` (guardian-only, one-way, emits `ForwarderRevoked`, reverts `UnknownForwarder` for an unregistered address) removes one from the registry, after which the vault refuses its subsidy calls, so a swap that needs a subsidy reverts, and the backend refuses to map it (the config reconciliation, R07, reports the revoked clone of an onboarding or active account as an error); its forward and recover paths and its funds are untouched. The factory binds each clone's CREATE2 address to its whole initialization, `keccak256(abi.encode(destination, recoveryAddress, targetPpm, floorPpm, salt))`, so `predictAddress` takes all five arguments (manual deploys too) and a deployer cannot squat an address predicted for other settings. Deployers survive a guardian transfer like keepers: a new guardian must review the `DeployerSet` events and reset them. The role takes the guardian key out of deployment only, not off the server: the dormancy pause (invariant 6) still signs with it. A clone still passes the account mapping's on-chain verification (trusted factory, `isForwarder`, destination, fee policy, derived refund wallet) before it is used. Vortex therefore holds a client's funds only on the refund path, only in that client's own refund wallet, and only for a payment the promised window was missed on (adr-0005, amendments 2026-09-17 and 2026-10-01). All keys are env-only and never logged. -2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. +2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. Known limit: the keeper's nonce reads use the public read RPC (the value path's explicit nonce, the re-send's nothing-pending check), which cannot see a keeper transaction still unmined in the private relay (a `poke` or `applyFeePolicy` sent with an implicit nonce), so a later send can take the same nonce; only one transaction per nonce is mined, and a losing value-moving row fails closed through the exact-match recovery (invariant 3) and retries after the backoff, so the cost is delay, never a double send. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. The retry backoff (1 min, doubling per failed row since the account's last confirmed execution, at most 60 min) holds a swap or forward back on failures of any kind, but a planned `recover` only on failed recovers, so failing swaps never delay a refund past its on-chain eligibility. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; on a clone that holds less than `MIN_SWAP_FLOOR` EURe and no USDC the marker never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; it skips the send while any keeper transaction is still unmined (latest and pending nonce differ), so a timed-out apply is not re-sent and stacked every cycle; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction; keeper writes go through the private transport on mainnet, which the public nonce counts never show), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply and may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. ## Monitoring From e0a22546296cc810c47fe234bccbb01c6ee29b14 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:11:01 +0200 Subject: [PATCH 31/80] test(api): pin the next-cycle poke after a failed sequenced poke A sequenced poke whose write throws is not followed by a compensating poke: the write may still have reached the relay, and the failed row's backoff pokes an unarmed marker on the next cycle anyway. The test now pins that one-cycle bound and the comment says why. --- .../api/services/monerium-b2b/conversion-executor.test.ts | 7 ++++++- .../src/api/services/monerium-b2b/conversion-executor.ts | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) 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 109bc60a1..bf799b4eb 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 @@ -1307,7 +1307,7 @@ describe("runConversionExecutor batch marker", () => { expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Pending]); }); - it("does not poke again after the sequenced poke was attempted", async () => { + it("does not poke again after the sequenced poke was attempted, and pokes in the next cycle's backoff", async () => { const writes = arrange({ batchOpenedAt: 0n, failPokeWrite: true }); const { accountId } = await activeAccountWithDeposit(); @@ -1315,6 +1315,11 @@ describe("runConversionExecutor batch marker", () => { expect(writes).toEqual(["poke:7"]); expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]); + + // The write may have reached the relay; the marker is re-armed one cycle later at most. + await runConversionExecutor(accountId); + expect(writes).toEqual(["poke:7", "poke:auto"]); + expect(await executions(accountId)).toHaveLength(1); }); it("keeps the poke-then-swap sequence on a sent attempt, with no extra poke", 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 745b32179..9c2e17ef5 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1261,7 +1261,9 @@ export async function runConversionExecutor(accountId: string): Promise { return; } const { attempt, execution } = slot; - // Cleared once the sequenced poke is attempted, so a failed attempt never pokes twice. + // Cleared once the sequenced poke is attempted: a poke write that throws may still have + // reached the relay, and the failed row's backoff pokes an unarmed marker on the next + // cycle anyway, so a compensating poke here would buy one cycle for a possible duplicate. let pokeOutstanding = pokeNeeded; try { From 49678789d3ad4310f119a065de9331d5bc05026e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:11:26 +0200 Subject: [PATCH 32/80] docs(api): count poke and fee apply in the keeper module comment The module comment still promised one transaction per account per cycle, which the poke and fee-apply sends of the same cycle contradict. --- apps/api/src/api/services/monerium-b2b/conversion-executor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 9c2e17ef5..f3527a9e9 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -52,7 +52,8 @@ import { fetchCoinbaseReference, isWithinReferenceBand, ReferenceQuote } from ". * to the client's destination in one transfer; * - `recover(eure, usdc)`: a deposit marked `recovering` is moved to the recovery * wallet once the clone's batch has been open for RECOVERY_DELAY. - * One transaction per account per cycle; a pending row of any kind blocks the next. + * At most one value-moving transaction per account per cycle, plus the permissionless + * `poke` and `applyFeePolicy`; a pending row of any kind holds the account's cycle until it resolves. * * Serialization: every database mutation runs inside the per-forwarder advisory lock * (withForwarderLock). The chain send/wait itself deliberately happens OUTSIDE a lock — From dfe96ea9bc3072701a75396c84dd3959576f1f70 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:11:33 +0200 Subject: [PATCH 33/80] docs(api): bound the marker delay after a dropped sequenced poke The re-send fills a dropped poke's nonce with a no-op rather than a poke. That is enough: the re-sent swap or forward arms an unarmed marker itself, and after a no-op the next cycle pokes, so the recovery clock starts about the five-minute idle deadline late. Re-poking the gap would need its own gas estimate for that bound. --- apps/api/src/api/services/monerium-b2b/conversion-executor.ts | 4 +++- docs/architecture-monerium-b2b-onramp.md | 4 +++- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) 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 f3527a9e9..d9a3b74cf 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -597,7 +597,9 @@ function isReplayable(pending: MoneriumConversionExecution, account: MoneriumAcc * closed or dormant) is treated the same way (its call must not run), while a recover is * exempt from that gate: it is the refund path. When * the mined nonce is below the row's (a swap or forward reserves nonce+1 behind a poke that - * was dropped), the gap is first filled with no-ops so the row's nonce can be mined. The keeper lock covers the + * was dropped), the gap is first filled with no-ops so the row's nonce can be mined; the + * re-sent swap or forward arms an unarmed marker itself, and after a no-op the next cycle + * pokes, so a dropped poke delays the clock by about the idle deadline. The keeper lock covers the * re-check and the send, so a live owner still about to send is waited for. Lock order is * forwarder, then keeper; the send path never takes the forwarder lock. This is the one * send made while the forwarder lock is held; the caller checks the grace first so a live diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 6ddd6c4d6..24535fcda 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -322,7 +322,9 @@ threw, or a private relay dropped the transaction) would block the account and i refund path forever, so once the row has been idle for the same five minutes and the nonce is not yet mined and nothing is pending at the keeper's next nonce, the keeper (first filling with zero-value self-transfers any gap below the row's nonce, which a -dropped `poke()` leaves because a swap or forward reserves nonce+1 behind it) +dropped `poke()` leaves because a swap or forward reserves nonce+1 behind it; the +re-sent swap or forward arms an unarmed marker itself and after a no-op the next cycle +pokes, so a dropped poke delays the recovery clock by about the five idle minutes) re-sends the row's exact calldata at that nonce under the keeper send lock. Whichever copy is mined is the expected call and the exact recovery below adopts it. If the call no longer executes (the gas estimate reverts; a transport error or an unfunded keeper diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 8f1520887..606d2221c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -37,7 +37,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 1. **Three-way key separation, bounded fund movement** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`, `applyFeePolicy()`, `swap()`, `forward()` and `recover()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can redirect funds: `swap` only executes a factory-whitelisted, oracle-floored swap whose USDC stays on the clone, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `forward` only ever pays the clone's own `destination`; `recover` only ever pays the clone's `recoveryAddress`, the client's refund wallet fixed at deployment with no setter, and only once the clone's batch marker has been open for `RECOVERY_DELAY` (the contract, not the keeper, enforces the promised window); `setGuardianPaused` is protective-only by contract invariant (it blocks swaps and forwards, never a recovery); the attestor signs the fixed link statement. Besides the guardian, an address the guardian registers on the factory as a deployer (`setDeployer`) may call `deployForwarder` and no other factory or clone function, so deploying needs no guardian signature; the backend's deployer key (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, distinct from the other keys) holds that role for partner registrations (invariant 12). The role is not harmless, though: every clone a deployer deploys is registered (`isForwarder`), the trust anchor of the subsidy vault's `pay` and of the backend's mapping check, and a deployer may pick any destination and recovery address. A leaked deployer key can therefore create registered clones of its own, fund them itself and draw vault subsidies within the vault's per-swap cap and daily budget; it can touch no existing clone's funds or configuration. The guardian bounds it: `setDeployer(address, false)` stops new deployments but does not unregister clones already deployed, and `revokeForwarder(clone)` (guardian-only, one-way, emits `ForwarderRevoked`, reverts `UnknownForwarder` for an unregistered address) removes one from the registry, after which the vault refuses its subsidy calls, so a swap that needs a subsidy reverts, and the backend refuses to map it (the config reconciliation, R07, reports the revoked clone of an onboarding or active account as an error); its forward and recover paths and its funds are untouched. The factory binds each clone's CREATE2 address to its whole initialization, `keccak256(abi.encode(destination, recoveryAddress, targetPpm, floorPpm, salt))`, so `predictAddress` takes all five arguments (manual deploys too) and a deployer cannot squat an address predicted for other settings. Deployers survive a guardian transfer like keepers: a new guardian must review the `DeployerSet` events and reset them. The role takes the guardian key out of deployment only, not off the server: the dormancy pause (invariant 6) still signs with it. A clone still passes the account mapping's on-chain verification (trusted factory, `isForwarder`, destination, fee policy, derived refund wallet) before it is used. Vortex therefore holds a client's funds only on the refund path, only in that client's own refund wallet, and only for a payment the promised window was missed on (adr-0005, amendments 2026-09-17 and 2026-10-01). All keys are env-only and never logged. 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. Known limit: the keeper's nonce reads use the public read RPC (the value path's explicit nonce, the re-send's nothing-pending check), which cannot see a keeper transaction still unmined in the private relay (a `poke` or `applyFeePolicy` sent with an implicit nonce), so a later send can take the same nonce; only one transaction per nonce is mined, and a losing value-moving row fails closed through the exact-match recovery (invariant 3) and retries after the backoff, so the cost is delay, never a double send. -3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. The retry backoff (1 min, doubling per failed row since the account's last confirmed execution, at most 60 min) holds a swap or forward back on failures of any kind, but a planned `recover` only on failed recovers, so failing swaps never delay a refund past its on-chain eligibility. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. +3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers; the gap is not re-poked, because the re-sent `swap` or `forward` arms an unarmed marker itself and, after a no-op, the next cycle pokes, so a dropped poke delays the recovery clock by about the five-minute idle deadline) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. The retry backoff (1 min, doubling per failed row since the account's last confirmed execution, at most 60 min) holds a swap or forward back on failures of any kind, but a planned `recover` only on failed recovers, so failing swaps never delay a refund past its on-chain eligibility. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; on a clone that holds less than `MIN_SWAP_FLOOR` EURe and no USDC the marker never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. From 13316dd8d341b752434439f8384d208d0ec5831a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:18:49 +0200 Subject: [PATCH 34/80] fix(api): report refundedAt for a refund closed without a recovery row A deposit closed as refunded through the admin status route has no MoneriumRecovery row, so the partner snapshot reported refund.refundedAt as null forever, which reads as a refund that never completed. Fall back to the deposit's own updated_at, its refunded transition, and keep the received and returned event markers silent so that timestamp does not move after the fact. --- .../monerium-b2b/manager-events.test.ts | 36 +++++++++++++++++++ .../services/monerium-b2b/manager-events.ts | 11 ++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts index f2707981e..b94caf800 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts @@ -17,6 +17,7 @@ import { provisionMoneriumB2bAccount } from "./account-provisioning"; import { NOTIFY_CONFIRMATION_DEPTH } from "./chain"; import { markDepositForRecovery } from "./conversion-executor"; import { emitMoneriumDepositEvents, maskIban } from "./manager-events"; +import { setDepositStatus } from "./recovery"; const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; @@ -335,6 +336,41 @@ describe("monerium b2b manager events", () => { expect(maskIban("EE08 7224 5745 6244 9516")).toBe("EE08…9516"); }); + it("reports a refund closed by hand as refunded at its refunded transition", async () => { + const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RETURNED, WebhookEventType.DEPOSIT_UPDATED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "500000000000000000", + blockNumber: 999, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + receivedEventAt: new Date(), + refundReason: "window_missed", + refundStartedAt: new Date(), + status: MoneriumFiatDepositStatus.Recovering, + txHash: "0xmint" + }); + // The admin PATCH path: no recover execution, no MoneriumRecovery row. + expect(await setDepositStatus(deposit, MoneriumFiatDepositStatus.Refunded)).toBeNull(); + await deposit.reload(); + const refundedAt = deposit.updatedAt.toISOString(); + await new Promise(resolve => setTimeout(resolve, 5)); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await emitMoneriumDepositEvents(depsAtBlock(null)); + const updates = (await WebhookDelivery.findAll()) + .map(delivery => delivery.payload as unknown as { eventType: string; payload: Record }) + .filter(payload => payload.eventType === WebhookEventType.DEPOSIT_UPDATED); + expect(updates).toHaveLength(1); + expect(updates[0].payload).toMatchObject({ + refund: { recoverTxHash: null, redeemOrderId: null, refundedAt }, + status: "refunded" + }); + }); + it("only enqueues to the controlling manager's webhooks", async () => { const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); const otherManager = await createTestUser(); diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.ts b/apps/api/src/api/services/monerium-b2b/manager-events.ts index 4427c8201..dea3ebdfe 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -177,7 +177,10 @@ export async function depositSnapshots( reason: deposit.refundReason as DepositRefundReason | null, recoverTxHash: recover?.txHash ?? null, redeemOrderId: recovery?.redeemOrderId ?? null, - refundedAt: deposit.status === MoneriumFiatDepositStatus.Refunded ? iso(recovery?.updatedAt) : null, + // A refund closed by hand has no recovery row; the deposit's last write is then + // its refunded transition (the received/returned markers are silent, the status is terminal). + refundedAt: + deposit.status === MoneriumFiatDepositStatus.Refunded ? iso(recovery?.updatedAt ?? deposit.updatedAt) : null, startedAt: iso(deposit.refundStartedAt) } : null, @@ -240,7 +243,8 @@ async function emitReceivedEvents(): Promise { // Marked emitted even with zero subscribers: webhooks are forward-looking, a // later registration must not receive the whole history. A crash between the // enqueue and this marker is absorbed by the outbox (webhook_id, event_id) dedup. - await deposit.update({ receivedEventAt: new Date() }); + // Silent: updated_at is a refunded deposit's refundedAt fallback. + await deposit.update({ receivedEventAt: new Date() }, { silent: true }); } catch (error) { // Per-deposit isolation: one failing deposit must not block its siblings. logger.error(`monerium-b2b: DEPOSIT_RECEIVED emission failed for deposit ${deposit.id}:`, error); @@ -351,7 +355,8 @@ async function emitReturnedEvents(): Promise { timestamp: new Date().toISOString() }; await enqueueForManager(WebhookEventType.DEPOSIT_RETURNED, managerProfileId, payload); - await deposit.update({ returnedEventAt: new Date() }); + // Silent: updated_at stays the refunded transition, the refundedAt fallback below. + await deposit.update({ returnedEventAt: new Date() }, { silent: true }); } catch (error) { logger.error(`monerium-b2b: DEPOSIT_RETURNED emission failed for deposit ${deposit.id}:`, error); } From 35e7c62efff7bd85ce3965c1d3867f647213bcda Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:19:24 +0200 Subject: [PATCH 35/80] docs(api): say deposit waiting reasons may grow The reference listed the waiting reasons as a closed enum, so a strict client validating against it would reject a reason added later, as below_minimum just was, and its webhook endpoint would keep failing deliveries. Tell clients to treat an unknown reason as a generic wait. --- docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/api/openapi/vortex.openapi.json | 2 +- docs/api/pages/07-webhooks.md | 2 +- packages/shared/src/endpoints/webhook.endpoints.ts | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 5fdfe1419..53f2d41ef 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -3054,7 +3054,7 @@ export interface components { txHash: string | null; /** @description Sum of the confirmed chunks' net USDC in 6-decimal base units: what the deposit's single transfer delivers once forwarded. */ usdcNetRaw: string; - /** @description Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise. */ + /** @description Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise. New reasons may be added; treat an unknown reason as a generic wait. */ waiting: { /** @enum {string} */ reason: "monerium_pending" | "account_not_active" | "oracle_unavailable" | "reference_unavailable" | "reference_out_of_band" | "no_route" | "below_floor" | "below_minimum"; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 70c2632ce..921d57fa4 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2787,7 +2787,7 @@ "type": "string" }, "waiting": { - "description": "Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise.", + "description": "Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can convert, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise. New reasons may be added; treat an unknown reason as a generic wait.", "properties": { "reason": { "enum": [ diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index bddc7e09c..9069fdc5c 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -265,7 +265,7 @@ Fired whenever anything about a deposit changes: the payment arrives at the EUR - **IDs:** `depositId`, `accountId`, `profileId` (your managed child, the `X-Managed-Profile-Id` value), `externalSubjectId` (your own client reference), and `moneriumProfileId` and `moneriumOrderId` (the EUR provider's IDs for the client and for the incoming payment). Every transaction hash is included: the mint (`txHash`), each chunk, the forward, and on a refund the recovery transaction and the provider's redemption order. - **Amounts:** `amount` is the EUR amount to the cent and `amountRaw` the same in 18-decimal base units. Each chunk carries the EURe it converted and its net USDC; the top-level `usdcNetRaw` is the sum of the confirmed chunks, which the single transfer delivers. - **Timestamps:** `receivedAt`, `mintedAt` (the conversion window counts from here), each chunk's `sentAt` and `confirmedAt`, `deliveredAt`, and on a refund `refund.startedAt` and `refund.refundedAt`. -- **Hold status:** `waiting` is set while the deposit waits. `monerium_pending` means the EUR provider has not minted it yet: it is minting or under a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor`, when the market is below the client's floor by more than the subsidy currently allows. +- **Hold status:** `waiting` is set while the deposit waits. `monerium_pending` means the EUR provider has not minted it yet: it is minting or under a compliance review, which the provider does not tell apart. After the mint, `account_not_active` while the client's account cannot convert (it is not `active` yet, is suspended, or is paused for dormancy; the reason clears once it can, and a payment still waiting when the promised conversion window ends is refunded), `below_minimum` while the amount still to convert is below the minimum swap (the payment waits for the refund path and holds back no later payment), otherwise the reason Vortex is holding the next chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor`, when the market is below the client's floor by more than the subsidy currently allows. New reasons may be added; treat an unknown reason as a generic wait. - **Failure status:** `rejectedReason` holds the provider's reason when it returned the payment before minting (`status` is then `returned`). `refund.reason` says why a deposit is refunded: `window_missed`, `compliance`, `incident`, or `operator`. `eventId` is unique per snapshot. If retries deliver an older snapshot after a newer one, keep the one with the later `timestamp`. diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index fd44b2e4c..9bbaac786 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -191,7 +191,8 @@ export interface DepositReturnedWebhookPayload { * account cannot convert (not activated yet, suspended, or paused for dormancy), * `below_minimum` while the unconverted amount is below the minimum swap (it waits for the * refund path and holds back no later deposit), otherwise the reason the keeper is holding - * the next conversion chunk. + * the next conversion chunk. New reasons may be added; clients treat an unknown one as a + * generic wait. */ export type DepositWaitingReason = | "monerium_pending" From 666326cda77f7dfb19d2d479bd3a1aea681f6af3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:20:15 +0200 Subject: [PATCH 36/80] docs(api): state the ordering cost of passing over small payments The architecture doc still called a chunk-swapped remainder recoverable as usual because its USDC keeps the marker armed, yet every younger forward re-times that marker, and a younger forward that leaves a clone below the floor switches it off. Spell out both outcomes so the top-up and REFUND OVERDUE cases read as the known cost of no longer blocking younger payments. --- docs/architecture-monerium-b2b-onramp.md | 16 +++++++++++----- .../05-integrations/monerium-b2b.md | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 24535fcda..42c4a151a 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -385,11 +385,17 @@ permissionless `poke` and `applyFeePolicy`): the payment shows `below_minimum`: the marker then arms at once and the refund lands at about the normal deadline instead of a `RECOVERY_DELAY` after the alert. Monerium's minimum redeem order is EUR 0.01, so a sub-EUR-1 refund is allowed (not yet rehearsed - end to end). A remainder left after chunk swaps is recoverable as usual: its USDC - keeps the marker armed. Every younger deposit's `forward` re-times the marker, so a - passed-over remainder at or above the floor (possible only once `minSwapAmount` is - raised above `MIN_SWAP_FLOOR`) becomes recoverable only after a forward-free - `RECOVERY_DELAY`; the stranded-balance monitor reports it as REFUND OVERDUE. + end to end). Passing over trades ordering for liveness: a younger payment converts + and forwards before the older passed-over one is refunded, and every `forward` + resyncs the marker (`_syncBatch(true)`). A clone left below the floor with no USDC + switches the marker off, so the older payment needs the top-up above even where the + younger balance, had it been held back, would have kept the marker armed for it. + While the clone stays funded (a passed-over remainder at or above the floor, possible + only once `minSwapAmount` is raised above `MIN_SWAP_FLOOR`, or a remainder left after + chunk swaps, whose USDC is still on the clone), each younger `forward` re-times the + marker instead, so the passed-over deposit becomes recoverable only after a + forward-free `RECOVERY_DELAY`; the stranded-balance monitor reports it as REFUND + OVERDUE once it runs late. - **The refund path.** A deposit marked `recovering` — by an operator through the admin endpoint, or once automated by the missed window — is moved off the clone with `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 606d2221c..650765706 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -39,7 +39,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. Known limit: the keeper's nonce reads use the public read RPC (the value path's explicit nonce, the re-send's nothing-pending check), which cannot see a keeper transaction still unmined in the private relay (a `poke` or `applyFeePolicy` sent with an implicit nonce), so a later send can take the same nonce; only one transaction per nonce is mined, and a losing value-moving row fails closed through the exact-match recovery (invariant 3) and retries after the backoff, so the cost is delay, never a double send. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers; the gap is not re-poked, because the re-sent `swap` or `forward` arms an unarmed marker itself and, after a no-op, the next cycle pokes, so a dropped poke delays the recovery clock by about the five-minute idle deadline) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. The retry backoff (1 min, doubling per failed row since the account's last confirmed execution, at most 60 min) holds a swap or forward back on failures of any kind, but a planned `recover` only on failed recovers, so failing swaps never delay a refund past its on-chain eligibility. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. -5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; on a clone that holds less than `MIN_SWAP_FLOOR` EURe and no USDC the marker never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; each younger `forward` re-times the batch marker, so a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; the trade-off is ordering: a younger deposit forwards before the older passed-over one is refunded, and each `forward` resyncs the batch marker (`_syncBatch(true)`); on a clone left with less than `MIN_SWAP_FLOOR` EURe and no USDC the marker switches off or never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; while the clone stays funded (a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, or a chunk-swapped remainder whose USDC is still on the clone) each younger `forward` re-times the marker, so the passed-over deposit is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction; keeper writes go through the private transport on mainnet, which the public nonce counts never show), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply and may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. From b3da667d6189887b42886be5a58a1b89412cfed6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:20:53 +0200 Subject: [PATCH 37/80] docs(api): add audit checklist items for the keeper gap fixes The audit checklist pinned none of the new security-relevant keeper and monitor behaviours, so an auditor had no pointer from the invariants to the tests that hold them: the below_minimum pass-over and clear, the recover backoff exemption, the fee apply re-send guard, the top-up remedy and the overdue refund alert. --- docs/security-spec/05-integrations/monerium-b2b.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 650765706..a9debd0d8 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -114,6 +114,9 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; a cycle that sends no swap (deferral, retry backoff, failed attempt) still pokes an unarmed funded batch, at most once per cycle (a cycle waiting on a pending execution leaves it to that execution's sequence); no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` - [ ] Every execution carries `kind` and `deposit_id` (migration 080); the migration refuses an execution that spanned several deposits under the former allocation join instead of guessing - [ ] A due fee increase is applied once per cycle before pricing, nothing is sent while none is pending or its timelock runs, and a reverted apply is logged without stopping the cycle (`conversion-executor.test.ts`) +- [ ] The fee apply is guarded against re-sends: it is skipped while its own earlier apply is unmined (remembered for 15 minutes, so a private-relay apply the public pool cannot see is not re-sent before then) or while any keeper transaction is unmined in the public pool, each skip is logged once per reason, and a failed read of the pending policy only skips the apply (`conversion-executor.test.ts`: "does not re-send an unmined apply the public pool cannot see…", "does not apply while any keeper transaction is unmined…", "carries on with the cycle when the pending fee policy cannot be read") +- [ ] A remainder below `minSwapAmount` is passed over with `waitingReason` `below_minimum` and never holds back a younger deposit; the reason clears (with a fresh `waitingSince` on a same-cycle deferral) once the guardian lowers the minimum (`conversion-executor.test.ts`: "never lets a deposit below the minimum hold back a younger one", "tells the partner a payment below the minimum swap waits…", "clears below_minimum on a queued deposit…", "starts a fresh wait when a deposit cleared from below_minimum defers…") +- [ ] A planned `recover` waits out the retry backoff only for failed recovers, never for failed swaps or forwards, which still back off on a failure of any kind (`conversion-executor.test.ts`: "recovers an eligible deposit on time however often its swaps failed", "still backs a recover off on its own failed recovers") - [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`) - [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch - [ ] `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` is the factory queried during provisioning/monitoring, and a self-reported mismatch is rejected before local persistence @@ -123,6 +126,8 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] Registered accounts auto-activate only when `SANDBOX_ENABLED=true`, only with an IBAN and only from a mapped registration; activation sets `activated_at`, the dormancy anchor is the later of the last conversion and the activation, and a deposit on an account that cannot convert carries `account_not_active` until it can (`registration.test.ts`, `dormancy.test.ts`, `conversion-executor.test.ts`) - [ ] The admin registration list filters by status and withdraws only a `requested` registration (409 otherwise, 404 when unknown), and `onboarding → suspended` is accepted and logged (`moneriumB2b.controller.test.ts`) - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` +- [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`) and never a hand refund, fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, and only warns "reconcile" when funds left by an unrecorded path (`monitoring.test.ts`: "asks the operator for the exact top-up…", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…") +- [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") - [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; guardian fee-policy changes (R07/P11) are warn-level reconciliations, a destination change is an incident - [ ] Admin deposit endpoints (`POST .../deposits/:id/recover`, `PATCH .../deposits/:id/status`) only ever apply forward-only transitions under the forwarder lock; `POST .../recover` refuses a deposit with a pending execution, and `PATCH .../status` closes a parked refund from `recovery_failed` as `refunded` (`moneriumB2b.controller.test.ts`) - [ ] `recovery.test.ts` proves: the phase walk, the balance-derived crash recovery (no second swap or top-up), the memo adoption instead of a second redeem order, the float-underfunded wait, the supporting-document and missing-payer parking, the five-attempt failure (also across orchestrator cycles), a refused or failed deposit transition keeping the recovery open (no `redeemed` before `refunded`, no operator-retry reset after a refused park), a parked refund closed by hand from `recovery_failed` ending `redeemed` without a redeem order, a rejected order re-parking on a retry and never reported after the hand close, one recovery at a time per client (`activeRecoveryExists(accountId)`, also wired through the keeper) with other clients' refunds running side by side, at most one float-wallet send per cycle (top-up, reverse swap or surplus sweep), the parked float gate and the cycle time budget and the operator retry resuming from the preserved phase From 5e1382c2ae874793fea546c05592650f72cdc28a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:21:57 +0200 Subject: [PATCH 38/80] docs(api): match the overdue alert's scope to the monitor The architecture doc limited REFUND OVERDUE to clones at or above the floor, but the monitor also raises it on a sub-floor clone that still holds USDC, as the security spec already says. --- docs/architecture-monerium-b2b-onramp.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 42c4a151a..260c28073 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -567,9 +567,9 @@ read-only — no keys, no transactions: may have left the clone by an unrecorded path, so it warns "reconcile before refunding; do not refund and do not top up the clone". The pass skips that account while any of its `recover` executions is still pending: a mined `recover` not yet - confirmed would make the ledger overstate the clone. On a clone at or above the floor, a - `recovering` deposit with no pending or confirmed `recover` more than - `RECOVERY_DELAY` plus an hour after it was marked warns REFUND OVERDUE (an error past + confirmed would make the ledger overstate the clone. On a clone at or above the floor + (or holding USDC), a `recovering` deposit with no pending or confirmed `recover` more + than `RECOVERY_DELAY` plus an hour after it was marked warns REFUND OVERDUE (an error past `TRIGGER_DELAY`): younger forwards keep re-timing its marker, or another refund of the client is stuck. 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized From 216292a50fbf2c377b95d99a98faaa8a0e3be229 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:37:25 +0200 Subject: [PATCH 39/80] fix(api): keep every refund top-up off a whole cent The prescribed top-up was MIN_SWAP_FLOOR - balance + 1 wei, which is off a whole cent only while the clone balance is a whole number of cents. Once an earlier top-up's odd wei sits on the clone, the next top-up equals the recovered deposit's amount exactly, so the mint watcher could bind the operator's transfer to a pending order of that amount and record the client's real mint as unattributed. Add one more wei whenever the amount would land on a whole cent. --- .../services/monerium-b2b/monitoring.test.ts | 25 +++++++++++++++++++ .../api/services/monerium-b2b/monitoring.ts | 9 ++++--- docs/adr-0005-monerium-b2b-onramp.md | 11 ++++---- docs/architecture-monerium-b2b-onramp.md | 11 ++++---- .../05-integrations/monerium-b2b.md | 2 +- 5 files changed, 44 insertions(+), 14 deletions(-) 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 f746e2073..8d0cdc888 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -485,6 +485,31 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(warnings).toEqual([]); }); + it("keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", async () => { + // D1 (0.3) was recovered with top-up T1 = 0.3 EURe + 1 wei; D2 (0.4) and T1 stay on the clone. + const recovered = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, (EUR * 3n) / 10n); + await execution(recovered, MoneriumConversionExecutionKind.Recover, MoneriumConversionExecutionStatus.Confirmed); + const stuck = await addDeposit(recovered.accountId, MoneriumFiatDepositStatus.Recovering, (EUR * 4n) / 10n); + await MoneriumFiatDeposit.create({ + accountId: recovered.accountId, + amountRaw: ((EUR * 3n) / 10n + 1n).toString(), + blockNumber: 101, + chainId: 11155111, + currency: "eur", + logIndex: 99, + mintedAt: new Date(), + moneriumOrderId: "unattr:topup", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xtopup" + }); + eureOnClone = (EUR * 7n) / 10n + 1n; + await runStrandedBalanceMonitor(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(stuck.id); + // FLOOR - balance + 1 would be exactly 0.30 EUR, the amount of D1: matchable to a pending 0.30 order. + expect(errors[0]).toContain("send exactly 0.300000000000000001 EURe (300000000000000001 raw)"); + }); + for (const status of [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) { it(`stays quiet while the keeper's recover is ${status} (the automatic refund is under way)`, async () => { const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 058ea5186..3964ef826 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -465,10 +465,13 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi const onClone = ledgerEure <= eureBalance ? unswapped : []; const elsewhere = stuck.filter(deposit => !onClone.includes(deposit)); if (onClone.length > 0) { - // A top-up to one wei above the floor lets `poke` arm the marker, and the keeper's + // A top-up to just above the floor lets `poke` arm the marker, and the keeper's // normal `recover` then refunds the payer's own EURe; the top-up stays on the clone - // below the floor. The odd wei keeps the mint watcher from matching it to an order. - const topUpRaw = minSwapFloor - eureBalance + 1n; + // below the floor. Deposits and the floor are whole cents, so an amount off a whole cent + // can never match an order in the mint watcher; a leftover top-up's odd wei on the + // clone would make the plain `+ 1n` land on a whole cent, hence the second wei. + let topUpRaw = minSwapFloor - eureBalance + 1n; + if (topUpRaw % 10n ** 16n === 0n) topUpRaw += 1n; // 10^16 raw = one cent logger.error( `monerium-b2b: REFUND NEEDS OPERATOR — deposit(s) ${onClone.map(deposit => deposit.id).join(", ")} on forwarder ` + `${forwarder} (account ${account.id}) are marked for recovery, but the clone holds ${formatUnits(eureBalance, 18)} ` + diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index cffbc0c20..959a90e70 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -293,13 +293,14 @@ the promised window. Decisions: never arms its batch marker, so `recover` reverts for such a payment and the keeper cannot refund it on its own. Once it is marked `recovering`, the stranded-balance monitor raises REFUND NEEDS OPERATOR with the exact top-up (`MIN_SWAP_FLOOR - clone - EURe + 1 wei`), which an operator sends to the clone from any wallet, e.g. the float - (internal B2B runbook §2.7). The marker then arms and the keeper's normal `recover` + EURe + 1 wei`, one wei more when that is a whole number of cents), which an operator + sends exactly as printed to the clone from any wallet, e.g. the float (internal B2B + runbook §2.7). The marker then arms and the keeper's normal `recover` and refund pipeline refund the payer's own EURe after `RECOVERY_DELAY`, with the full refund record and the one-refund-per-client gate; such a payment is never refunded by - hand. The top-up stays on the clone below the floor as Vortex EURe (the odd wei keeps - the mint watcher from attributing it to an order), and a later sub-floor payment that - reaches the floor with it is refunded with no operator step. Sending the top-up as + hand. The top-up stays on the clone below the floor as Vortex EURe (never a whole + number of cents, so the mint watcher never attributes it to an order), and a later + sub-floor payment that reaches the floor with it is refunded with no operator step. Sending the top-up as soon as the payment shows `below_minimum` lands the refund at about the normal deadline; otherwise it lands `RECOVERY_DELAY` after the top-up. diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 260c28073..6a07c7f54 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -370,16 +370,17 @@ permissionless `poke` and `applyFeePolicy`): design against dust), so `recover` cannot move it unless a larger balance keeps the marker open for `RECOVERY_DELAY`; once marked `recovering` on a clone below the floor, the stranded-balance monitor raises REFUND NEEDS OPERATOR with the exact top-up: - `MIN_SWAP_FLOOR - clone EURe + 1 wei` of EURe, which operations send to the clone from - any wallet (e.g. the float; internal B2B runbook §2.7). The clone then holds the floor, + `MIN_SWAP_FLOOR - clone EURe + 1 wei` of EURe (one wei more when that is a whole + number of cents), which operations send, exactly as printed, to the clone from any + wallet (e.g. the float; internal B2B runbook §2.7). The clone then holds the floor, `poke` arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline below refund the payer exactly, from the payer's own EURe, with the full record (`recover` transaction, redeem order, `refundedAt`); the deposit is never refunded by hand. The top-up stays on the clone below the floor, so `recover` switches the marker off by itself: no follow-up step. The - odd wei makes it a non-whole-cent amount the mint watcher can never match to a - Monerium order, so it is recorded as an `unattr:` Minted row (one warn) that the keeper - never converts. Leftover top-ups are Vortex EURe and stay below EUR 1 per clone; a + top-up is never a whole number of cents, even with an earlier top-up's odd wei on the + clone, so the mint watcher can never match it to a Monerium order; it is recorded as + an `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups are Vortex EURe and stay below EUR 1 per clone; a later sub-floor payment on that clone that reaches the floor with them arms the marker and is refunded with no operator step. The top-up may also be sent early, as soon as the payment shows `below_minimum`: the marker then arms at once and the refund lands at diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index a9debd0d8..37cb68b0e 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (decimal and raw) of the EURe token, which the operator sends to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The odd wei makes the amount a non-whole-cent value that the mint watcher can never match to a Monerium order by amount, so it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From 52056c416fd2199e3bc535bf8546d28684d2d508 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:38:09 +0200 Subject: [PATCH 40/80] fix(api): point the top-up alert to the manual redeem outside auto mode REFUND NEEDS OPERATOR fires in every MONERIUM_B2B_AUTO_RECOVERY mode, but only auto runs the refund pipeline after the keeper's recover. In alert or off the payer's EURe then waits on the client's refund wallet while the alert told the operator never to refund by hand. Name the mode and the manual redeem there instead. --- .../services/monerium-b2b/monitoring.test.ts | 22 ++++++++++++++++++- .../api/services/monerium-b2b/monitoring.ts | 13 ++++++++--- docs/adr-0005-monerium-b2b-onramp.md | 3 ++- docs/architecture-monerium-b2b-onramp.md | 5 ++++- .../05-integrations/monerium-b2b.md | 2 +- 5 files changed, 38 insertions(+), 7 deletions(-) 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 8d0cdc888..5e6ba4233 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -340,7 +340,11 @@ describe("runExecutableDepthCheck", () => { describe("runStrandedBalanceMonitor below the swap floor", () => { const FACTORY = "0x2222222222222222222222222222222222222222" as Address; const EURE = "0x4444444444444444444444444444444444444444" as Address; - const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl }; + const saved = { + autoRecovery: config.moneriumB2b.autoRecovery, + factory: config.moneriumB2b.forwarderFactoryAddress, + rpcUrl: config.moneriumB2b.rpcUrl + }; let errors: string[]; let warnings: string[]; let eureOnClone: bigint; @@ -355,10 +359,12 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { afterAll(() => { config.moneriumB2b.rpcUrl = saved.rpcUrl; config.moneriumB2b.forwarderFactoryAddress = saved.factory; + config.moneriumB2b.autoRecovery = saved.autoRecovery; }); beforeEach(async () => { await resetTestDatabase(); + config.moneriumB2b.autoRecovery = "auto"; errors = []; warnings = []; eureOnClone = EUR / 2n; @@ -456,6 +462,20 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(errors[0]).toContain("the internal B2B runbook §2.7"); }); + for (const mode of ["alert", "off"] as const) { + it(`points to the manual redeem instead of the automatic refund with auto recovery ${mode}`, async () => { + config.moneriumB2b.autoRecovery = mode; + await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + await runStrandedBalanceMonitor(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("send exactly 0.500000000000000001 EURe"); + expect(errors[0]).toContain("moves the payment to the client's refund wallet"); + expect(errors[0]).toContain(`MONERIUM_B2B_AUTO_RECOVERY is ${mode}, so no automatic refund follows`); + expect(errors[0]).toContain("redeem it from the refund wallet under the manual procedure"); + expect(errors[0]).not.toContain("never refund this deposit by hand"); + }); + } + it("asks for the top-up when the keeper's recover failed, since the funds stayed on the clone", async () => { const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); await execution(deposit, MoneriumConversionExecutionKind.Recover, MoneriumConversionExecutionStatus.Failed); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 3964ef826..507cf70ec 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -472,14 +472,21 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi // clone would make the plain `+ 1n` land on a whole cent, hence the second wei. let topUpRaw = minSwapFloor - eureBalance + 1n; if (topUpRaw % 10n ** 16n === 0n) topUpRaw += 1n; // 10^16 raw = one cent + // Only the `auto` mode runs the refund pipeline after the keeper's `recover`. + const autoRefund = config.moneriumB2b.autoRecovery === "auto"; logger.error( `monerium-b2b: REFUND NEEDS OPERATOR — deposit(s) ${onClone.map(deposit => deposit.id).join(", ")} on forwarder ` + `${forwarder} (account ${account.id}) are marked for recovery, but the clone holds ${formatUnits(eureBalance, 18)} ` + `EURe, below MIN_SWAP_FLOOR, so the contract cannot recover them: send exactly ${formatUnits(topUpRaw, 18)} EURe ` + `(${topUpRaw} raw) of EURe ${eure} to the clone from any wallet (e.g. the float). The keeper then arms the marker ` + - "and refunds through the normal recover path after RECOVERY_DELAY, one deposit at a time (this alert returns " + - "with a new amount for any left below the floor); the top-up stays on the clone as Vortex EURe; expect one " + - "unattributed-mint warn; never refund this deposit by hand (the internal B2B runbook §2.7)" + `and ${autoRefund ? "refunds through" : "moves the payment to the client's refund wallet with"} the normal ` + + "recover path after RECOVERY_DELAY, one deposit at a time (this alert returns with a new amount for any left " + + "below the floor); the top-up stays on the clone as Vortex EURe; expect one unattributed-mint warn; " + + (autoRefund + ? "never refund this deposit by hand" + : `MONERIUM_B2B_AUTO_RECOVERY is ${config.moneriumB2b.autoRecovery}, so no automatic refund follows: once ` + + "the recover is confirmed, redeem it from the refund wallet under the manual procedure") + + " (the internal B2B runbook §2.7)" ); } if (elsewhere.length > 0) { diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 959a90e70..6592bf4a0 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -298,7 +298,8 @@ the promised window. Decisions: runbook §2.7). The marker then arms and the keeper's normal `recover` and refund pipeline refund the payer's own EURe after `RECOVERY_DELAY`, with the full refund record and the one-refund-per-client gate; such a payment is never refunded by - hand. The top-up stays on the clone below the floor as Vortex EURe (never a whole + hand (with `MONERIUM_B2B_AUTO_RECOVERY=auto`; in `alert` or `off` the operator redeems + it from the client's refund wallet after the keeper's `recover`, as the alert says). The top-up stays on the clone below the floor as Vortex EURe (never a whole number of cents, so the mint watcher never attributes it to an order), and a later sub-floor payment that reaches the floor with it is refunded with no operator step. Sending the top-up as soon as the payment shows `below_minimum` lands the refund at about the normal diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 6a07c7f54..2c1c3dc20 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -376,7 +376,10 @@ permissionless `poke` and `applyFeePolicy`): `poke` arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline below refund the payer exactly, from the payer's own EURe, with the full record (`recover` transaction, redeem order, - `refundedAt`); the deposit is never refunded by hand. The top-up stays on the clone + `refundedAt`); the deposit is never refunded by hand. That refund pipeline runs only + with `MONERIUM_B2B_AUTO_RECOVERY=auto`; in `alert` or `off` the keeper's `recover` + still moves the payment to the client's refund wallet, and the alert points to the + manual redeem from that wallet instead. The top-up stays on the clone below the floor, so `recover` switches the marker off by itself: no follow-up step. The top-up is never a whole number of cents, even with an earlier top-up's odd wei on the clone, so the mint watcher can never match it to a Monerium order; it is recorded as diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 37cb68b0e..754a3d488 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`: in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead of forbidding a hand refund. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From c16aa15b17d0dc7a4a2359be92228360680532fa Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:38:49 +0200 Subject: [PATCH 41/80] fix(api): date a failed refund closed by hand at its close A refund the orchestrator parked in recovery_failed keeps a recovery row whose last write is the failure. When an operator closes it as refunded through the admin route, refund.refundedAt reported that failure time. Use the recovery row's timestamp only once the row is redeemed, otherwise the deposit's refunded transition. --- .../monerium-b2b/manager-events.test.ts | 42 +++++++++++++++++++ .../services/monerium-b2b/manager-events.ts | 11 +++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts index b94caf800..cdacef1fc 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts @@ -371,6 +371,48 @@ describe("monerium b2b manager events", () => { }); }); + it("dates a failed refund closed by hand at its refunded transition, not at the failure", async () => { + const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RETURNED, WebhookEventType.DEPOSIT_UPDATED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "500000000000000000", + blockNumber: 999, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + receivedEventAt: new Date(), + refundReason: "window_missed", + refundStartedAt: new Date(), + status: MoneriumFiatDepositStatus.RecoveryFailed, + txHash: "0xmint" + }); + // The orchestrator parked the refund at topping_up: the row's last write is the failure. + const recovery = await MoneriumRecovery.create({ + depositId: deposit.id, + error: "float top-up reverted", + eureRecoveredRaw: deposit.amountRaw, + phase: MoneriumRecoveryPhase.ToppingUp, + usdcRecoveredRaw: "0" + }); + await new Promise(resolve => setTimeout(resolve, 5)); + // The operator refunds by hand and closes it through the admin PATCH (recovery_failed -> refunded). + expect(await setDepositStatus(deposit, MoneriumFiatDepositStatus.Refunded)).toBeNull(); + await deposit.reload(); + expect(deposit.updatedAt.getTime()).toBeGreaterThan(recovery.updatedAt.getTime()); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + const updates = (await WebhookDelivery.findAll()) + .map(delivery => delivery.payload as unknown as { eventType: string; payload: Record }) + .filter(payload => payload.eventType === WebhookEventType.DEPOSIT_UPDATED); + expect(updates).toHaveLength(1); + expect(updates[0].payload).toMatchObject({ + refund: { refundedAt: deposit.updatedAt.toISOString() }, + status: "refunded" + }); + }); + it("only enqueues to the controlling manager's webhooks", async () => { const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); const otherManager = await createTestUser(); diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.ts b/apps/api/src/api/services/monerium-b2b/manager-events.ts index dea3ebdfe..a05378048 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -21,7 +21,7 @@ import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; -import MoneriumRecovery from "../../../models/moneriumRecovery.model"; +import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; import webhookService from "../webhook/webhook.service"; import { enqueueWebhookDeliveries } from "../webhook/webhook-outbox.service"; import { getPublicClient, NOTIFY_CONFIRMATION_DEPTH } from "./chain"; @@ -177,10 +177,13 @@ export async function depositSnapshots( reason: deposit.refundReason as DepositRefundReason | null, recoverTxHash: recover?.txHash ?? null, redeemOrderId: recovery?.redeemOrderId ?? null, - // A refund closed by hand has no recovery row; the deposit's last write is then - // its refunded transition (the received/returned markers are silent, the status is terminal). + // The recovery row dates the refund only once it is redeemed; a refund closed by hand + // (no row, or one parked at its failure) is dated by the deposit's refunded transition, + // its last write (the received/returned markers are silent, the status is terminal). refundedAt: - deposit.status === MoneriumFiatDepositStatus.Refunded ? iso(recovery?.updatedAt ?? deposit.updatedAt) : null, + deposit.status === MoneriumFiatDepositStatus.Refunded + ? iso(recovery?.phase === MoneriumRecoveryPhase.Redeemed ? recovery.updatedAt : deposit.updatedAt) + : null, startedAt: iso(deposit.refundStartedAt) } : null, From 5f47679be2e1bdd220fe5d7dbfaae4d8119ee7e3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:57:34 +0200 Subject: [PATCH 42/80] fix(api): keep refund top-ups from making an account a keeper candidate Under the sub-floor refund remedy every clone that had such a refund keeps its top-up as an unattr: Minted row. The candidate query did not exclude those rows, so the account ran the executor on every cycle for nothing and had due fee applies sent while idle. The executor already ignores unattr: rows; the mint watcher's touched accounts still give the top-up its one run. --- .../api/workers/monerium-b2b.worker.test.ts | 71 +++++++++++++++++++ .../src/api/workers/monerium-b2b.worker.ts | 10 +-- docs/architecture-monerium-b2b-onramp.md | 6 +- .../05-integrations/monerium-b2b.md | 2 +- 4 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/api/workers/monerium-b2b.worker.test.ts diff --git a/apps/api/src/api/workers/monerium-b2b.worker.test.ts b/apps/api/src/api/workers/monerium-b2b.worker.test.ts new file mode 100644 index 000000000..af0958790 --- /dev/null +++ b/apps/api/src/api/workers/monerium-b2b.worker.test.ts @@ -0,0 +1,71 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { config } from "../../config/vars"; +import ManagedProfileManager from "../../models/managedProfileManager.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; +import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; +import { createTestUser } from "../../test-utils/factories"; +import { provisionMoneriumB2bAccount } from "../services/monerium-b2b/account-provisioning"; +import MoneriumB2bWorker from "./monerium-b2b.worker"; + +describe("MoneriumB2bWorker conversion candidates", () => { + let originalRpcUrl: string | undefined; + + beforeAll(async () => { + originalRpcUrl = config.moneriumB2b.rpcUrl; + config.moneriumB2b.rpcUrl = undefined; // provisioning skips the on-chain clone check + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = originalRpcUrl; + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + async function accountWithMintedRow(moneriumOrderId: string) { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const { accountId } = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: "0x5555555555555555555555555555555555555555", + externalSubjectId: "client-1", + forwarderAddress: "0x1111111111111111111111111111111111111111", + managerProfileId: manager.id, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + await MoneriumFiatDeposit.create({ + accountId, + amountRaw: "700000000000000001", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId, + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + return accountId; + } + + // Called off the prototype: constructing the worker runs a whole keeper cycle on init. + const candidates = (minted: string[] = []) => MoneriumB2bWorker.prototype["conversionCandidates"](minted); + + it("runs the executor for an account with a chain-indexed deposit still settling", async () => { + const accountId = await accountWithMintedRow("order-1"); + expect(await candidates()).toEqual([accountId]); + }); + + it("does not keep an account a candidate for a refund top-up left on the clone (unattr: row)", async () => { + const accountId = await accountWithMintedRow("unattr:topup"); + expect(await candidates()).toEqual([]); + // The mint watcher's touched accounts still get the run in which the top-up lands. + expect(await candidates([accountId])).toEqual([accountId]); + }); +}); diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 01a3e7fa2..4a19d73e3 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -8,7 +8,7 @@ import { runConversionExecutor, SETTLING_STATUSES } from "../services/monerium-b import { processMoneriumWebhookInbox, pruneProcessedWebhookEvents } from "../services/monerium-b2b/deposit-processor"; import { runDormancyGate } from "../services/monerium-b2b/dormancy"; import { emitMoneriumDepositEvents } from "../services/monerium-b2b/manager-events"; -import { runMintWatcher } from "../services/monerium-b2b/mint-watcher"; +import { runMintWatcher, UNATTRIBUTED_ORDER_PREFIX } from "../services/monerium-b2b/mint-watcher"; import { runMonitoringPass } from "../services/monerium-b2b/monitoring"; import { advanceOnboardingAccounts } from "../services/monerium-b2b/onboarding"; import { runRecoveryDeadlines, runRecoveryOrchestrator } from "../services/monerium-b2b/recovery"; @@ -110,7 +110,8 @@ class MoneriumB2bWorker { * Accounts worth running the executor for: settled mints from this cycle and accounts * with chain-indexed deposits still settling (converting, awaiting their forward, or * marked for recovery). The executor never outruns the watcher's reorg-safety window - * merely because a live balance is visible. + * merely because a live balance is visible. `unattr:` rows (e.g. a refund top-up left on + * the clone) are never converted, so they alone keep no account a candidate. */ private async conversionCandidates(mintedAccountIds: string[]): Promise { const candidates = new Set(mintedAccountIds); @@ -119,9 +120,10 @@ class MoneriumB2bWorker { `SELECT DISTINCT account_id AS "accountId" FROM monerium_fiat_deposits WHERE status IN (:settling) - AND block_number IS NOT NULL`, + AND block_number IS NOT NULL + AND monerium_order_id NOT LIKE :unattributed`, { - replacements: { settling: SETTLING_STATUSES }, + replacements: { settling: SETTLING_STATUSES, unattributed: `${UNATTRIBUTED_ORDER_PREFIX}%` }, type: QueryTypes.SELECT } ); diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 2c1c3dc20..e0ccf6d89 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -204,8 +204,10 @@ Vortex learns about a deposit through two complementary channels, which converge same per-forwarder advisory lock: the **webhooks** carry the provider order accounting (amount, order id, compliance holds), while the **mint watcher** proves the on-chain mint identity. Only a settled, chain-indexed mint makes an account a conversion -candidate. A live balance by itself is deliberately insufficient: this prevents a swap -from outrunning the watcher's reorg window and becoming impossible to attribute safely. +candidate (an `unattr:` row, such as a refund top-up left on the clone, keeps none +after the cycle it lands in). A live balance by itself is deliberately insufficient: +this prevents a swap from outrunning the watcher's reorg window and becoming +impossible to attribute safely. A payer can name a chain and address in the SEPA memo; when that address is linked to the client's profile, Monerium mints there instead of to the IBAN's default address. Besides diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 754a3d488..eafafdce5 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -39,7 +39,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. Known limit: the keeper's nonce reads use the public read RPC (the value path's explicit nonce, the re-send's nothing-pending check), which cannot see a keeper transaction still unmined in the private relay (a `poke` or `applyFeePolicy` sent with an implicit nonce), so a later send can take the same nonce; only one transaction per nonce is mined, and a losing value-moving row fails closed through the exact-match recovery (invariant 3) and retries after the backoff, so the cost is delay, never a double send. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers; the gap is not re-poked, because the re-sent `swap` or `forward` arms an unarmed marker itself and, after a no-op, the next cycle pokes, so a dropped poke delays the recovery clock by about the five-minute idle deadline) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. The retry backoff (1 min, doubling per failed row since the account's last confirmed execution, at most 60 min) holds a swap or forward back on failures of any kind, but a planned `recover` only on failed recovers, so failing swaps never delay a refund past its on-chain eligibility. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. -5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; the trade-off is ordering: a younger deposit forwards before the older passed-over one is refunded, and each `forward` resyncs the batch marker (`_syncBatch(true)`); on a clone left with less than `MIN_SWAP_FLOOR` EURe and no USDC the marker switches off or never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; while the clone stays funded (a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, or a chunk-swapped remainder whose USDC is still on the clone) each younger `forward` re-times the marker, so the passed-over deposit is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; the trade-off is ordering: a younger deposit forwards before the older passed-over one is refunded, and each `forward` resyncs the batch marker (`_syncBatch(true)`); on a clone left with less than `MIN_SWAP_FLOOR` EURe and no USDC the marker switches off or never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; while the clone stays funded (a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, or a chunk-swapped remainder whose USDC is still on the clone) each younger `forward` re-times the marker, so the passed-over deposit is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed, provider-attributed deposits make an account a conversion candidate (an `unattr:` row only for the cycle its mint lands in; the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction; keeper writes go through the private transport on mainnet, which the public nonce counts never show), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply and may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. From b56c047cd153f7338ffce6e055912f1f52857f26 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:57:53 +0200 Subject: [PATCH 43/80] docs(api): name every process that can resend a remembered fee apply The last fee apply is remembered in one process's memory, but the keeper is built to run in several processes on one database, and each of them is as blind to it as a restarted one. State that limit where the restart case was documented. --- .../api/src/api/services/monerium-b2b/conversion-executor.ts | 5 +++-- docs/architecture-monerium-b2b-onramp.md | 3 ++- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) 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 d9a3b74cf..953489b1d 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1462,8 +1462,9 @@ async function sendPoke(forwarder: Address): Promise { const FEE_APPLY_RESEND_AFTER_MS = 15 * 60_000; /** The last applyFeePolicy sent per forwarder (lower-cased address). */ -// ponytail: in-process memory; a restart forgets an unmined apply and may send it once more (the -// duplicate reverts NoPendingFeePolicy or is dropped). Persist it if that ever costs more than gas. +// ponytail: in-process memory; a restart, or another keeper process on the same database, does not +// know an unmined apply and may send it once more (the duplicate reverts NoPendingFeePolicy or is +// dropped). Persist it if that ever costs more than gas. const sentFeeApplies = new Map(); /** The skip reason last logged per forwarder, so a skip is logged once, not every cycle. */ const loggedFeeApplySkips = new Map(); diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index e0ccf6d89..6dfd787c2 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -484,7 +484,8 @@ contract settles every fill into three bands against that reference (decisions: or a timeout is logged and the cycle goes on. A timed-out apply is not re-sent while it has no receipt and is younger than 15 minutes (remembered per forwarder in process memory, since a private relay's pending transactions are invisible to the public nonce - counts), and no apply is sent while the public pool holds any keeper transaction, of + counts; a restart or another keeper process does not know it and may send one + duplicate, which reverts `NoPendingFeePolicy` or is dropped), and no apply is sent while the public pool holds any keeper transaction, of any account; each skip reason is logged once. The executor only runs for accounts with a settling deposit, so an idle clone's increase is applied before its next swap. - **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index eafafdce5..f358f75f2 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -42,7 +42,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; the trade-off is ordering: a younger deposit forwards before the older passed-over one is refunded, and each `forward` resyncs the batch marker (`_syncBatch(true)`); on a clone left with less than `MIN_SWAP_FLOOR` EURe and no USDC the marker switches off or never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; while the clone stays funded (a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, or a chunk-swapped remainder whose USDC is still on the clone) each younger `forward` re-times the marker, so the passed-over deposit is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed, provider-attributed deposits make an account a conversion candidate (an `unattr:` row only for the cycle its mint lands in; the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction; keeper writes go through the private transport on mainnet, which the public nonce counts never show), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply and may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction; keeper writes go through the private transport on mainnet, which the public nonce counts never show), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply, and another keeper process on the same database never knew it, so either may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. ## Monitoring From 2009a90b761d408816c23685f559cb5770d97f60 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:58:38 +0200 Subject: [PATCH 44/80] fix(api): date every refund by the deposit's refunded transition A parked refund closed by hand in auto mode was dated by the PATCH until the next orchestrator cycle closed its recovery row, and by that close afterwards, so the webhook and the deposits endpoint could report different refundedAt values. Every later write to a refunded row is silent, and on the automatic path the deposit turns refunded just before the row is redeemed, so the deposit's transition is one stable time for both paths. --- .../services/monerium-b2b/manager-events.test.ts | 10 ++++++++-- .../src/api/services/monerium-b2b/manager-events.ts | 13 +++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts index cdacef1fc..108349b09 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts @@ -17,7 +17,7 @@ import { provisionMoneriumB2bAccount } from "./account-provisioning"; import { NOTIFY_CONFIRMATION_DEPTH } from "./chain"; import { markDepositForRecovery } from "./conversion-executor"; import { emitMoneriumDepositEvents, maskIban } from "./manager-events"; -import { setDepositStatus } from "./recovery"; +import { runRecoveryOrchestrator, setDepositStatus } from "./recovery"; const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; @@ -371,7 +371,7 @@ describe("monerium b2b manager events", () => { }); }); - it("dates a failed refund closed by hand at its refunded transition, not at the failure", async () => { + it("dates a failed refund closed by hand at its refunded transition, not at the failure or the row's close", async () => { const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RETURNED, WebhookEventType.DEPOSIT_UPDATED]); const deposit = await MoneriumFiatDeposit.create({ accountId: mapped.accountId, @@ -401,6 +401,12 @@ describe("monerium b2b manager events", () => { expect(await setDepositStatus(deposit, MoneriumFiatDepositStatus.Refunded)).toBeNull(); await deposit.reload(); expect(deposit.updatedAt.getTime()).toBeGreaterThan(recovery.updatedAt.getTime()); + await new Promise(resolve => setTimeout(resolve, 5)); + // The next keeper cycle closes the parked row before the events run. + await runRecoveryOrchestrator(() => Promise.reject(new Error("a refunded head never needs deps"))); + await recovery.reload(); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeemed); + expect(recovery.updatedAt.getTime()).toBeGreaterThan(deposit.updatedAt.getTime()); await emitMoneriumDepositEvents(depsAtBlock(null)); const updates = (await WebhookDelivery.findAll()) diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.ts b/apps/api/src/api/services/monerium-b2b/manager-events.ts index a05378048..f9775c93f 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -21,7 +21,7 @@ import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; -import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; +import MoneriumRecovery from "../../../models/moneriumRecovery.model"; import webhookService from "../webhook/webhook.service"; import { enqueueWebhookDeliveries } from "../webhook/webhook-outbox.service"; import { getPublicClient, NOTIFY_CONFIRMATION_DEPTH } from "./chain"; @@ -177,13 +177,10 @@ export async function depositSnapshots( reason: deposit.refundReason as DepositRefundReason | null, recoverTxHash: recover?.txHash ?? null, redeemOrderId: recovery?.redeemOrderId ?? null, - // The recovery row dates the refund only once it is redeemed; a refund closed by hand - // (no row, or one parked at its failure) is dated by the deposit's refunded transition, - // its last write (the received/returned markers are silent, the status is terminal). - refundedAt: - deposit.status === MoneriumFiatDepositStatus.Refunded - ? iso(recovery?.phase === MoneriumRecoveryPhase.Redeemed ? recovery.updatedAt : deposit.updatedAt) - : null, + // Dated by the deposit's refunded transition, its last write (the received/returned markers + // are silent, the status is terminal): the orchestrator closes the recovery row after it, or + // a cycle later for a refund closed by hand, so that row's time would drift between snapshots. + refundedAt: deposit.status === MoneriumFiatDepositStatus.Refunded ? iso(deposit.updatedAt) : null, startedAt: iso(deposit.refundStartedAt) } : null, From 46dff7f88b0029aaf6782064cfaf48194b8e467f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:59:34 +0200 Subject: [PATCH 45/80] fix(api): alert on a recover the keeper leaves pending The sub-floor refund check skips an account while a recover is pending, and REFUND OVERDUE treats a pending recover as progress. A recover row the keeper cannot resolve (failing receipt lookups, an unresolvable hashless row) therefore silenced every refund alert of the account with no time limit, and no other monitor sees such a row before a recovery row exists. Warn once it is pending past the refund monitor's linger margin and error after four times that. --- .../services/monerium-b2b/monitoring.test.ts | 36 +++++++++++++++++-- .../api/services/monerium-b2b/monitoring.ts | 23 ++++++++++++ docs/architecture-monerium-b2b-onramp.md | 4 ++- .../05-integrations/monerium-b2b.md | 3 +- 4 files changed, 62 insertions(+), 4 deletions(-) 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 5e6ba4233..5a167063d 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -435,9 +435,15 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { }); } - function execution(deposit: MoneriumFiatDeposit, kind: MoneriumConversionExecutionKind, status: MoneriumConversionExecutionStatus) { + function execution( + deposit: MoneriumFiatDeposit, + kind: MoneriumConversionExecutionKind, + status: MoneriumConversionExecutionStatus, + createdAt = new Date() + ) { return MoneriumConversionExecution.create({ accountId: deposit.accountId, + createdAt, depositId: deposit.id, destination: "0x5555555555555555555555555555555555555555", eureInRaw: deposit.amountRaw, @@ -539,6 +545,32 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { }); } + // A recover the keeper keeps skipping (failing receipt lookups, an unresolvable hashless row) + // would otherwise keep the account silent, since the checks take a pending recover for progress. + for (const [hours, level] of [ + [2, "warn"], + [5, "error"] + ] as const) { + it(`${level}s on a recover still pending ${hours}h after it was sent`, async () => { + const recovering = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, (EUR * 3n) / 10n); + const stuck = await addDeposit(recovering.accountId, MoneriumFiatDepositStatus.Recovering, EUR / 2n); + const recover = await execution( + recovering, + MoneriumConversionExecutionKind.Recover, + MoneriumConversionExecutionStatus.Pending, + new Date(Date.now() - hours * 3_600_000) + ); + await runStrandedBalanceMonitor(); + const alerts = [...errors, ...warnings]; + expect(alerts).toHaveLength(1); + expect((level === "error" ? errors : warnings)[0]).toContain("RECOVER STUCK PENDING"); + expect(alerts[0]).toContain(`${recover.id} (deposit ${recovering.id})`); + expect(alerts[0]).toContain(`pending for up to ${hours}h`); + // The sibling's sub-floor alert still waits for the recover, as for a young one. + expect(alerts[0]).not.toContain(stuck.id); + }); + } + it("waits for a sibling's mined recover to confirm before judging the clone's EURe", async () => { // D2's recover is mined (the clone holds only D1's 0.5 EURe) but its row is still Pending. const stuck = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); @@ -643,7 +675,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { for (const status of [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) { it(`stays quiet while its recover is ${status}`, async () => { const deposit = await markedHoursAgo(4); - await execution(deposit, MoneriumConversionExecutionKind.Recover, status); + await execution(deposit, MoneriumConversionExecutionKind.Recover, status, new Date(now - 10 * 60_000)); await runStrandedBalanceMonitor(now); expect(warnings).toEqual([]); }); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 507cf70ec..0b42027dd 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -402,6 +402,29 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }) ]); + // The checks below take a pending `recover` for progress; one pending past the refund + // monitor's linger margin is stuck (the keeper's info log says why), so it alerts here. + const stuckRecovers = await MoneriumConversionExecution.findAll({ + attributes: ["createdAt", "depositId", "id"], + order: [["created_at", "ASC"]], + where: { + accountId: account.id, + createdAt: { [Op.lt]: new Date(now - RECOVERY_LINGER_MS) }, + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Pending + } + }); + if (stuckRecovers.length > 0) { + const ageMs = now - stuckRecovers[0].createdAt.getTime(); + const message = + `monerium-b2b: RECOVER STUCK PENDING — recover execution(s) ` + + `${stuckRecovers.map(e => `${e.id} (deposit ${e.depositId})`).join(", ")} on forwarder ${forwarder} ` + + `(account ${account.id}) pending for up to ${Math.floor(ageMs / 3_600_000)}h: the keeper cannot resolve it ` + + "(see its 'remains pending' or 'lookup failed' info log), and this account's refund alerts wait for it; check " + + "the transaction and the keeper's RPC (the internal B2B runbook §2.7)"; + if (classifyRefundQueue(stuckRecovers[0].createdAt, false, now) === "error") logger.error(message); + else logger.warn(message); + } if (eureBalance < minSwapFloor && usdcBalance === 0n) { // Below MIN_SWAP_FLOOR the contract arms no batch, so `recover` can never run for a // payment marked for the refund path (one already recovered is the refund monitor's). diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 6dfd787c2..99dbaffef 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -574,7 +574,9 @@ read-only — no keys, no transactions: may have left the clone by an unrecorded path, so it warns "reconcile before refunding; do not refund and do not top up the clone". The pass skips that account while any of its `recover` executions is still pending: a mined `recover` not yet - confirmed would make the ledger overstate the clone. On a clone at or above the floor + confirmed would make the ledger overstate the clone. A `recover` still pending an + hour after it was created warns RECOVER STUCK PENDING (an error after four hours), on + any clone, so one the keeper cannot resolve never keeps the account silent. On a clone at or above the floor (or holding USDC), a `recovering` deposit with no pending or confirmed `recover` more than `RECOVERY_DELAY` plus an hour after it was marked warns REFUND OVERDUE (an error past `TRIGGER_DELAY`): younger forwards keep re-timing its marker, or another refund of the diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index f358f75f2..419a9316b 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`: in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead of forbidding a hand refund. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`: in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead of forbidding a hand refund. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. @@ -127,6 +127,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] The admin registration list filters by status and withdraws only a `requested` registration (409 otherwise, 404 when unknown), and `onboarding → suspended` is accepted and logged (`moneriumB2b.controller.test.ts`) - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` - [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`) and never a hand refund, fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, and only warns "reconcile" when funds left by an unrecorded path (`monitoring.test.ts`: "asks the operator for the exact top-up…", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…") +- [ ] RECOVER STUCK PENDING warns for a `recover` execution still pending one hour after its creation and errors after four, so a pending `recover` the keeper cannot resolve never keeps the account's refund alerts silent (`monitoring.test.ts`: "warns on a recover still pending 2h after it was sent", "errors on a recover still pending 5h after it was sent") - [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") - [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; guardian fee-policy changes (R07/P11) are warn-level reconciliations, a destination change is an incident - [ ] Admin deposit endpoints (`POST .../deposits/:id/recover`, `PATCH .../deposits/:id/status`) only ever apply forward-only transitions under the forwarder lock; `POST .../recover` refuses a deposit with a pending execution, and `PATCH .../status` closes a parked refund from `recovery_failed` as `refunded` (`moneriumB2b.controller.test.ts`) From 6af3e22850641c527177137bf05f2f9498d2cc25 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 11:59:45 +0200 Subject: [PATCH 46/80] docs(api): match the top-up checklist item to the alert The audit checklist item predates the whole-cent guard and the alert/off wording, so it contradicted Monitoring invariant 3 and left out the tests that pin both. --- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 419a9316b..9432f7c1c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -126,7 +126,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] Registered accounts auto-activate only when `SANDBOX_ENABLED=true`, only with an IBAN and only from a mapped registration; activation sets `activated_at`, the dormancy anchor is the later of the last conversion and the activation, and a deposit on an account that cannot convert carries `account_not_active` until it can (`registration.test.ts`, `dormancy.test.ts`, `conversion-executor.test.ts`) - [ ] The admin registration list filters by status and withdraws only a `requested` registration (409 otherwise, 404 when unknown), and `onboarding → suspended` is accepted and logged (`moneriumB2b.controller.test.ts`) - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` -- [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`) and never a hand refund, fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, and only warns "reconcile" when funds left by an unrecorded path (`monitoring.test.ts`: "asks the operator for the exact top-up…", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…") +- [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, and only warns "reconcile" when funds left by an unrecorded path (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…") - [ ] RECOVER STUCK PENDING warns for a `recover` execution still pending one hour after its creation and errors after four, so a pending `recover` the keeper cannot resolve never keeps the account's refund alerts silent (`monitoring.test.ts`: "warns on a recover still pending 2h after it was sent", "errors on a recover still pending 5h after it was sent") - [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") - [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; guardian fee-policy changes (R07/P11) are warn-level reconciliations, a destination change is an incident From bfd61a3dddd92fe5d6acf8ea96c336c1fde01b94 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 12:00:34 +0200 Subject: [PATCH 47/80] style(api): use a plain string in the stuck recover alert Biome's lint fix prefers a plain literal where nothing is interpolated. --- apps/api/src/api/services/monerium-b2b/monitoring.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 0b42027dd..a30061b93 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -417,7 +417,7 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi if (stuckRecovers.length > 0) { const ageMs = now - stuckRecovers[0].createdAt.getTime(); const message = - `monerium-b2b: RECOVER STUCK PENDING — recover execution(s) ` + + "monerium-b2b: RECOVER STUCK PENDING — recover execution(s) " + `${stuckRecovers.map(e => `${e.id} (deposit ${e.depositId})`).join(", ")} on forwarder ${forwarder} ` + `(account ${account.id}) pending for up to ${Math.floor(ageMs / 3_600_000)}h: the keeper cannot resolve it ` + "(see its 'remains pending' or 'lookup failed' info log), and this account's refund alerts wait for it; check " + From 9fce85a342da3cef4083e25308f74f3997883ad3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 12:23:11 +0200 Subject: [PATCH 48/80] fix(api): keep a late payer fill from re-dating a refunded deposit --- .../monerium-b2b/deposit-processor.test.ts | 29 +++++++++++++++++++ .../monerium-b2b/deposit-processor.ts | 8 +++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts index 379a2c74b..ce5b25893 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts @@ -495,6 +495,35 @@ describe("order-event inbox processing (end to end)", () => { expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); }); + it("leaves a refunded deposit untouched when a later delivery carries the payer", async () => { + const account = await createAccount(); + const deposit = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw: (1005n * 10n ** 17n).toString(), + currency: "eur", + moneriumOrderId: ORDER_ID, + status: Refunded, + txHash: "0xmint" + }); + const refundedAt = deposit.updatedAt; + await MoneriumWebhookEvent.create({ + eventId: "evt-late-payer", + payload: orderEvent("processed", { + counterpart: { + details: { name: "Payer GmbH" }, + identifier: { iban: "DE89370400440532013000", standard: "iban" } + } + }) + }); + + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1); + await deposit.reload(); + // updated_at dates refundedAt, so a late fill would re-date the refund. + expect(deposit.updatedAt).toEqual(refundedAt); + expect(deposit.payerIban).toBeNull(); + expect(deposit.payerName).toBeNull(); + }); + it("terminally discards malformed amounts without delaying later orders", async () => { await createAccount(); await MoneriumWebhookEvent.create({ diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts index cf3d02b08..80d124dac 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -384,9 +384,11 @@ async function processInboxRow(row: MoneriumWebhookEvent, deps: DepositProcessor status?: MoneriumFiatDepositStatus; txHash?: string; } = {}; - // The refund target: filled once, never overwritten by a later delivery. - if (event.payerIban && !existing.payerIban) updates.payerIban = event.payerIban; - if (event.payerName && !existing.payerName) updates.payerName = event.payerName; + // The refund target: filled once, never overwritten by a later delivery, and never + // after the refund (the write would move updated_at, which dates refundedAt). + const fillPayer = existing.status !== MoneriumFiatDepositStatus.Refunded; + if (fillPayer && event.payerIban && !existing.payerIban) updates.payerIban = event.payerIban; + if (fillPayer && event.payerName && !existing.payerName) updates.payerName = event.payerName; const alreadyPastMint = targetStatus === MoneriumFiatDepositStatus.Minted && PAST_MINT_STATUSES.includes(existing.status); if (targetStatus && targetStatus !== existing.status && !alreadyPastMint) { if (isForwardTransition(existing.status, targetStatus)) { From 6d26f9966333c7778ce1b7c5aaff217ff5d9aeb8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 12:23:20 +0200 Subject: [PATCH 49/80] docs(api): drop the stale refundedAt fallback wording --- apps/api/src/api/services/monerium-b2b/manager-events.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.ts b/apps/api/src/api/services/monerium-b2b/manager-events.ts index f9775c93f..c0c07a17c 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -243,7 +243,7 @@ async function emitReceivedEvents(): Promise { // Marked emitted even with zero subscribers: webhooks are forward-looking, a // later registration must not receive the whole history. A crash between the // enqueue and this marker is absorbed by the outbox (webhook_id, event_id) dedup. - // Silent: updated_at is a refunded deposit's refundedAt fallback. + // Silent: updated_at dates a refunded deposit's refundedAt. await deposit.update({ receivedEventAt: new Date() }, { silent: true }); } catch (error) { // Per-deposit isolation: one failing deposit must not block its siblings. @@ -355,7 +355,7 @@ async function emitReturnedEvents(): Promise { timestamp: new Date().toISOString() }; await enqueueForManager(WebhookEventType.DEPOSIT_RETURNED, managerProfileId, payload); - // Silent: updated_at stays the refunded transition, the refundedAt fallback below. + // Silent: updated_at stays the refunded transition, which dates refundedAt. await deposit.update({ returnedEventAt: new Date() }, { silent: true }); } catch (error) { logger.error(`monerium-b2b: DEPOSIT_RETURNED emission failed for deposit ${deposit.id}:`, error); From ed8473788fad12507a425cf4a68dc586e4f187a2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 12:24:34 +0200 Subject: [PATCH 50/80] fix(api): keep accounts with a pending execution keeper candidates --- .../api/workers/monerium-b2b.worker.test.ts | 23 +++++++++++++++++++ .../src/api/workers/monerium-b2b.worker.ts | 10 +++++--- docs/architecture-monerium-b2b-onramp.md | 3 ++- .../05-integrations/monerium-b2b.md | 2 +- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/workers/monerium-b2b.worker.test.ts b/apps/api/src/api/workers/monerium-b2b.worker.test.ts index af0958790..2d6f91a0a 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.test.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.test.ts @@ -1,6 +1,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { config } from "../../config/vars"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; import { createTestUser } from "../../test-utils/factories"; @@ -68,4 +72,23 @@ describe("MoneriumB2bWorker conversion candidates", () => { // The mint watcher's touched accounts still get the run in which the top-up lands. expect(await candidates([accountId])).toEqual([accountId]); }); + + it("keeps an account a candidate while it has a pending execution, even after its deposit left settling", async () => { + const accountId = await accountWithMintedRow("order-1"); + const deposit = (await MoneriumFiatDeposit.findOne({ where: { accountId } })) as MoneriumFiatDeposit; + await deposit.update({ status: MoneriumFiatDepositStatus.Refunded }); // closed by hand + const recover = await MoneriumConversionExecution.create({ + accountId, + depositId: deposit.id, + destination: "0x5555555555555555555555555555555555555555", + eureInRaw: deposit.amountRaw, + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Pending, + txHash: "0xrecover" + }); + expect(await candidates()).toEqual([accountId]); + + await recover.update({ status: MoneriumConversionExecutionStatus.Confirmed }); + expect(await candidates()).toEqual([]); + }); }); diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 4a19d73e3..2cb8255f6 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -111,17 +111,21 @@ class MoneriumB2bWorker { * with chain-indexed deposits still settling (converting, awaiting their forward, or * marked for recovery). The executor never outruns the watcher's reorg-safety window * merely because a live balance is visible. `unattr:` rows (e.g. a refund top-up left on - * the clone) are never converted, so they alone keep no account a candidate. + * the clone) are never converted, so they alone keep no account a candidate. Accounts + * with a pending execution are always candidates: only the executor resolves that row, + * even after an operator moved its deposit out of the settling statuses. */ private async conversionCandidates(mintedAccountIds: string[]): Promise { const candidates = new Set(mintedAccountIds); const outstanding = await sequelize.query<{ accountId: string }>( - `SELECT DISTINCT account_id AS "accountId" + `SELECT account_id AS "accountId" FROM monerium_fiat_deposits WHERE status IN (:settling) AND block_number IS NOT NULL - AND monerium_order_id NOT LIKE :unattributed`, + AND monerium_order_id NOT LIKE :unattributed + UNION + SELECT account_id FROM monerium_conversion_executions WHERE status = 'pending'`, { replacements: { settling: SETTLING_STATUSES, unattributed: `${UNATTRIBUTED_ORDER_PREFIX}%` }, type: QueryTypes.SELECT diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 99dbaffef..244fc2b61 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -207,7 +207,8 @@ mint identity. Only a settled, chain-indexed mint makes an account a conversion candidate (an `unattr:` row, such as a refund top-up left on the clone, keeps none after the cycle it lands in). A live balance by itself is deliberately insufficient: this prevents a swap from outrunning the watcher's reorg window and becoming -impossible to attribute safely. +impossible to attribute safely. An account with a pending execution stays a candidate +until the executor resolves that row, even after an operator closed its deposit. A payer can name a chain and address in the SEPA memo; when that address is linked to the client's profile, Monerium mints there instead of to the IBAN's default address. Besides diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 9432f7c1c..96f824c80 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -39,7 +39,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. Known limit: the keeper's nonce reads use the public read RPC (the value path's explicit nonce, the re-send's nothing-pending check), which cannot see a keeper transaction still unmined in the private relay (a `poke` or `applyFeePolicy` sent with an implicit nonce), so a later send can take the same nonce; only one transaction per nonce is mined, and a losing value-moving row fails closed through the exact-match recovery (invariant 3) and retries after the backoff, so the cost is delay, never a double send. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers; the gap is not re-poked, because the re-sent `swap` or `forward` arms an unarmed marker itself and, after a no-op, the next cycle pokes, so a dropped poke delays the recovery clock by about the five-minute idle deadline) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. The retry backoff (1 min, doubling per failed row since the account's last confirmed execution, at most 60 min) holds a swap or forward back on failures of any kind, but a planned `recover` only on failed recovers, so failing swaps never delay a refund past its on-chain eligibility. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. -5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; the trade-off is ordering: a younger deposit forwards before the older passed-over one is refunded, and each `forward` resyncs the batch marker (`_syncBatch(true)`); on a clone left with less than `MIN_SWAP_FLOOR` EURe and no USDC the marker switches off or never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; while the clone stays funded (a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, or a chunk-swapped remainder whose USDC is still on the clone) each younger `forward` re-times the marker, so the passed-over deposit is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed, provider-attributed deposits make an account a conversion candidate (an `unattr:` row only for the cycle its mint lands in; the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; the trade-off is ordering: a younger deposit forwards before the older passed-over one is refunded, and each `forward` resyncs the batch marker (`_syncBatch(true)`); on a clone left with less than `MIN_SWAP_FLOOR` EURe and no USDC the marker switches off or never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; while the clone stays funded (a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, or a chunk-swapped remainder whose USDC is still on the clone) each younger `forward` re-times the marker, so the passed-over deposit is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed, provider-attributed deposits make an account a conversion candidate (an `unattr:` row only for the cycle its mint lands in; an account with a pending execution stays one until the executor resolves it; the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction; keeper writes go through the private transport on mainnet, which the public nonce counts never show), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply, and another keeper process on the same database never knew it, so either may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. From 1c6346d2456d5b489ebcbfd4d56203003cf22b49 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 12:24:45 +0200 Subject: [PATCH 51/80] docs(api): scope the no-manual-refund rule to auto recovery --- docs/architecture-monerium-b2b-onramp.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 244fc2b61..818ab2de5 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -379,8 +379,8 @@ permissionless `poke` and `applyFeePolicy`): `poke` arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline below refund the payer exactly, from the payer's own EURe, with the full record (`recover` transaction, redeem order, - `refundedAt`); the deposit is never refunded by hand. That refund pipeline runs only - with `MONERIUM_B2B_AUTO_RECOVERY=auto`; in `alert` or `off` the keeper's `recover` + `refundedAt`); with `MONERIUM_B2B_AUTO_RECOVERY=auto` the deposit is never refunded + by hand. That refund pipeline runs only in `auto`; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the alert points to the manual redeem from that wallet instead. The top-up stays on the clone below the floor, so `recover` switches the marker off by itself: no follow-up step. The From 787df9bcf14d9b36aef007ea98af3414a0739296 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 12:24:51 +0200 Subject: [PATCH 52/80] docs(api): state the single poke an orphan inflow gets --- docs/architecture-monerium-b2b-onramp.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 818ab2de5..01bcc518b 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -209,6 +209,12 @@ after the cycle it lands in). A live balance by itself is deliberately insuffici this prevents a swap from outrunning the watcher's reorg window and becoming impossible to attribute safely. An account with a pending execution stays a candidate until the executor resolves that row, even after an operator closed its deposit. +An orphan inflow (an `unattr:` row whose order never arrives) gets a single +best-effort poke in the cycle its mint lands in; if that poke fails, the batch marker +stays unarmed, the stranded-balance check never escalates, and the one-time +"unattributed EURe mint" warning is the only signal. Arming the marker on unattributed +funds would also start the permissionless swap and forward clocks, so the keeper does +not retry it. A payer can name a chain and address in the SEPA memo; when that address is linked to the client's profile, Monerium mints there instead of to the IBAN's default address. Besides From 222aa7030283e76193c9a92255b8f8e21b0fd057 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 12:25:03 +0200 Subject: [PATCH 53/80] docs(api): say why an orphan inflow's poke is not retried --- docs/architecture-monerium-b2b-onramp.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 01bcc518b..b2cc7083b 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -212,9 +212,8 @@ until the executor resolves that row, even after an operator closed its deposit. An orphan inflow (an `unattr:` row whose order never arrives) gets a single best-effort poke in the cycle its mint lands in; if that poke fails, the batch marker stays unarmed, the stranded-balance check never escalates, and the one-time -"unattributed EURe mint" warning is the only signal. Arming the marker on unattributed -funds would also start the permissionless swap and forward clocks, so the keeper does -not retry it. +"unattributed EURe mint" warning is the only signal; later cycles do not retry the +poke, because the account is no longer a candidate. A payer can name a chain and address in the SEPA memo; when that address is linked to the client's profile, Monerium mints there instead of to the IBAN's default address. Besides From 5edb60ba8263c60dafee9de468e14ae431fd6545 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 12:26:03 +0200 Subject: [PATCH 54/80] fix(api): raise the stuck recover alert before the clone reads --- .../services/monerium-b2b/monitoring.test.ts | 24 ++++++++++++++++--- .../api/services/monerium-b2b/monitoring.ts | 17 ++++++------- 2 files changed, 30 insertions(+), 11 deletions(-) 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 5a167063d..1cafd32af 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -349,6 +349,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { let warnings: string[]; let eureOnClone: bigint; let batchOpenedAt: bigint; + let cloneReadsFail: boolean; beforeAll(async () => { config.moneriumB2b.rpcUrl = undefined; // provisioning skips the on-chain clone check @@ -369,6 +370,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { warnings = []; eureOnClone = EUR / 2n; batchOpenedAt = 0n; + cloneReadsFail = false; const reads: Record = { MIN_SWAP_FLOOR: 1n * EUR, TRIGGER_DELAY: 86_400n }; spyOn(chain, "getForwarderImmutables").mockResolvedValue({ eure: EURE, @@ -377,14 +379,16 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { usdc: "0x6666666666666666666666666666666666666666" } as unknown as chain.ForwarderImmutables); spyOn(chain, "getPublicClient").mockReturnValue({ - readContract: async ({ address, functionName }: { address: Address; functionName: string }) => - functionName === "balanceOf" + readContract: async ({ address, functionName }: { address: Address; functionName: string }) => { + if (cloneReadsFail && functionName === "balanceOf") throw new Error("rpc down"); + return functionName === "balanceOf" ? address === EURE ? eureOnClone : 0n : functionName === "batchOpenedAt" ? batchOpenedAt - : reads[functionName] + : reads[functionName]; + } } as unknown as ReturnType); spyOn(logger, "error").mockImplementation(((message: string) => { errors.push(message); @@ -571,6 +575,20 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { }); } + it("still errors on a stuck recover while the clone's balance reads fail", async () => { + const recovering = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + await execution( + recovering, + MoneriumConversionExecutionKind.Recover, + MoneriumConversionExecutionStatus.Pending, + new Date(Date.now() - 5 * 3_600_000) + ); + cloneReadsFail = true; // the RPC failure that keeps the keeper from resolving the row + await runStrandedBalanceMonitor(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("RECOVER STUCK PENDING"); + }); + it("waits for a sibling's mined recover to confirm before judging the clone's EURe", async () => { // D2's recover is mined (the clone holds only D1's 0.5 EURe) but its row is still Pending. const stuck = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index a30061b93..4a9e41b18 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -396,14 +396,9 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi for (const account of accounts) { try { const forwarder = account.forwarderAddress as Address; - const { eure, usdc } = await getForwarderImmutables(forwarder); - const [eureBalance, usdcBalance, batchOpenedAt] = await Promise.all([ - client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), - client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), - client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }) - ]); // The checks below take a pending `recover` for progress; one pending past the refund - // monitor's linger margin is stuck (the keeper's info log says why), so it alerts here. + // monitor's linger margin is stuck (the keeper's info log says why), so it alerts here, + // before the chain reads a failing RPC would make throw. const stuckRecovers = await MoneriumConversionExecution.findAll({ attributes: ["createdAt", "depositId", "id"], order: [["created_at", "ASC"]], @@ -420,11 +415,17 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi "monerium-b2b: RECOVER STUCK PENDING — recover execution(s) " + `${stuckRecovers.map(e => `${e.id} (deposit ${e.depositId})`).join(", ")} on forwarder ${forwarder} ` + `(account ${account.id}) pending for up to ${Math.floor(ageMs / 3_600_000)}h: the keeper cannot resolve it ` + - "(see its 'remains pending' or 'lookup failed' info log), and this account's refund alerts wait for it; check " + + "(see its 'remains pending' or 'lookup failed' info log), and refund alerts for it wait; check " + "the transaction and the keeper's RPC (the internal B2B runbook §2.7)"; if (classifyRefundQueue(stuckRecovers[0].createdAt, false, now) === "error") logger.error(message); else logger.warn(message); } + const { eure, usdc } = await getForwarderImmutables(forwarder); + const [eureBalance, usdcBalance, batchOpenedAt] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }) + ]); if (eureBalance < minSwapFloor && usdcBalance === 0n) { // Below MIN_SWAP_FLOOR the contract arms no batch, so `recover` can never run for a // payment marked for the refund path (one already recovered is the refund monitor's). From aa8f29e5199476076f606cf9d5094839473974ac Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Sat, 10 Oct 2026 12:26:23 +0200 Subject: [PATCH 55/80] docs(api): describe refundedAt as the recorded refund, by hand too --- docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/api/openapi/vortex.openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 53f2d41ef..56325fa41 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -3034,7 +3034,7 @@ export interface components { redeemOrderId: string | null; /** * Format: date-time - * @description When the provider processed the refund; null until then. + * @description When Vortex recorded the refund as paid out: the provider processed the redeem order, or operations closed a refund made by hand. Null until then. */ refundedAt: string | null; /** diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 921d57fa4..9aa602ec6 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2746,7 +2746,7 @@ "type": ["string", "null"] }, "refundedAt": { - "description": "When the provider processed the refund; null until then.", + "description": "When Vortex recorded the refund as paid out: the provider processed the redeem order, or operations closed a refund made by hand. Null until then.", "format": "date-time", "type": ["string", "null"] }, From 462642e0d98b7a81a601d64a2960a1fd629ffd0a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 12 Oct 2026 17:40:35 +0200 Subject: [PATCH 56/80] refactor(api): clear below_minimum through the loaded deposits Updating each loaded deposit keeps the instance in step with its row by itself, so the bulk update and the separate set loop that mirrored it into memory collapse into one write per deposit, in the same transaction. --- .../services/monerium-b2b/conversion-executor.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) 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 953489b1d..9bdaf6ef6 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1164,16 +1164,10 @@ export async function runConversionExecutor(accountId: string): Promise { ); } // A lowered minimum ends that wait even for a deposit queued behind the one planned. - const nowAboveMinimum = withState - .filter(({ deposit, state }) => deposit.waitingReason === BELOW_MINIMUM && state.remainingEureRaw >= minSwapAmount) - .map(({ deposit }) => deposit); - if (nowAboveMinimum.length > 0) { - await MoneriumFiatDeposit.update( - { waitingReason: null, waitingSince: null }, - { transaction, where: { id: { [Op.in]: nowAboveMinimum.map(deposit => deposit.id) } } } - ); - // In step with the row, so a deferral below starts a fresh wait instead of writing back the old start. - for (const deposit of nowAboveMinimum) deposit.set({ waitingReason: null, waitingSince: null }); + for (const { deposit, state } of withState) { + if (deposit.waitingReason === BELOW_MINIMUM && state.remainingEureRaw >= minSwapAmount) { + await deposit.update({ waitingReason: null, waitingSince: null }, { transaction }); + } } return planAction(withState, { batchOpenedAtSec: batchOpenedAt, From c1125f7f9d04ddc1463601a29f437d7567335355 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 12 Oct 2026 17:41:30 +0200 Subject: [PATCH 57/80] refactor(api): drop the monitor's unreachable pending-recover filter The pass already skips an account while any of its recovers is pending, so the marked-deposit filter's pending check can never match. What remains is the confirmed-recover filter the ledger sum uses, so both now derive from one list, and recoverOf takes the one status each caller asks for. --- .../api/services/monerium-b2b/monitoring.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 4a9e41b18..9e6b6b6cd 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -465,27 +465,25 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi }); const swapsOf = (deposit: MoneriumFiatDeposit) => executions.filter(e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Swap); - const recoverOf = (deposit: MoneriumFiatDeposit, statuses: MoneriumConversionExecutionStatus[]) => + const recoverOf = (deposit: MoneriumFiatDeposit, status: MoneriumConversionExecutionStatus) => executions.some( - e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Recover && statuses.includes(e.status) + e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Recover && e.status === status ); // A mined `recover` whose row is still Pending already took its EURe, which the ledger // below would still count: wait for the keeper to confirm it before judging the rest. - if (deposits.some(deposit => recoverOf(deposit, [MoneriumConversionExecutionStatus.Pending]))) continue; - const stuck = deposits.filter( - deposit => - deposit.status === MoneriumFiatDepositStatus.Recovering && - !recoverOf(deposit, [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed]) - ); + if (deposits.some(deposit => recoverOf(deposit, MoneriumConversionExecutionStatus.Pending))) continue; + const unrecovered = deposits.filter(deposit => !recoverOf(deposit, MoneriumConversionExecutionStatus.Confirmed)); + const stuck = unrecovered.filter(deposit => deposit.status === MoneriumFiatDepositStatus.Recovering); // Only an unswapped payment gets the top-up remedy, and only while the clone still holds // all the EURe the ledger places there (every payment's unswapped, unrecovered rest); // anything less means EURe left by a path the ledger does not record (the permissionless // swap/forwardAll), so refunding could pay twice. A top-up that landed lifts the clone to // the floor, so this branch is skipped until the keeper's `recover` took the payer's EURe. const unswapped = stuck.filter(deposit => swapsOf(deposit).length === 0); - const ledgerEure = deposits - .filter(deposit => !recoverOf(deposit, [MoneriumConversionExecutionStatus.Confirmed])) - .reduce((sum, deposit) => sum + settlementState(deposit, swapsOf(deposit)).remainingEureRaw, 0n); + const ledgerEure = unrecovered.reduce( + (sum, deposit) => sum + settlementState(deposit, swapsOf(deposit)).remainingEureRaw, + 0n + ); const onClone = ledgerEure <= eureBalance ? unswapped : []; const elsewhere = stuck.filter(deposit => !onClone.includes(deposit)); if (onClone.length > 0) { From 0b84e7d8526a9388647ef104b4cf1240606848aa Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 12 Oct 2026 17:42:13 +0200 Subject: [PATCH 58/80] docs(api): shorten the fee-apply docstring The resend bound, the public-pool skip and the once-per-reason logging are already documented on the constant, the skip map and the inline check, so the docstring keeps only the decisions: why the keeper applies, why it remembers the last apply, and why a failure never stops the cycle. --- .../services/monerium-b2b/conversion-executor.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) 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 9bdaf6ef6..42d7a93f2 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1465,21 +1465,17 @@ const loggedFeeApplySkips = new Map(); /** * P11: finalizes a guardian fee increase whose timelock has elapsed (`applyFeePolicy` is - * permissionless; nothing else would ever apply it). Waits for the receipt so the cycle - * then reads the applied policy. A timed-out apply is not re-sent every cycle: the forwarder's - * last apply is remembered and nothing is sent while it has no receipt and is younger than - * FEE_APPLY_RESEND_AFTER_MS (keeper writes go through the private transport on mainnet, which - * the public nonce counts never show), nor while the public pool holds any keeper transaction, - * of any account. A skip is logged once per reason. Best-effort like sendPoke: a failed read, a - * revert (applied by someone else, clock skew) or a timeout is logged and the cycle goes on, - * since the contract always settles under its current on-chain policy. The monitor's config + * permissionless; nothing else would ever apply it) and waits for the receipt, so the cycle + * reads the applied policy. It remembers the last apply because keeper writes go through the + * private transport on mainnet, which the public nonce counts never show. Best-effort like + * sendPoke: a failed read, a revert or a timeout is logged and the cycle goes on, since the + * contract always settles under its current on-chain policy. The monitor's config * reconciliation mirrors the applied values into the account row. */ async function applyDueFeePolicy(forwarder: Address): Promise { const key = forwarder.toLowerCase(); try { const client = getPublicClient(); - // Read here, not with the cycle's state: a failed read must not stop the cycle's recover or poke. const effectiveAt = await client.readContract({ abi: forwarderAbi, address: forwarder, From 5b434ef0343fff0087c3cc2b0be06d8d424c2ae2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 12 Oct 2026 17:43:03 +0200 Subject: [PATCH 59/80] test(api): drop a fee-apply test two sharper tests cover Both resend guards block its second cycle, so it fails only when both are gone. The private-transport test pins the remembered apply and the public-pool test pins the nonce gate, each on its own: disabling either guard fails one of them. --- .../services/monerium-b2b/conversion-executor.test.ts | 11 ----------- 1 file changed, 11 deletions(-) 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 bf799b4eb..c82563516 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 @@ -1769,17 +1769,6 @@ describe("runConversionExecutor fee-policy apply", () => { expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); // the cycle went on }); - it("does not re-send while an earlier apply is still unmined", async () => { - reads.pendingFeePolicyEffectiveAt = NOW; - receiptTimesOut = true; - const { accountId } = await activeAccountWithDeposit(); - - await runConversionExecutor(accountId); // sent, receipt wait times out - await runConversionExecutor(accountId); // still looks pending on chain - - expect(writes.filter(write => write === "applyFeePolicy")).toEqual(["applyFeePolicy"]); - }); - it("does not re-send an unmined apply the public pool cannot see (private transport) until the relay bound", async () => { reads.pendingFeePolicyEffectiveAt = NOW - 60n; receiptTimesOut = true; From d819b974c4f291be52fc752d2e736c20a2f076a6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 12 Oct 2026 17:43:43 +0200 Subject: [PATCH 60/80] test(api): share the account helper of the batch-marker and fee tests Both describes defined a byte-identical activeAccountWithDeposit, so one file-scope copy serves both and a fixture change cannot drift between them. --- .../monerium-b2b/conversion-executor.test.ts | 102 ++++++------------ 1 file changed, 34 insertions(+), 68 deletions(-) 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 c82563516..43f1b00ae 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 @@ -125,6 +125,40 @@ function swapRow(eureInRaw: bigint, usdcNetRaw: bigint): MoneriumConversionExecu } as unknown as MoneriumConversionExecution; } +async function activeAccountWithDeposit() { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const { accountId } = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: "0x5555555555555555555555555555555555555555", + externalSubjectId: "client-1", + forwarderAddress: "0x1111111111111111111111111111111111111111", + managerProfileId: manager.id, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + const deposit = await MoneriumFiatDeposit.create({ + accountId, + amountRaw: (100n * EUR).toString(), + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + mintedAt: new Date(), + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xorder1" + }); + return { accountId, deposit }; +} + describe("settlementState", () => { it("aggregates the confirmed chunks of a deposit", () => { const state = settlementState({ amountRaw: (100n * EUR).toString() }, [ @@ -1143,40 +1177,6 @@ describe("runConversionExecutor batch marker", () => { return writes; } - async function activeAccountWithDeposit() { - const manager = await createTestUser(); - await ManagedProfileManager.create({ - allowedCorridors: ["EU"], - allowedCustomerTypes: ["business"], - isActive: true, - profileId: manager.id - }); - const { accountId } = await provisionMoneriumB2bAccount({ - contactEmail: "ops@client.example.com", - destination: "0x5555555555555555555555555555555555555555", - externalSubjectId: "client-1", - forwarderAddress: "0x1111111111111111111111111111111111111111", - managerProfileId: manager.id, - moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" - }); - await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); - const deposit = await MoneriumFiatDeposit.create({ - accountId, - amountRaw: (100n * EUR).toString(), - blockNumber: 100, - chainId: 11155111, - currency: "eur", - logIndex: 1, - mintedAt: new Date(), - moneriumOrderId: "order-1", - payerIban: "DE89370400440532013000", - payerName: "Payer GmbH", - status: MoneriumFiatDepositStatus.Minted, - txHash: "0xorder1" - }); - return { accountId, deposit }; - } - const executions = (accountId: string) => MoneriumConversionExecution.findAll({ where: { accountId } }); async function failedAttempt(accountId: string, depositId: string, kind = MoneriumConversionExecutionKind.Swap) { @@ -1725,40 +1725,6 @@ describe("runConversionExecutor fee-policy apply", () => { afterEach(() => mock.restore()); - async function activeAccountWithDeposit() { - const manager = await createTestUser(); - await ManagedProfileManager.create({ - allowedCorridors: ["EU"], - allowedCustomerTypes: ["business"], - isActive: true, - profileId: manager.id - }); - const { accountId } = await provisionMoneriumB2bAccount({ - contactEmail: "ops@client.example.com", - destination: "0x5555555555555555555555555555555555555555", - externalSubjectId: "client-1", - forwarderAddress: "0x1111111111111111111111111111111111111111", - managerProfileId: manager.id, - moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" - }); - await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); - const deposit = await MoneriumFiatDeposit.create({ - accountId, - amountRaw: (100n * EUR).toString(), - blockNumber: 100, - chainId: 11155111, - currency: "eur", - logIndex: 1, - mintedAt: new Date(), - moneriumOrderId: "order-1", - payerIban: "DE89370400440532013000", - payerName: "Payer GmbH", - status: MoneriumFiatDepositStatus.Minted, - txHash: "0xorder1" - }); - return { accountId, deposit }; - } - it("applies a due increase once, before the cycle prices its swap", async () => { reads.pendingFeePolicyEffectiveAt = NOW; const { accountId, deposit } = await activeAccountWithDeposit(); From 1ed113199ba4e171e937d0439e0cc2079f22afa7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 12 Oct 2026 17:44:54 +0200 Subject: [PATCH 61/80] test(api): fold the quoter checks into the depth-check cases The depth-check cases already run once per quoted chain, so each now also asserts that both quote sizes went to that chain's pinned QuoterV2, which makes the two standalone quoter tests redundant. --- .../services/monerium-b2b/monitoring.test.ts | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) 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 1cafd32af..c4386ab7b 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -292,23 +292,11 @@ describe("runExecutableDepthCheck", () => { 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); - }); - - for (const [chainId, level] of [ - [1, "error"], - [11_155_111, "warn"] + for (const [chainId, level, quoter] of [ + [1, "error", "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"], + [11_155_111, "warn", "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3"] ] as const) { - it(`logs DEPTH BELOW FLOOR at ${level} on chain ${chainId}`, async () => { + it(`quotes on chain ${chainId}'s QuoterV2 and logs DEPTH BELOW FLOOR at ${level}`, async () => { const { quoteSpy } = arrange(chainId); quoteSpy.mockImplementation(async (_quoter, _path, amountIn) => (amountIn * 100n) / (100n * 10n ** 12n)); // ~12% impact const lines: Record = { error: [], warn: [] }; @@ -318,6 +306,8 @@ describe("runExecutableDepthCheck", () => { }) as unknown as typeof logger.error); } await runExecutableDepthCheck(); + expect(quoteSpy).toHaveBeenCalledWith(quoter, "0xaa", 1n * EUR); + expect(quoteSpy).toHaveBeenCalledWith(quoter, "0xaa", 10_000n * EUR); const other = level === "error" ? "warn" : "error"; expect(lines[level]).toHaveLength(1); expect(lines[level][0]).toContain("DEPTH BELOW FLOOR"); From ff20387214f94fdfd2a2f5a485a6024fc844e143 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 12 Oct 2026 17:46:18 +0200 Subject: [PATCH 62/80] docs(api): state the unattr: candidate rule once The paragraph gave the one-cycle candidacy of an unattr: row twice, once in a parenthetical and again as the reason the orphan inflow's poke is not retried. One sentence now carries the rule and its consequence. --- docs/architecture-monerium-b2b-onramp.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index b2cc7083b..9faed5282 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -204,16 +204,14 @@ Vortex learns about a deposit through two complementary channels, which converge same per-forwarder advisory lock: the **webhooks** carry the provider order accounting (amount, order id, compliance holds), while the **mint watcher** proves the on-chain mint identity. Only a settled, chain-indexed mint makes an account a conversion -candidate (an `unattr:` row, such as a refund top-up left on the clone, keeps none -after the cycle it lands in). A live balance by itself is deliberately insufficient: -this prevents a swap from outrunning the watcher's reorg window and becoming -impossible to attribute safely. An account with a pending execution stays a candidate -until the executor resolves that row, even after an operator closed its deposit. -An orphan inflow (an `unattr:` row whose order never arrives) gets a single -best-effort poke in the cycle its mint lands in; if that poke fails, the batch marker -stays unarmed, the stranded-balance check never escalates, and the one-time -"unattributed EURe mint" warning is the only signal; later cycles do not retry the -poke, because the account is no longer a candidate. +candidate. A live balance by itself is deliberately insufficient: this prevents a swap +from outrunning the watcher's reorg window and becoming impossible to attribute safely. +An account with a pending execution stays a candidate until the executor resolves that +row, even after an operator closed its deposit. An `unattr:` row (a refund top-up left +on the clone, or an orphan inflow whose order never arrives) keeps its account a +candidate only for the cycle it lands in, so an orphan inflow gets a single best-effort +poke; if that poke fails, the batch marker stays unarmed, the stranded-balance check +never escalates, and the one-time "unattributed EURe mint" warning is the only signal. A payer can name a chain and address in the SEPA memo; when that address is linked to the client's profile, Monerium mints there instead of to the IBAN's default address. Besides From b3ec41bfb916dcd853b6d69458f79964cf28c9b0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 12 Oct 2026 17:48:56 +0200 Subject: [PATCH 63/80] docs(api): trim repetition in the sub-floor refund docs Each sub-floor refund text restated a point it had just made: the ADR amendment its no-automatic-refund rule, the whole-cent rule and the timing; the architecture bullet the auto-only pipeline and the leftover top-up; the spec its hand-refund rule, in a form that contradicted the alert and off modes. The sandbox minimum now points to the production paragraph instead of repeating it. No decision or mode scope changes. --- docs/adr-0005-monerium-b2b-onramp.md | 21 +++++++++---------- docs/api/pages/14-managed-profiles.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 20 +++++++++--------- .../05-integrations/monerium-b2b.md | 2 +- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 6592bf4a0..e1f6b3053 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -290,20 +290,19 @@ the promised window. Decisions: - **Payments below `MIN_SWAP_FLOOR` are refunded through an operator top-up, not automatically.** The 2026-09-17 refund triggers include remainders below `minSwapAmount`, but a clone holding less than `MIN_SWAP_FLOOR` (€1) EURe and no USDC - never arms its batch marker, so `recover` reverts for such a payment and the keeper - cannot refund it on its own. Once it is marked `recovering`, the stranded-balance - monitor raises REFUND NEEDS OPERATOR with the exact top-up (`MIN_SWAP_FLOOR - clone - EURe + 1 wei`, one wei more when that is a whole number of cents), which an operator - sends exactly as printed to the clone from any wallet, e.g. the float (internal B2B - runbook §2.7). The marker then arms and the keeper's normal `recover` + never arms its batch marker, so `recover` reverts for such a payment. Once it is + marked `recovering`, the stranded-balance monitor raises REFUND NEEDS OPERATOR with + the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a + whole number of cents, so the mint watcher never attributes it to an order), which an + operator sends exactly as printed to the clone from any wallet, e.g. the float + (internal B2B runbook §2.7). The marker then arms and the keeper's normal `recover` and refund pipeline refund the payer's own EURe after `RECOVERY_DELAY`, with the full refund record and the one-refund-per-client gate; such a payment is never refunded by hand (with `MONERIUM_B2B_AUTO_RECOVERY=auto`; in `alert` or `off` the operator redeems - it from the client's refund wallet after the keeper's `recover`, as the alert says). The top-up stays on the clone below the floor as Vortex EURe (never a whole - number of cents, so the mint watcher never attributes it to an order), and a later - sub-floor payment that reaches the floor with it is refunded with no operator step. Sending the top-up as - soon as the payment shows `below_minimum` lands the refund at about the normal - deadline; otherwise it lands `RECOVERY_DELAY` after the top-up. + it from the client's refund wallet after the keeper's `recover`). The top-up stays on + the clone below the floor as Vortex EURe, and a later sub-floor payment that reaches + the floor with it is refunded with no operator step. Sending the top-up as soon as the + payment shows `below_minimum` lands the refund at about the normal deadline. ## Final parameters (decided 2026-08-26 unless noted) diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 9bd08b62c..1cd907a10 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -199,7 +199,7 @@ X-Managed-Profile-Id: **Sandbox specifics** - **Network.** Sandbox converts and delivers on Ethereum Sepolia; production uses Ethereum mainnet. The destination must be an Ethereum address the client controls, and it is fixed for the life of the account. -- **Minimum.** As in production, payments from EUR 1 are converted; a smaller payment is refunded later than the normal window, after a step by Vortex operations (see [Register A Business EUR Client](#register-a-business-eur-client)). +- **Minimum.** As in production (see [Register A Business EUR Client](#register-a-business-eur-client)). - **Refund window.** A payment that is not converted within 15 minutes of its mint (two hours by default in production), for example because the account was not active yet, is refunded in full to the account it came from (operations process the refund by hand where automatic refunds are off). The EUR provider pays refunds out only for profiles it has approved. - **Webhook signatures.** Sandbox signs deliveries with its own key: verify them against `https://api-sandbox.vortexfinance.co/v1/public-key`, not the production key. See [Webhooks](https://api-docs.vortexfinance.co/webhooks). diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 9faed5282..3358b0124 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -382,16 +382,16 @@ permissionless `poke` and `applyFeePolicy`): `poke` arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline below refund the payer exactly, from the payer's own EURe, with the full record (`recover` transaction, redeem order, - `refundedAt`); with `MONERIUM_B2B_AUTO_RECOVERY=auto` the deposit is never refunded - by hand. That refund pipeline runs only in `auto`; in `alert` or `off` the keeper's `recover` - still moves the payment to the client's refund wallet, and the alert points to the - manual redeem from that wallet instead. The top-up stays on the clone - below the floor, so `recover` switches the marker off by itself: no follow-up step. The - top-up is never a whole number of cents, even with an earlier top-up's odd wei on the - clone, so the mint watcher can never match it to a Monerium order; it is recorded as - an `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups are Vortex EURe and stay below EUR 1 per clone; a - later sub-floor payment on that clone that reaches the floor with them arms the marker - and is refunded with no operator step. The top-up may also be sent early, as soon as + `refundedAt`); with `MONERIUM_B2B_AUTO_RECOVERY=auto` the deposit is never refunded by + hand; in `alert` or `off` the keeper's `recover` still moves the payment to the + client's refund wallet, and the alert points to the manual redeem from that wallet + instead. Leftover top-ups stay on the clone as Vortex EURe, below EUR 1 per clone, so + `recover` switches the marker off by itself (no follow-up step), and a later sub-floor + payment on that clone that reaches the floor with them arms the marker and is refunded + with no operator step. The top-up is never a whole number of cents, even with an + earlier top-up's odd wei on the clone, so the mint watcher can never match it to a + Monerium order; it is recorded as an `unattr:` Minted row (one warn) that the keeper + never converts. The top-up may also be sent early, as soon as the payment shows `below_minimum`: the marker then arms at once and the refund lands at about the normal deadline instead of a `RECOVERY_DELAY` after the alert. Monerium's minimum redeem order is EUR 0.01, so a sub-EUR-1 refund is allowed (not yet rehearsed diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 96f824c80..b23c037d0 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up, never a hand refund: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`: in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead of forbidding a hand refund. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". +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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From fe900e2a16093360e1561226bc04d93fb9527be4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 12 Oct 2026 17:49:11 +0200 Subject: [PATCH 64/80] docs(api): point the fee-apply resend bound to invariant 2 Keeper invariant 2 already states that keeper writes go through the private transport and that the public nonce counts cannot see them, so the fee-apply invariant cites it instead of repeating it. --- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index b23c037d0..e54a043c3 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -42,7 +42,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; the trade-off is ordering: a younger deposit forwards before the older passed-over one is refunded, and each `forward` resyncs the batch marker (`_syncBatch(true)`); on a clone left with less than `MIN_SWAP_FLOOR` EURe and no USDC the marker switches off or never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; while the clone stays funded (a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, or a chunk-swapped remainder whose USDC is still on the clone) each younger `forward` re-times the marker, so the passed-over deposit is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed, provider-attributed deposits make an account a conversion candidate (an `unattr:` row only for the cycle its mint lands in; an account with a pending execution stays one until the executor resolves it; the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction; keeper writes go through the private transport on mainnet, which the public nonce counts never show), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply, and another keeper process on the same database never knew it, so either may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction, which the public nonce counts never show: invariant 2), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply, and another keeper process on the same database never knew it, so either may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. ## Monitoring From 9bfc8342fdfa20ac33ff56358ee7a0b5f72f81ae Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:25:27 +0200 Subject: [PATCH 65/80] fix(api): read the stranded monitor's floor and delays per clone The monitor took MIN_SWAP_FLOOR, RECOVERY_DELAY and TRIGGER_DELAY from whichever account the unordered query listed first and applied them to every clone, so during a migration the REFUND NEEDS OPERATOR top-up and the REFUND OVERDUE bounds could come from another factory. A clone off the trusted factory, which the keeper refuses, also got a top-up it could never act on; it is now skipped with an error, as the executor and the config reconciliation do. --- .../services/monerium-b2b/monitoring.test.ts | 67 ++++++++++++++++++- .../api/services/monerium-b2b/monitoring.ts | 31 +++++---- docs/architecture-monerium-b2b-onramp.md | 4 +- .../05-integrations/monerium-b2b.md | 4 +- 4 files changed, 88 insertions(+), 18 deletions(-) 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 c4386ab7b..d15a82145 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -390,7 +390,11 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { afterEach(() => mock.restore()); - async function accountWithDeposit(status: MoneriumFiatDepositStatus, amountRaw = EUR / 2n) { + async function accountWithDeposit( + status: MoneriumFiatDepositStatus, + amountRaw = EUR / 2n, + forwarderAddress = "0x1111111111111111111111111111111111111111" + ) { const manager = await createTestUser(); await ManagedProfileManager.create({ allowedCorridors: ["EU"], @@ -402,9 +406,9 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { contactEmail: "ops@client.example.com", destination: "0x5555555555555555555555555555555555555555", externalSubjectId: "client-1", - forwarderAddress: "0x1111111111111111111111111111111111111111", + forwarderAddress, managerProfileId: manager.id, - moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + moneriumProfileId: crypto.randomUUID() }); await MoneriumAccount.update({ status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); return addDeposit(accountId, status, amountRaw); @@ -638,6 +642,63 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(errors).toEqual([]); }); + // A migration can leave clones of two factories live: each clone's own floor and delays + // apply, and a clone off the trusted factory, which the keeper refuses, gets no advice. + describe("on clones of different factories", () => { + const OTHER_FACTORY = "0x3333333333333333333333333333333333333333" as Address; + const OTHER_CLONE = "0x7777777777777777777777777777777777777777"; + const now = 1_800_000_000_000; + + beforeEach(() => { + spyOn(chain, "getForwarderImmutables").mockImplementation( + async forwarder => + ({ + eure: EURE, + factory: forwarder === OTHER_CLONE ? OTHER_FACTORY : FACTORY, + recoveryDelaySeconds: forwarder === OTHER_CLONE ? 7_200 : 900, + usdc: "0x6666666666666666666666666666666666666666" + }) as unknown as chain.ForwarderImmutables + ); + spyOn(chain, "getPublicClient").mockReturnValue({ + readContract: async ({ address, functionName }: { address: Address; functionName: string }) => { + if (functionName === "balanceOf") return address === EURE ? eureOnClone : 0n; + if (functionName === "batchOpenedAt") return batchOpenedAt; + if (functionName === "MIN_SWAP_FLOOR") return address === OTHER_FACTORY ? 5n * EUR : 1n * EUR; + return address === OTHER_CLONE ? 86_400n : 3_600n; // TRIGGER_DELAY + } + } as unknown as ReturnType); + }); + + it("prescribes the top-up from the clone's own factory floor and skips a clone off the trusted factory", async () => { + // Created first, so the unordered account query tends to list it first. + const other = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, EUR / 2n, OTHER_CLONE); + const trusted = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + await runStrandedBalanceMonitor(now); + const topUps = errors.filter(message => message.includes("REFUND NEEDS OPERATOR")); + expect(topUps).toHaveLength(1); + expect(topUps[0]).toContain(trusted.id); + expect(topUps[0]).toContain("send exactly 0.500000000000000001 EURe"); + expect([...errors, ...warnings].filter(message => message.includes(other.id))).toEqual([]); + const skipped = errors.filter(message => message.includes(OTHER_CLONE)); + expect(skipped).toHaveLength(1); + expect(skipped[0]).toContain(`untrusted factory ${OTHER_FACTORY}`); + }); + + it("times REFUND OVERDUE by the clone's own RECOVERY_DELAY and TRIGGER_DELAY", async () => { + eureOnClone = 10n * EUR; + batchOpenedAt = BigInt(Math.floor((now - 10 * 60_000) / 1000)); + const other = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, 10n * EUR, OTHER_CLONE); + const trusted = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, 10n * EUR); + // 80 min ago: past the trusted clone's 15 min RECOVERY_DELAY plus the margin, and its 1 h TRIGGER_DELAY. + for (const deposit of [other, trusted]) await deposit.update({ refundStartedAt: new Date(now - 80 * 60_000) }); + await runStrandedBalanceMonitor(now); + const overdue = errors.filter(message => message.includes("REFUND OVERDUE")); + expect(overdue).toHaveLength(1); + expect(overdue[0]).toContain(trusted.id); + expect(warnings.filter(message => message.includes("REFUND OVERDUE"))).toEqual([]); + }); + }); + // At or above the floor the contract can recover, but younger forwards may keep re-timing // the marker so the deposit never becomes eligible (minSwapAmount raised above the floor). describe("a marked deposit the keeper never recovers", () => { diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 9e6b6b6cd..cd605599d 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -383,15 +383,10 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi return; } const client = getPublicClient(); - const { factory, recoveryDelaySeconds } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); - const [minSwapFloor, triggerDelay] = await Promise.all([ - client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), - client.readContract({ - abi: forwarderMonitoringAbi, - address: accounts[0].forwarderAddress as Address, - functionName: "TRIGGER_DELAY" - }) - ]); + const trustedFactory = config.moneriumB2b.forwarderFactoryAddress; + // The trusted factory's MIN_SWAP_FLOOR, read at the first clone that passes the factory + // check below (every clone checked is that factory's). + let minSwapFloor: bigint | undefined; for (const account of accounts) { try { @@ -420,11 +415,23 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi if (classifyRefundQueue(stuckRecovers[0].createdAt, false, now) === "error") logger.error(message); else logger.warn(message); } - const { eure, usdc } = await getForwarderImmutables(forwarder); - const [eureBalance, usdcBalance, batchOpenedAt] = await Promise.all([ + // The clone's own immutables: during a migration, clones of another factory and + // implementation (another floor, other delays) can still be live. + const { eure, factory, recoveryDelaySeconds, usdc } = await getForwarderImmutables(forwarder); + if (!trustedFactory || factory.toLowerCase() !== trustedFactory.toLowerCase()) { + // The keeper refuses such a clone on every cycle, so no top-up or refund advice applies. + logger.error( + `monerium-b2b: stranded-balance check skipped for forwarder ${forwarder} (account ${account.id}): it ` + + `reports untrusted factory ${factory}, expected ${trustedFactory}, so the keeper cannot act on it` + ); + continue; + } + minSwapFloor ??= await client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }); + const [eureBalance, usdcBalance, batchOpenedAt, triggerDelay] = await Promise.all([ client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), - client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }) + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "TRIGGER_DELAY" }) ]); if (eureBalance < minSwapFloor && usdcBalance === 0n) { // Below MIN_SWAP_FLOOR the contract arms no batch, so `recover` can never run for a diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 3358b0124..b1386efdb 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -568,7 +568,9 @@ read-only — no keys, no transactions: 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 — - a keeper-outage signal; funds are never at risk). An unswapped deposit marked + a keeper-outage signal; funds are never at risk). Each clone is judged by its own + delays and its factory's floor; a clone off the configured trusted factory, which the + keeper refuses, is skipped with an error line. An unswapped deposit marked `recovering` without a pending or confirmed `recover`, on a clone below `MIN_SWAP_FLOOR` that still holds all the EURe the ledger places there (every deposit's unswapped, unrecovered rest, a legacy hand refund's EURe and `unattr:` diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index e54a043c3..60933030c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". +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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. @@ -119,7 +119,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] A planned `recover` waits out the retry backoff only for failed recovers, never for failed swaps or forwards, which still back off on a failure of any kind (`conversion-executor.test.ts`: "recovers an eligible deposit on time however often its swaps failed", "still backs a recover off on its own failed recovers") - [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`) - [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch -- [ ] `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` is the factory queried during provisioning/monitoring, and a self-reported mismatch is rejected before local persistence +- [ ] `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` is the factory queried during provisioning/monitoring, and a self-reported mismatch is rejected before local persistence; the stranded-balance monitor skips a clone off it with an error and reads each clone's own floor and delays (`monitoring.test.ts`: "on clones of different factories") - [ ] The factory binds the CREATE2 address to the whole initialization and `predictAddress` takes all five arguments; a deployer cannot squat a predicted address; `revokeForwarder` is guardian-only and one-way; a deployer holds no guardian power and survives a guardian transfer (`VortexForwarder.t.sol`: `test_predictAddress_bindsTheInitialization`, `test_deployer_cannotSquatAPredictedAddress`, `test_deployer_holdsNoGuardianPower`, `test_deployer_survivesGuardianTransfer`, `test_revokeForwarder_guardianOnly_oneWay`) - [ ] A registration waits with a `waitingReason` through every transient failure and is rejected only for a definite reason (Monerium rejected or closed the profile, a factory refusal of the arguments, a client or mapping conflict, an operator mapping to another destination, a withdrawal); the keeper evaluates the least recently checked registrations first, sends one deployment per cycle and resends one that reverted or has no receipt after 10 minutes (`registration.test.ts`) - [ ] `POST /v1/monerium-b2b/accounts` refuses an impersonation token while `GET /registrations` stays readable, answers 503 when Monerium fails, 422 for an unknown, rejected or closed profile, and 409 for a destination or client conflict, and lets the partner register a rejected profile again (`monerium-b2b-registration.integration.test.ts`) From f4eb5256b911b47cfc771812e70732e0e171ba93 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:26:53 +0200 Subject: [PATCH 66/80] fix(api): hold the sub-floor top-up while a sibling needs reconciling REFUND NEEDS OPERATOR prescribed a top-up even when another marked payment on the same clone was flagged for reconciling in the same pass. The top-up arms the marker for that payment too, whose recover cannot succeed, so the keeper could keep retrying it while the payment the alert promised to refund waited. The unswapped payments now get a warning that they wait for the reconcile, which does not claim their own funds left the clone. --- .../services/monerium-b2b/monitoring.test.ts | 19 +++++++++++++++++++ .../api/services/monerium-b2b/monitoring.ts | 16 ++++++++++++++-- docs/architecture-monerium-b2b-onramp.md | 5 ++++- .../05-integrations/monerium-b2b.md | 4 ++-- 4 files changed, 39 insertions(+), 5 deletions(-) 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 d15a82145..fa2a861cd 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -614,6 +614,25 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(warning).not.toContain("are no longer on the clone"); }); + it("holds the top-up while another marked payment on the clone needs reconciling", async () => { + // The swapped payment's USDC left on an unrecorded forwardAll, so its recover reverts on this + // USDC-less clone; a top-up would arm the marker and let the keeper retry it. + const swapped = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, 5n * EUR); + const swap = await execution(swapped, MoneriumConversionExecutionKind.Swap, MoneriumConversionExecutionStatus.Confirmed); + await swap.update({ usdcNetRaw: ((57n * USDC) / 10n).toString() }); + const unswapped = await addDeposit(swapped.accountId, MoneriumFiatDepositStatus.Recovering, EUR / 2n); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + const wait = warnings.filter(message => message.includes(unswapped.id)); + expect(wait).toHaveLength(1); + expect(wait[0]).toContain(`another marked payment on this clone (${swapped.id}) needs reconciling first`); + expect(wait[0]).toContain("do not top up the clone until it is reconciled"); + expect(wait[0]).not.toContain("left the clone"); + const reconcile = warnings.filter(message => message.includes("part or all of their funds left the clone")); + expect(reconcile).toHaveLength(1); + expect(reconcile[0]).toContain(swapped.id); + }); + // EURe the ledger places on the clone for other payments: a younger one still settling, or // one refunded by hand (its EURe never left the clone). for (const other of [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Refunded]) { diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index cd605599d..d5675ce18 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -46,7 +46,8 @@ import { refundAccountFor } from "./refund-wallet"; * permissionless-trigger delay, registry P4) they error — a keeper-outage signal. * An unswapped payment marked for recovery whose EURe is still on a clone below * MIN_SWAP_FLOOR errors too: the contract cannot recover it until the operator tops the - * clone up to the floor (one whose funds already left the clone only warns: reconcile). + * clone up to the floor (one whose funds already left the clone only warns: reconcile, and + * the others' top-up waits for that). * A marked deposit left unrecovered RECOVERY_DELAY plus an hour after marking warns * (REFUND OVERDUE), and errors past TRIGGER_DELAY. * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault @@ -493,7 +494,18 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi ); const onClone = ledgerEure <= eureBalance ? unswapped : []; const elsewhere = stuck.filter(deposit => !onClone.includes(deposit)); - if (onClone.length > 0) { + if (onClone.length > 0 && elsewhere.length > 0) { + // A top-up would arm the marker for those payments too, whose `recover` cannot succeed + // before they are reconciled (a swapped one's always reverts on this USDC-less clone), + // so the keeper could keep retrying it instead of refunding these. + logger.warn( + `monerium-b2b: deposit(s) ${onClone.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + + `(account ${account.id}) are marked for recovery below MIN_SWAP_FLOOR but wait: another marked payment on ` + + `this clone (${elsewhere.map(deposit => deposit.id).join(", ")}) needs reconciling first, and a top-up would ` + + "arm the marker, so the keeper could retry that payment's recover ahead of these; do not top up the clone " + + "until it is reconciled (this alert then returns with the top-up amount; the internal B2B runbook §2.7)" + ); + } else if (onClone.length > 0) { // A top-up to just above the floor lets `poke` arm the marker, and the keeper's // normal `recover` then refunds the payer's own EURe; the top-up stays on the clone // below the floor. Deposits and the floor are whole cents, so an amount off a whole cent diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index b1386efdb..5ecb1f38c 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -578,7 +578,10 @@ read-only — no keys, no transactions: the keeper recover it (the refund path above, internal B2B runbook §2.7); a landed top-up lifts the clone to the floor and quiets it. Otherwise part or all of its funds may have left the clone by an unrecorded path, so it warns "reconcile before - refunding; do not refund and do not top up the clone". The pass skips that account + refunding; do not refund and do not top up the clone"; while such a payment is + flagged, the clone's unswapped marked payments only get a warning that they wait for + that reconcile, since a top-up would arm the marker for the flagged payment too, whose + `recover` cannot succeed. The pass skips that account while any of its `recover` executions is still pending: a mined `recover` not yet confirmed would make the ledger overstate the clone. A `recover` still pending an hour after it was created warns RECOVER STUCK PENDING (an error after four hours), on diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 60933030c..223a2c1a2 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". +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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. @@ -126,7 +126,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] Registered accounts auto-activate only when `SANDBOX_ENABLED=true`, only with an IBAN and only from a mapped registration; activation sets `activated_at`, the dormancy anchor is the later of the last conversion and the activation, and a deposit on an account that cannot convert carries `account_not_active` until it can (`registration.test.ts`, `dormancy.test.ts`, `conversion-executor.test.ts`) - [ ] The admin registration list filters by status and withdraws only a `requested` registration (409 otherwise, 404 when unknown), and `onboarding → suspended` is accepted and logged (`moneriumB2b.controller.test.ts`) - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` -- [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, and only warns "reconcile" when funds left by an unrecorded path (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…") +- [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, only warns "reconcile" when funds left by an unrecorded path, and holds the top-up while another marked payment on the clone needs that reconcile (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…", "holds the top-up while another marked payment on the clone needs reconciling") - [ ] RECOVER STUCK PENDING warns for a `recover` execution still pending one hour after its creation and errors after four, so a pending `recover` the keeper cannot resolve never keeps the account's refund alerts silent (`monitoring.test.ts`: "warns on a recover still pending 2h after it was sent", "errors on a recover still pending 5h after it was sent") - [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") - [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; guardian fee-policy changes (R07/P11) are warn-level reconciliations, a destination change is an incident From 48731b71f1dd28564add3fbf99214c5c551a58e3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:28:50 +0200 Subject: [PATCH 67/80] fix(api): hold the sub-floor top-up while the client's refund is open The keeper sends no second recover while another refund of the client is open, but REFUND NEEDS OPERATOR still asked for the top-up. Sent then, it arms the marker at the floor (at least minSwapAmount), and if the open refund outlives TRIGGER_DELAY the permissionless swap and forwardAll can reach the payer's and Vortex's EURe before the keeper may recover. The alert now warns that the payment waits and returns with the amount once that refund is redeemed. --- .../services/monerium-b2b/monitoring.test.ts | 32 +++++++++++++++++-- .../api/services/monerium-b2b/monitoring.ts | 14 ++++++-- docs/adr-0005-monerium-b2b-onramp.md | 5 ++- docs/architecture-monerium-b2b-onramp.md | 13 ++++++-- .../05-integrations/monerium-b2b.md | 4 +-- 5 files changed, 58 insertions(+), 10 deletions(-) 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 fa2a861cd..8cc510440 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -9,6 +9,7 @@ import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; import { createTestUser } from "../../../test-utils/factories"; import { provisionMoneriumB2bAccount } from "./account-provisioning"; @@ -510,8 +511,8 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { }); it("keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", async () => { - // D1 (0.3) was recovered with top-up T1 = 0.3 EURe + 1 wei; D2 (0.4) and T1 stay on the clone. - const recovered = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, (EUR * 3n) / 10n); + // D1 (0.3) was refunded with top-up T1 = 0.3 EURe + 1 wei; D2 (0.4) and T1 stay on the clone. + const recovered = await accountWithDeposit(MoneriumFiatDepositStatus.Refunded, (EUR * 3n) / 10n); await execution(recovered, MoneriumConversionExecutionKind.Recover, MoneriumConversionExecutionStatus.Confirmed); const stuck = await addDeposit(recovered.accountId, MoneriumFiatDepositStatus.Recovering, (EUR * 4n) / 10n); await MoneriumFiatDeposit.create({ @@ -614,6 +615,33 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(warning).not.toContain("are no longer on the clone"); }); + it("holds the top-up while another refund of the client is open, and asks for it once that is redeemed", async () => { + const deposit = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering); + // The client's earlier refund: its recover took its EURe off the clone, its redeem is under way. + const earlier = await addDeposit(deposit.accountId, MoneriumFiatDepositStatus.Recovering, 2n * EUR); + await execution(earlier, MoneriumConversionExecutionKind.Recover, MoneriumConversionExecutionStatus.Confirmed); + const refund = await MoneriumRecovery.create({ + depositId: earlier.id, + eureRecoveredRaw: (2n * EUR).toString(), + phase: MoneriumRecoveryPhase.Redeeming, + usdcRecoveredRaw: "0" + }); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(deposit.id); + expect(warnings[0]).toContain("another refund of this client is still open"); + expect(warnings[0]).not.toContain("send exactly"); + + await refund.update({ phase: MoneriumRecoveryPhase.Redeemed }); + warnings = []; + await runStrandedBalanceMonitor(); + expect(warnings).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("REFUND NEEDS OPERATOR"); + expect(errors[0]).toContain("send exactly 0.500000000000000001 EURe"); + }); + it("holds the top-up while another marked payment on the clone needs reconciling", async () => { // The swapped payment's USDC left on an unrecorded forwardAll, so its recover reverts on this // USDC-less clone; a top-up would arm the marker and let the keeper retry it. diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index d5675ce18..43492368b 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -27,6 +27,7 @@ import { } from "./chain"; import { settlementState } from "./conversion-executor"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; +import { activeRecoveryExists } from "./recovery"; import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; import { refundAccountFor } from "./refund-wallet"; @@ -46,8 +47,8 @@ import { refundAccountFor } from "./refund-wallet"; * permissionless-trigger delay, registry P4) they error — a keeper-outage signal. * An unswapped payment marked for recovery whose EURe is still on a clone below * MIN_SWAP_FLOOR errors too: the contract cannot recover it until the operator tops the - * clone up to the floor (one whose funds already left the clone only warns: reconcile, and - * the others' top-up waits for that). + * clone up to the floor (one whose funds already left the clone only warns: reconcile; the + * others' top-up waits for that, and for the client's open refund). * A marked deposit left unrecovered RECOVERY_DELAY plus an hour after marking warns * (REFUND OVERDUE), and errors past TRIGGER_DELAY. * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault @@ -505,6 +506,15 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi "arm the marker, so the keeper could retry that payment's recover ahead of these; do not top up the clone " + "until it is reconciled (this alert then returns with the top-up amount; the internal B2B runbook §2.7)" ); + } else if (onClone.length > 0 && (await activeRecoveryExists(account.id))) { + // The keeper sends no second `recover` meanwhile (its own gate), so a top-up now would + // only leave the clone armed, past TRIGGER_DELAY open to the permissionless swap. + logger.warn( + `monerium-b2b: deposit(s) ${onClone.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + + `(account ${account.id}) are marked for recovery below MIN_SWAP_FLOOR but wait: another refund of this ` + + "client is still open, and the keeper sends no second recover until it is redeemed; do not top up the " + + "clone until then (this alert then returns with the top-up amount; the internal B2B runbook §2.7)" + ); } else if (onClone.length > 0) { // A top-up to just above the floor lets `poke` arm the marker, and the keeper's // normal `recover` then refunds the payer's own EURe; the top-up stays on the clone diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index e1f6b3053..aa0a5f33f 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -302,7 +302,10 @@ the promised window. Decisions: it from the client's refund wallet after the keeper's `recover`). The top-up stays on the clone below the floor as Vortex EURe, and a later sub-floor payment that reaches the floor with it is refunded with no operator step. Sending the top-up as soon as the - payment shows `below_minimum` lands the refund at about the normal deadline. + payment shows `below_minimum` lands the refund at about the normal deadline, but not + while the client's refund queue is busy: while another refund of the client is open + the keeper sends no `recover`, so an armed clone could wait into the permissionless + window, and the alert holds the top-up back until that refund is redeemed. ## Final parameters (decided 2026-08-26 unless noted) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 5ecb1f38c..040444f1a 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -385,7 +385,10 @@ permissionless `poke` and `applyFeePolicy`): `refundedAt`); with `MONERIUM_B2B_AUTO_RECOVERY=auto` the deposit is never refunded by hand; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the alert points to the manual redeem from that wallet - instead. Leftover top-ups stay on the clone as Vortex EURe, below EUR 1 per clone, so + instead. While another refund of the client is open, the keeper sends no `recover` + (one refund per client), so the alert holds the top-up back and only warns that the + payment waits; it returns with the amount once that refund is redeemed. Leftover + top-ups stay on the clone as Vortex EURe, below EUR 1 per clone, so `recover` switches the marker off by itself (no follow-up step), and a later sub-floor payment on that clone that reaches the floor with them arms the marker and is refunded with no operator step. The top-up is never a whole number of cents, even with an @@ -393,7 +396,10 @@ permissionless `poke` and `applyFeePolicy`): Monerium order; it is recorded as an `unattr:` Minted row (one warn) that the keeper never converts. The top-up may also be sent early, as soon as the payment shows `below_minimum`: the marker then arms at once and the refund lands at - about the normal deadline instead of a `RECOVERY_DELAY` after the alert. Monerium's + about the normal deadline instead of a `RECOVERY_DELAY` after the alert. Not while + the client's refund queue is busy, though: a top-up that arms the marker while another + refund of the client is open can leave the clone open to the permissionless path after + `TRIGGER_DELAY` before the keeper may recover. Monerium's minimum redeem order is EUR 0.01, so a sub-EUR-1 refund is allowed (not yet rehearsed end to end). Passing over trades ordering for liveness: a younger payment converts and forwards before the older passed-over one is refunded, and every `forward` @@ -575,7 +581,8 @@ read-only — no keys, no transactions: `MIN_SWAP_FLOOR` that still holds all the EURe the ledger places there (every deposit's unswapped, unrecovered rest, a legacy hand refund's EURe and `unattr:` top-ups included), raises REFUND NEEDS OPERATOR with the exact EURe top-up that lets - the keeper recover it (the refund path above, internal B2B runbook §2.7); a landed + the keeper recover it (the refund path above, internal B2B runbook §2.7), or, while + another refund of the client is open, a warning that it waits for that refund; a landed top-up lifts the clone to the floor and quiets it. Otherwise part or all of its funds may have left the clone by an unrecorded path, so it warns "reconcile before refunding; do not refund and do not top up the clone"; while such a payment is diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 223a2c1a2..cb6f204fd 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. +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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. @@ -126,7 +126,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] Registered accounts auto-activate only when `SANDBOX_ENABLED=true`, only with an IBAN and only from a mapped registration; activation sets `activated_at`, the dormancy anchor is the later of the last conversion and the activation, and a deposit on an account that cannot convert carries `account_not_active` until it can (`registration.test.ts`, `dormancy.test.ts`, `conversion-executor.test.ts`) - [ ] The admin registration list filters by status and withdraws only a `requested` registration (409 otherwise, 404 when unknown), and `onboarding → suspended` is accepted and logged (`moneriumB2b.controller.test.ts`) - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` -- [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, only warns "reconcile" when funds left by an unrecorded path, and holds the top-up while another marked payment on the clone needs that reconcile (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…", "holds the top-up while another marked payment on the clone needs reconciling") +- [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, only warns "reconcile" when funds left by an unrecorded path, and holds the top-up while another marked payment on the clone needs that reconcile or another refund of the client is open (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…", "holds the top-up while another marked payment on the clone needs reconciling", "holds the top-up while another refund of the client is open…") - [ ] RECOVER STUCK PENDING warns for a `recover` execution still pending one hour after its creation and errors after four, so a pending `recover` the keeper cannot resolve never keeps the account's refund alerts silent (`monitoring.test.ts`: "warns on a recover still pending 2h after it was sent", "errors on a recover still pending 5h after it was sent") - [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") - [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; guardian fee-policy changes (R07/P11) are warn-level reconciliations, a destination change is an incident From 90f3fcf511a2d6c5c8b8186cf2e0db95f010504c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:30:18 +0200 Subject: [PATCH 68/80] fix(api): name failing recovers in the overdue refund alert REFUND OVERDUE counted a deposit whose recover attempts had failed as one no recover had been sent for, and only hinted at marker re-timing or another stuck refund. A recover that keeps reverting may mean its funds left by a path the ledger does not record, where a hand refund pays twice, so the alert now says no recover has succeeded, names the latest error and asks to reconcile the ledger instead. --- .../services/monerium-b2b/monitoring.test.ts | 18 ++++++++++++ .../api/services/monerium-b2b/monitoring.ts | 29 +++++++++++-------- docs/architecture-monerium-b2b-onramp.md | 5 +++- .../05-integrations/monerium-b2b.md | 4 +-- 4 files changed, 41 insertions(+), 15 deletions(-) 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 8cc510440..9463fe76c 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -781,6 +781,24 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(errors[0]).toContain(deposit.id); }); + it("asks to reconcile, not to check the marker, when the recover attempts failed", async () => { + const deposit = await markedHoursAgo(4); + const recover = MoneriumConversionExecutionKind.Recover; + const failed = MoneriumConversionExecutionStatus.Failed; + await (await execution(deposit, recover, failed, new Date(now - 2 * HOUR))).update({ error: "attempt 1: timeout" }); + await (await execution(deposit, recover, failed, new Date(now - HOUR))).update({ error: "attempt 3: InvalidAmount()" }); + await runStrandedBalanceMonitor(now); + expect(errors).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("REFUND OVERDUE"); + expect(warnings[0]).toContain(deposit.id); + expect(warnings[0]).toContain("no recover has succeeded"); + expect(warnings[0]).toContain("latest error: attempt 3: InvalidAmount()"); + expect(warnings[0]).toContain("reconcile the clone's balances against the ledger before any refund; do not refund by hand"); + expect(warnings[0]).not.toContain("no recover has been sent"); + expect(warnings[0]).not.toContain("re-timing"); + }); + it("stays quiet before the bound", async () => { await markedHoursAgo(2); await runStrandedBalanceMonitor(now); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 43492368b..f295a9093 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -562,23 +562,28 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi }); if (overdue.length > 0) { const recovers = await MoneriumConversionExecution.findAll({ - attributes: ["depositId"], - where: { - depositId: { [Op.in]: overdue.map(deposit => deposit.id) }, - kind: MoneriumConversionExecutionKind.Recover, - status: { [Op.in]: [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed] } - } + attributes: ["depositId", "error", "status"], + order: [["created_at", "DESC"]], + where: { depositId: { [Op.in]: overdue.map(deposit => deposit.id) }, kind: MoneriumConversionExecutionKind.Recover } }); - const late = overdue.filter(deposit => !recovers.some(e => e.depositId === deposit.id)); + const late = overdue.filter( + deposit => !recovers.some(e => e.depositId === deposit.id && e.status !== MoneriumConversionExecutionStatus.Failed) + ); if (late.length > 0) { const ageMs = now - Math.min(...late.map(deposit => (deposit.refundStartedAt as Date).getTime())); + // The latest failed attempt, if any: a `recover` that reverts may mean the funds left by + // a path the ledger does not record, where a hand refund would pay twice. + const failure = recovers.find(e => late.some(deposit => deposit.id === e.depositId)); + const state = `eure=${formatUnits(eureBalance, 18)}, usdc=${formatUnits(usdcBalance, 6)}, batchOpenedAt=${batchOpenedAt}`; const message = `monerium-b2b: REFUND OVERDUE — deposit(s) ${late.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + - `(account ${account.id}) were marked for recovery up to ${Math.floor(ageMs / 3_600_000)}h ago and no recover ` + - `has been sent (eure=${formatUnits(eureBalance, 18)}, usdc=${formatUnits(usdcBalance, 6)}, ` + - `batchOpenedAt=${batchOpenedAt}): check whether younger forwards keep re-timing the batch marker or another ` + - "refund of this client is stuck; after an operator top-up this clears once the keeper recovers (the internal " + - "B2B runbook §2.7)"; + `(account ${account.id}) were marked for recovery up to ${Math.floor(ageMs / 3_600_000)}h ago and ` + + (failure + ? `no recover has succeeded (${state}; latest error: ${failure.error}): reconcile the clone's balances ` + + "against the ledger before any refund; do not refund by hand" + : `no recover has been sent (${state}): check whether younger forwards keep re-timing the batch marker or ` + + "another refund of this client is stuck; after an operator top-up this clears once the keeper recovers") + + " (the internal B2B runbook §2.7)"; if (ageMs >= Number(triggerDelay) * 1000) logger.error(message); else logger.warn(message); } diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 040444f1a..5a0d642c0 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -596,7 +596,10 @@ read-only — no keys, no transactions: (or holding USDC), a `recovering` deposit with no pending or confirmed `recover` more than `RECOVERY_DELAY` plus an hour after it was marked warns REFUND OVERDUE (an error past `TRIGGER_DELAY`): younger forwards keep re-timing its marker, or another refund of the - client is stuck. + client is stuck. Once its `recover` attempts have failed, the line names the latest + error and asks to reconcile the clone's balances against the ledger before any refund, + never a hand refund: a `recover` that keeps reverting may mean the funds left by an + unrecorded path. 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized fee-policy changes (timelocked) are reconciled into the DB with a version bump; a destination change (no setter exists), bytecode or registration drift is a diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index cb6f204fd..9307ad38c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. +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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. @@ -128,7 +128,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` - [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, only warns "reconcile" when funds left by an unrecorded path, and holds the top-up while another marked payment on the clone needs that reconcile or another refund of the client is open (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…", "holds the top-up while another marked payment on the clone needs reconciling", "holds the top-up while another refund of the client is open…") - [ ] RECOVER STUCK PENDING warns for a `recover` execution still pending one hour after its creation and errors after four, so a pending `recover` the keeper cannot resolve never keeps the account's refund alerts silent (`monitoring.test.ts`: "warns on a recover still pending 2h after it was sent", "errors on a recover still pending 5h after it was sent") -- [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") +- [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, asks to reconcile with the latest error once its `recover` attempts failed, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") - [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; guardian fee-policy changes (R07/P11) are warn-level reconciliations, a destination change is an incident - [ ] Admin deposit endpoints (`POST .../deposits/:id/recover`, `PATCH .../deposits/:id/status`) only ever apply forward-only transitions under the forwarder lock; `POST .../recover` refuses a deposit with a pending execution, and `PATCH .../status` closes a parked refund from `recovery_failed` as `refunded` (`moneriumB2b.controller.test.ts`) - [ ] `recovery.test.ts` proves: the phase walk, the balance-derived crash recovery (no second swap or top-up), the memo adoption instead of a second redeem order, the float-underfunded wait, the supporting-document and missing-payer parking, the five-attempt failure (also across orchestrator cycles), a refused or failed deposit transition keeping the recovery open (no `redeemed` before `refunded`, no operator-retry reset after a refused park), a parked refund closed by hand from `recovery_failed` ending `redeemed` without a redeem order, a rejected order re-parking on a retry and never reported after the hand close, one recovery at a time per client (`activeRecoveryExists(accountId)`, also wired through the keeper) with other clients' refunds running side by side, at most one float-wallet send per cycle (top-up, reverse swap or surplus sweep), the parked float gate and the cycle time budget and the operator retry resuming from the preserved phase From da632df8f4e3801cc910e1dffcc4727a727d49a5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:31:58 +0200 Subject: [PATCH 69/80] fix(api): report sub-floor payments past their window in off mode With MONERIUM_B2B_AUTO_RECOVERY=off the deadline pass neither marks nor reports anything, and a clone below MIN_SWAP_FLOOR arms no marker, so the stranded check never fires and REFUND NEEDS OPERATOR needs a marked deposit: a payment below the floor reached nobody. The stranded monitor now warns for such a payment past its window and says to mark it, then follow REFUND NEEDS OPERATOR. --- .../services/monerium-b2b/monitoring.test.ts | 42 +++++++++++++++++++ .../api/services/monerium-b2b/monitoring.ts | 30 ++++++++++++- docs/architecture-monerium-b2b-onramp.md | 5 ++- .../05-integrations/monerium-b2b.md | 3 +- 4 files changed, 76 insertions(+), 4 deletions(-) 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 9463fe76c..5e8873aa9 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -689,6 +689,48 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(errors).toEqual([]); }); + // With auto recovery off nothing marks or reports a payment past its window, and below the + // floor no batch arms, so the marker-age alert never fires for it either. + it("reports a payment past its window below the floor when auto recovery is off", async () => { + config.moneriumB2b.autoRecovery = "off"; + const late = new Date(Date.now() - config.moneriumB2b.recoveryDeadlineMinutes * 60_000 - 60_000); + const due = await accountWithDeposit(MoneriumFiatDepositStatus.Minted); + await due.update({ mintedAt: late }); + const fresh = await addDeposit(due.accountId, MoneriumFiatDepositStatus.Minted, EUR / 4n); + const orphan = await MoneriumFiatDeposit.create({ + accountId: due.accountId, + amountRaw: "1", + blockNumber: 101, + chainId: 11155111, + currency: "eur", + logIndex: 99, + mintedAt: late, + moneriumOrderId: "unattr:orphan", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xorphan" + }); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("REFUND DUE BELOW FLOOR"); + expect(warnings[0]).toContain(due.id); + expect(warnings[0]).not.toContain(fresh.id); + expect(warnings[0]).not.toContain(orphan.id); + expect(warnings[0]).toContain( + "mark each via POST /v1/admin/monerium-b2b/deposits/:id/recover, then follow REFUND NEEDS OPERATOR" + ); + }); + + for (const mode of ["auto", "alert"] as const) { + it(`leaves a payment past its window to the deadline pass with auto recovery ${mode}`, async () => { + config.moneriumB2b.autoRecovery = mode; + const due = await accountWithDeposit(MoneriumFiatDepositStatus.Minted); + await due.update({ mintedAt: new Date(Date.now() - config.moneriumB2b.recoveryDeadlineMinutes * 60_000 - 60_000) }); + await runStrandedBalanceMonitor(); + expect([...errors, ...warnings]).toEqual([]); + }); + } + // A migration can leave clones of two factories live: each clone's own floor and delays // apply, and a clone off the trusted factory, which the keeper refuses, gets no advice. describe("on clones of different factories", () => { diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index f295a9093..aa70933d8 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -26,8 +26,9 @@ import { SubsidyVaultState } from "./chain"; import { settlementState } from "./conversion-executor"; +import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; -import { activeRecoveryExists } from "./recovery"; +import { activeRecoveryExists, isPastDeadline } from "./recovery"; import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; import { refundAccountFor } from "./refund-wallet"; @@ -48,7 +49,8 @@ import { refundAccountFor } from "./refund-wallet"; * An unswapped payment marked for recovery whose EURe is still on a clone below * MIN_SWAP_FLOOR errors too: the contract cannot recover it until the operator tops the * clone up to the floor (one whose funds already left the clone only warns: reconcile; the - * others' top-up waits for that, and for the client's open refund). + * others' top-up waits for that, and for the client's open refund). With auto recovery off, + * a payment there past its window that nobody marked warns, since nothing else reports it. * A marked deposit left unrecovered RECOVERY_DELAY plus an hour after marking warns * (REFUND OVERDUE), and errors past TRIGGER_DELAY. * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault @@ -441,6 +443,30 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi const marked = await MoneriumFiatDeposit.count({ where: { accountId: account.id, status: MoneriumFiatDepositStatus.Recovering } }); + if (marked === 0 && config.moneriumB2b.autoRecovery === "off") { + // In `off` mode nothing marks or reports a payment past its window, and no batch arms + // below the floor, so the marker-age alert below never fires for it either. + const deadlineMs = config.moneriumB2b.recoveryDeadlineMinutes * 60_000; + const settling = await MoneriumFiatDeposit.findAll({ + attributes: ["createdAt", "id", "mintedAt"], + where: { + accountId: account.id, + blockNumber: { [Op.ne]: null }, + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + status: { [Op.in]: [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Converting] } + } + }); + const due = settling.filter(deposit => isPastDeadline(deposit, deadlineMs, now)); + if (due.length > 0) { + logger.warn( + `monerium-b2b: REFUND DUE BELOW FLOOR — deposit(s) ${due.map(deposit => deposit.id).join(", ")} on forwarder ` + + `${forwarder} (account ${account.id}) are past the ${config.moneriumB2b.recoveryDeadlineMinutes} min refund ` + + `window on a clone holding ${formatUnits(eureBalance, 18)} EURe, below MIN_SWAP_FLOOR, and ` + + "MONERIUM_B2B_AUTO_RECOVERY is off, so nothing marks them: mark each via POST " + + "/v1/admin/monerium-b2b/deposits/:id/recover, then follow REFUND NEEDS OPERATOR (the internal B2B runbook §2.7)" + ); + } + } if (marked === 0) continue; // Every deposit whose EURe the ledger may still place on the clone: settling, marked, or // refunded (a manual refund leaves its EURe there; a confirmed `recover` took it away). diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 5a0d642c0..309bea057 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -599,7 +599,10 @@ read-only — no keys, no transactions: client is stuck. Once its `recover` attempts have failed, the line names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an - unrecorded path. + unrecorded path. With `MONERIUM_B2B_AUTO_RECOVERY=off` nothing marks or reports a + payment past its window, and below the floor no marker arms, so on a clone below the + floor with nothing marked a provider-attributed payment past the window warns REFUND + DUE BELOW FLOOR: mark it for recovery, then follow REFUND NEEDS OPERATOR. 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized fee-policy changes (timelocked) are reconciled into the DB with a version bump; a destination change (no setter exists), bytecode or registration drift is a diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 9307ad38c..92ccdcdc9 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. +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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. With `MONERIUM_B2B_AUTO_RECOVERY=off` the deadline pass neither marks nor reports anything and a clone below the floor arms no marker, so a sub-floor payment would reach nobody: on a clone below the floor with no USDC and nothing marked, every chain-indexed, provider-attributed `minted` or `converting` deposit past `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (`isPastDeadline`) raises the REFUND DUE BELOW FLOOR warning, which says to mark it for recovery and then follow REFUND NEEDS OPERATOR (in `alert` and `auto` the deadline pass reports or marks it instead). A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. @@ -127,6 +127,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] The admin registration list filters by status and withdraws only a `requested` registration (409 otherwise, 404 when unknown), and `onboarding → suspended` is accepted and logged (`moneriumB2b.controller.test.ts`) - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` - [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, only warns "reconcile" when funds left by an unrecorded path, and holds the top-up while another marked payment on the clone needs that reconcile or another refund of the client is open (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…", "holds the top-up while another marked payment on the clone needs reconciling", "holds the top-up while another refund of the client is open…") +- [ ] With `MONERIUM_B2B_AUTO_RECOVERY=off`, REFUND DUE BELOW FLOOR warns for a provider-attributed payment past the refund window on a clone below the floor with nothing marked, and stays out of `alert` and `auto` (`monitoring.test.ts`: "reports a payment past its window below the floor when auto recovery is off", "leaves a payment past its window to the deadline pass…") - [ ] RECOVER STUCK PENDING warns for a `recover` execution still pending one hour after its creation and errors after four, so a pending `recover` the keeper cannot resolve never keeps the account's refund alerts silent (`monitoring.test.ts`: "warns on a recover still pending 2h after it was sent", "errors on a recover still pending 5h after it was sent") - [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, asks to reconcile with the latest error once its `recover` attempts failed, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") - [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; guardian fee-policy changes (R07/P11) are warn-level reconciliations, a destination change is an incident From 8440a6494a737b050af11bbf8eb16e3837b4aaa3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:32:16 +0200 Subject: [PATCH 70/80] fix(api): cite the internal B2B runbook in the depth alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other alert and doc names "the internal B2B runbook §x"; the DEPTH BELOW FLOOR line alone said "the B2B operations runbook", so a search for the runbook reference missed it. --- apps/api/src/api/services/monerium-b2b/monitoring.test.ts | 2 +- apps/api/src/api/services/monerium-b2b/monitoring.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 5e8873aa9..18b28f2e2 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -314,7 +314,7 @@ describe("runExecutableDepthCheck", () => { expect(lines[level][0]).toContain("DEPTH BELOW FLOOR"); expect(lines[other]).toEqual([]); if (level === "warn") expect(lines.warn[0]).toContain("re-centre the sandbox pool"); - else expect(lines.error[0]).toContain("triage per the B2B operations runbook §3"); + else expect(lines.error[0]).toContain("triage per the internal B2B runbook §3"); }); } diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index aa70933d8..128ad3e47 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -362,7 +362,7 @@ export async function runExecutableDepthCheck(): Promise { const verdict = classifyExecutableDepth(best.minImpactBps, best.capImpactBps, slippageBps); if (verdict.severity === "error" && chainId === 1) { - logger.error(`monerium-b2b: DEPTH BELOW FLOOR — ${verdict.reason}; triage per the B2B operations runbook §3. ${detail}`); + logger.error(`monerium-b2b: DEPTH BELOW FLOOR — ${verdict.reason}; triage per the internal B2B runbook §3. ${detail}`); } else if (verdict.severity === "error") { // Off mainnet a thin pool drifting from the oracle is a calibration signal, not an incident. logger.warn( From ad823f821f209df1b4ced4875410911bd6a6b440 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:32:31 +0200 Subject: [PATCH 71/80] docs(api): scope the depth alert's error level to mainnet in the spec Monitoring invariant 2 first called DEPTH BELOW FLOOR an error-level line outright and only qualified it at the end; the code and the architecture doc log it as a warning off mainnet, so the invariant now says so where it introduces the line. --- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 92ccdcdc9..d4608a0f0 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. On the sandbox the DEPTH BELOW FLOOR line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. +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 DEPTH BELOW FLOOR line, error-level on mainnet and a warning off mainnet (below), 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 line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. 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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. With `MONERIUM_B2B_AUTO_RECOVERY=off` the deadline pass neither marks nor reports anything and a clone below the floor arms no marker, so a sub-floor payment would reach nobody: on a clone below the floor with no USDC and nothing marked, every chain-indexed, provider-attributed `minted` or `converting` deposit past `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (`isPastDeadline`) raises the REFUND DUE BELOW FLOOR warning, which says to mark it for recovery and then follow REFUND NEEDS OPERATOR (in `alert` and `auto` the deadline pass reports or marks it instead). A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. 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 b4b1977b30f6cb28416b902def9b9772e119bf1b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:33:58 +0200 Subject: [PATCH 72/80] test(api): give each fee-apply test its own forwarder The keeper remembers the last fee apply and the logged skip reason per forwarder in module memory, and every fee-apply test used the same forwarder, so an earlier test's apply or skip carried into the next: a second test of the same skip saw it already logged and failed. A fresh forwarder per test keeps them independent without a test-only export. --- .../monerium-b2b/conversion-executor.test.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) 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 43f1b00ae..eea10a6f6 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 @@ -125,7 +125,7 @@ function swapRow(eureInRaw: bigint, usdcNetRaw: bigint): MoneriumConversionExecu } as unknown as MoneriumConversionExecution; } -async function activeAccountWithDeposit() { +async function activeAccountWithDeposit(forwarderAddress = "0x1111111111111111111111111111111111111111") { const manager = await createTestUser(); await ManagedProfileManager.create({ allowedCorridors: ["EU"], @@ -137,7 +137,7 @@ async function activeAccountWithDeposit() { contactEmail: "ops@client.example.com", destination: "0x5555555555555555555555555555555555555555", externalSubjectId: "client-1", - forwarderAddress: "0x1111111111111111111111111111111111111111", + forwarderAddress, managerProfileId: manager.id, moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" }); @@ -1650,6 +1650,8 @@ describe("runConversionExecutor fee-policy apply", () => { let simulateError: Error | null; /** This test's apply hashes (unique per write: the keeper remembers applies across cycles). */ let sentHashes: Set; + /** This test's own clone: the keeper remembers applies and logged skips per forwarder for the process lifetime. */ + let forwarder: string; beforeAll(async () => { config.moneriumB2b.rpcUrl = undefined; @@ -1672,6 +1674,7 @@ describe("runConversionExecutor fee-policy apply", () => { receiptStatus = "success"; simulateError = null; sentHashes = new Set(); + forwarder = `0x${crypto.randomUUID().replaceAll("-", "")}${"0".repeat(8)}`; reads = { batchOpenedAt: 1n, floorPpm: 1_500, @@ -1727,7 +1730,7 @@ describe("runConversionExecutor fee-policy apply", () => { it("applies a due increase once, before the cycle prices its swap", async () => { reads.pendingFeePolicyEffectiveAt = NOW; - const { accountId, deposit } = await activeAccountWithDeposit(); + const { accountId, deposit } = await activeAccountWithDeposit(forwarder); await runConversionExecutor(accountId); @@ -1740,7 +1743,7 @@ describe("runConversionExecutor fee-policy apply", () => { receiptTimesOut = true; privateTransport = true; const info = spyOn(logger, "info"); - const { accountId } = await activeAccountWithDeposit(); + const { accountId } = await activeAccountWithDeposit(forwarder); await runConversionExecutor(accountId); // sent, receipt wait times out await runConversionExecutor(accountId); // public latest == pending: only the remembered apply holds it @@ -1760,7 +1763,7 @@ describe("runConversionExecutor fee-policy apply", () => { reads.pendingFeePolicyEffectiveAt = NOW; nonces = { latest: 3, pending: 4 }; // e.g. another account's swap const info = spyOn(logger, "info"); - const { accountId } = await activeAccountWithDeposit(); + const { accountId } = await activeAccountWithDeposit(forwarder); await runConversionExecutor(accountId); await runConversionExecutor(accountId); @@ -1773,7 +1776,7 @@ describe("runConversionExecutor fee-policy apply", () => { reads.pendingFeePolicyEffectiveAt = NOW; simulateError = new Error("NoPendingFeePolicy()"); const warn = spyOn(logger, "warn"); - const { accountId, deposit } = await activeAccountWithDeposit(); + const { accountId, deposit } = await activeAccountWithDeposit(forwarder); await runConversionExecutor(accountId); @@ -1787,7 +1790,7 @@ describe("runConversionExecutor fee-policy apply", () => { receiptStatus = "reverted"; const info = spyOn(logger, "info"); const warn = spyOn(logger, "warn"); - const { accountId } = await activeAccountWithDeposit(); + const { accountId } = await activeAccountWithDeposit(forwarder); await runConversionExecutor(accountId); @@ -1797,7 +1800,7 @@ describe("runConversionExecutor fee-policy apply", () => { }); it("sends nothing while no increase is pending or its timelock still runs", async () => { - const { accountId } = await activeAccountWithDeposit(); + const { accountId } = await activeAccountWithDeposit(forwarder); await runConversionExecutor(accountId); reads.pendingFeePolicyEffectiveAt = NOW + 1n; await runConversionExecutor(accountId); @@ -1809,7 +1812,7 @@ describe("runConversionExecutor fee-policy apply", () => { get: () => Promise.reject(new Error("execution reverted")) }); const warn = spyOn(logger, "warn"); - const { accountId, deposit } = await activeAccountWithDeposit(); + const { accountId, deposit } = await activeAccountWithDeposit(forwarder); await runConversionExecutor(accountId); @@ -1822,7 +1825,7 @@ describe("runConversionExecutor fee-policy apply", () => { reads.pendingFeePolicyEffectiveAt = NOW - 60n; writeError = new Error("NoPendingFeePolicy()"); const warn = spyOn(logger, "warn"); - const { accountId, deposit } = await activeAccountWithDeposit(); + const { accountId, deposit } = await activeAccountWithDeposit(forwarder); await runConversionExecutor(accountId); From 853bf7132256ae3fbd3e2493cdb692511168a99a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:35:06 +0200 Subject: [PATCH 73/80] test(api): pin the keeper side of the sub-floor top-up refund Every sub-floor refund now leaves an unattr: top-up row on the clone, and only the settlingDeposits filter keeps the keeper from flagging, swapping and forwarding that Vortex EURe; no executor test covered it. The tests pin the filter, the poke once the top-up lifts the clone to the floor, and the recover of exactly the payer's EURe. --- .../monerium-b2b/conversion-executor.test.ts | 74 ++++++++++++++++++- 1 file changed, 70 insertions(+), 4 deletions(-) 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 eea10a6f6..a5386207e 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 @@ -1122,14 +1122,20 @@ describe("runConversionExecutor batch marker", () => { beforeEach(() => resetTestDatabase()); afterEach(() => mock.restore()); - /** A funded clone with a ready-priced 100 EURe swap; returns every keeper write as `fn:nonce`. */ - function arrange(options: { batchOpenedAt: bigint; failPokeWrite?: boolean; swapReverts?: boolean }) { + /** A funded clone (100 EURe unless `eureOnClone`) with a ready-priced swap; returns every keeper write as `fn:nonce`. */ + function arrange(options: { + batchOpenedAt: bigint; + eureOnClone?: bigint; + failPokeWrite?: boolean; + minSwapFloor?: bigint; + swapReverts?: boolean; + }) { const writes: string[] = []; const reads: Record = { batchOpenedAt: options.batchOpenedAt, floorPpm: 1_500, latestRoundData: [1n, 114_000_000n, 0n, 0n, 1n], - MIN_SWAP_FLOOR: 1n, + MIN_SWAP_FLOOR: options.minSwapFloor ?? 1n, minSwapAmount: 25n * EUR, pendingFeePolicyEffectiveAt: 0n, perSwapCap: 10_000n * EUR, @@ -1157,7 +1163,7 @@ describe("runConversionExecutor batch marker", () => { getBlockNumber: async () => 100n, getTransactionCount: async () => 7, readContract: async ({ address, functionName }: { address: Address; functionName: string }) => - functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName], + functionName === "balanceOf" ? (address === EURE ? (options.eureOnClone ?? 100n * EUR) : 0n) : reads[functionName], simulateContract: async ({ functionName }: { functionName: string }) => { if (functionName === "swap" && options.swapReverts) throw new Error("execution reverted"); return {}; @@ -1331,6 +1337,66 @@ describe("runConversionExecutor batch marker", () => { expect(writes).toEqual(["poke:7", "swap:8"]); expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Pending]); }); + + // The keeper's side of a refund below the swap floor (REFUND NEEDS OPERATOR): the operator's + // top-up is an `unattr:` row it never converts; it only lifts the clone to the floor. + async function toppedUpRefund() { + const { accountId, deposit } = await activeAccountWithDeposit(); + await deposit.update({ + amountRaw: (EUR / 2n).toString(), + refundStartedAt: new Date(), + status: MoneriumFiatDepositStatus.Recovering + }); + const topUp = await MoneriumFiatDeposit.create({ + accountId, + amountRaw: (EUR / 2n + 1n).toString(), + blockNumber: 101, + chainId: 11155111, + currency: "eur", + logIndex: 1, + mintedAt: new Date(), + moneriumOrderId: "unattr:topup", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xtopup" + }); + return { accountId, deposit, topUp }; + } + + it("pokes a clone a top-up lifted to the floor, and never converts or flags the top-up", async () => { + const writes = arrange({ batchOpenedAt: 0n, eureOnClone: EUR + 1n, minSwapFloor: EUR }); + const { accountId, topUp } = await toppedUpRefund(); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["poke:auto"]); + expect(await executions(accountId)).toHaveLength(0); + expect((await topUp.reload()).waitingReason).toBeNull(); + }); + + it("then recovers exactly the payer's EURe, leaving the top-up on the clone", async () => { + const eligible = BigInt(Math.floor(Date.now() / 1000) - 7_200 - 120); + const writes = arrange({ batchOpenedAt: eligible, eureOnClone: EUR + 1n, minSwapFloor: EUR }); + const { accountId, deposit } = await toppedUpRefund(); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["recover:7"]); + const rows = await executions(accountId); + expect(rows.map(row => [row.kind, row.depositId, row.eureInRaw, row.usdcNetRaw])).toEqual([ + [MoneriumConversionExecutionKind.Recover, deposit.id, (EUR / 2n).toString(), "0"] + ]); + }); + + it("never swaps an unattributed row, even one above the minimum swap", async () => { + const writes = arrange({ batchOpenedAt: 1n }); + const { accountId, deposit } = await activeAccountWithDeposit(); + await deposit.update({ moneriumOrderId: "unattr:orphan" }); // 100 EURe, the clone's whole balance + + await runConversionExecutor(accountId); + + expect(writes).toEqual([]); + expect(await executions(accountId)).toHaveLength(0); + }); }); // A reservation whose broadcast never happened (the process died between `reserve` and From 35440c75a5bbaa6df032e9d0daf67bcdf18d9e95 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:35:48 +0200 Subject: [PATCH 74/80] docs(api): list every kind of keeper candidate in the keeper docs Keeper invariant 5 said only provider-attributed deposits make an account a conversion candidate while its own parenthetical named the unattr: cycle and pending executions, and invariant 7 and the fee section still said the executor only runs for accounts with a settling deposit. Both predate the candidate change; they now name all three. --- docs/architecture-monerium-b2b-onramp.md | 6 ++++-- docs/security-spec/05-integrations/monerium-b2b.md | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 309bea057..c5a289ee3 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -496,8 +496,10 @@ contract settles every fill into three bands against that reference (decisions: memory, since a private relay's pending transactions are invisible to the public nonce counts; a restart or another keeper process does not know it and may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped), and no apply is sent while the public pool holds any keeper transaction, of - any account; each skip reason is logged once. The executor only runs for accounts - with a settling deposit, so an idle clone's increase is applied before its next swap. + any account; each skip reason is logged once. The executor runs only for conversion + candidates (a settling deposit, a transfer to the clone indexed that cycle, or a + pending execution; see the deposit-to-payout sequence), so an idle clone's increase is + applied before its next swap. - **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded from the treasury. It pays only when called by a factory-registered clone, to the clone itself (the subsidy is forwarded with the payment), within a guardian-settable diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index d4608a0f0..d3ea14dad 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -39,10 +39,10 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. Known limit: the keeper's nonce reads use the public read RPC (the value path's explicit nonce, the re-send's nothing-pending check), which cannot see a keeper transaction still unmined in the private relay (a `poke` or `applyFeePolicy` sent with an implicit nonce), so a later send can take the same nonce; only one transaction per nonce is mined, and a losing value-moving row fails closed through the exact-match recovery (invariant 3) and retries after the backoff, so the cost is delay, never a double send. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers; the gap is not re-poked, because the re-sent `swap` or `forward` arms an unarmed marker itself and, after a no-op, the next cycle pokes, so a dropped poke delays the recovery clock by about the five-minute idle deadline) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. The retry backoff (1 min, doubling per failed row since the account's last confirmed execution, at most 60 min) holds a swap or forward back on failures of any kind, but a planned `recover` only on failed recovers, so failing swaps never delay a refund past its on-chain eligibility. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. -5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; the trade-off is ordering: a younger deposit forwards before the older passed-over one is refunded, and each `forward` resyncs the batch marker (`_syncBatch(true)`); on a clone left with less than `MIN_SWAP_FLOOR` EURe and no USDC the marker switches off or never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; while the clone stays funded (a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, or a chunk-swapped remainder whose USDC is still on the clone) each younger `forward` re-times the marker, so the passed-over deposit is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed, provider-attributed deposits make an account a conversion candidate (an `unattr:` row only for the cycle its mint lands in; an account with a pending execution stays one until the executor resolves it; the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path with `waitingReason` `below_minimum` and is passed over, so it never holds back a younger deposit; the trade-off is ordering: a younger deposit forwards before the older passed-over one is refunded, and each `forward` resyncs the batch marker (`_syncBatch(true)`); on a clone left with less than `MIN_SWAP_FLOOR` EURe and no USDC the marker switches off or never arms, so such a payment is refunded only after the operator's top-up (Monitoring invariant 3), later than the promised window; while the clone stays funded (a passed-over remainder at or above `MIN_SWAP_FLOOR`, possible only with `minSwapAmount` above the floor, or a chunk-swapped remainder whose USDC is still on the clone) each younger `forward` re-times the marker, so the passed-over deposit is recover-eligible only after a forward-free `RECOVERY_DELAY`, and Monitoring invariant 3 reports it as REFUND OVERDUE), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. An account is a conversion candidate, the only kind the executor runs for, while it has a chain-indexed, provider-attributed deposit still settling, in the cycle an EURe transfer to its clone is indexed (an `unattr:` row too, for that cycle only), and while it has a pending execution, until the executor resolves it (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`); a raw forwarder balance alone never makes it one and never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction, which the public nonce counts never show: invariant 2), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply, and another keeper process on the same database never knew it, so either may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a timed-out apply is not re-sent and stacked every cycle: the keeper remembers each forwarder's last apply in process memory and sends nothing while that apply has no receipt and is younger than 15 minutes (longer than a private relay keeps a transaction, which the public nonce counts never show: invariant 2), nor while the public pool holds any keeper transaction, of any account (latest and pending nonce differ), and logs each skip reason once; a restart forgets the remembered apply, and another keeper process on the same database never knew it, so either may send one duplicate, which reverts `NoPendingFeePolicy` or is dropped; a failed read of the pending policy, a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper runs only for conversion candidates (invariant 5), so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. ## Monitoring From 70d00a95a40dd3f91902d74d85f5753c74faefd8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:36:05 +0200 Subject: [PATCH 75/80] docs(api): close the spec's stranded monitor with the off-mode report The off-mode report sat between REFUND OVERDUE and the sub-floor reconcile rule, so "a swapped deposit... only warns" read as if it described the off-mode warning. --- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index d3ea14dad..828d32e4d 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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 DEPTH BELOW FLOOR line, error-level on mainnet and a warning off mainnet (below), 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 line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. With `MONERIUM_B2B_AUTO_RECOVERY=off` the deadline pass neither marks nor reports anything and a clone below the floor arms no marker, so a sub-floor payment would reach nobody: on a clone below the floor with no USDC and nothing marked, every chain-indexed, provider-attributed `minted` or `converting` deposit past `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (`isPastDeadline`) raises the REFUND DUE BELOW FLOOR warning, which says to mark it for recovery and then follow REFUND NEEDS OPERATOR (in `alert` and `auto` the deadline pass reports or marks it instead). A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. +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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. With `MONERIUM_B2B_AUTO_RECOVERY=off` the deadline pass neither marks nor reports anything and a clone below the floor arms no marker, so a sub-floor payment would reach nobody: on a clone below the floor with no USDC and nothing marked, every chain-indexed, provider-attributed `minted` or `converting` deposit past `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (`isPastDeadline`) raises the REFUND DUE BELOW FLOOR warning, which says to mark it for recovery and then follow REFUND NEEDS OPERATOR (in `alert` and `auto` the deadline pass reports or marks it instead). 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. From 52e82b2c3f7571ad42acb40df08940ce04f645c9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 12:36:58 +0200 Subject: [PATCH 76/80] fix(api): point the sub-floor wait lines to REFUND NEEDS OPERATOR The two wait lines said "this alert then returns with the top-up amount", but they are not the alert that returns; naming REFUND NEEDS OPERATOR tells the operator which line carries the amount to send. --- apps/api/src/api/services/monerium-b2b/monitoring.test.ts | 3 ++- apps/api/src/api/services/monerium-b2b/monitoring.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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 18b28f2e2..d1b4cfee1 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -631,6 +631,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(warnings).toHaveLength(1); expect(warnings[0]).toContain(deposit.id); expect(warnings[0]).toContain("another refund of this client is still open"); + expect(warnings[0]).toContain("do not top up the clone before that; REFUND NEEDS OPERATOR then returns"); expect(warnings[0]).not.toContain("send exactly"); await refund.update({ phase: MoneriumRecoveryPhase.Redeemed }); @@ -654,7 +655,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { const wait = warnings.filter(message => message.includes(unswapped.id)); expect(wait).toHaveLength(1); expect(wait[0]).toContain(`another marked payment on this clone (${swapped.id}) needs reconciling first`); - expect(wait[0]).toContain("do not top up the clone until it is reconciled"); + expect(wait[0]).toContain("do not top up the clone before it is reconciled; REFUND NEEDS OPERATOR then returns"); expect(wait[0]).not.toContain("left the clone"); const reconcile = warnings.filter(message => message.includes("part or all of their funds left the clone")); expect(reconcile).toHaveLength(1); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 128ad3e47..c30d50e43 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -530,7 +530,7 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi `(account ${account.id}) are marked for recovery below MIN_SWAP_FLOOR but wait: another marked payment on ` + `this clone (${elsewhere.map(deposit => deposit.id).join(", ")}) needs reconciling first, and a top-up would ` + "arm the marker, so the keeper could retry that payment's recover ahead of these; do not top up the clone " + - "until it is reconciled (this alert then returns with the top-up amount; the internal B2B runbook §2.7)" + "before it is reconciled; REFUND NEEDS OPERATOR then returns with the amount (the internal B2B runbook §2.7)" ); } else if (onClone.length > 0 && (await activeRecoveryExists(account.id))) { // The keeper sends no second `recover` meanwhile (its own gate), so a top-up now would @@ -539,7 +539,7 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi `monerium-b2b: deposit(s) ${onClone.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + `(account ${account.id}) are marked for recovery below MIN_SWAP_FLOOR but wait: another refund of this ` + "client is still open, and the keeper sends no second recover until it is redeemed; do not top up the " + - "clone until then (this alert then returns with the top-up amount; the internal B2B runbook §2.7)" + "clone before that; REFUND NEEDS OPERATOR then returns with the amount (the internal B2B runbook §2.7)" ); } else if (onClone.length > 0) { // A top-up to just above the floor lets `poke` arm the marker, and the keeper's From 4c397cf133101a3c9b7c222af3ae8a88875d2e9e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 13:11:57 +0200 Subject: [PATCH 77/80] fix(api): report sub-floor payments past their window beside marked ones In off mode a marked sibling (e.g. one whose recover is confirmed and waits for the manual redeem) silenced the report for every other payment past its window on the clone, and nothing else reports them. The report names only minted or converting rows and the other sub-floor lines only marked ones, so they never name the same deposit. --- .../api/services/monerium-b2b/monitoring.test.ts | 15 +++++++++++++++ .../src/api/services/monerium-b2b/monitoring.ts | 8 +++++--- docs/architecture-monerium-b2b-onramp.md | 5 +++-- .../security-spec/05-integrations/monerium-b2b.md | 4 ++-- 4 files changed, 25 insertions(+), 7 deletions(-) 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 d1b4cfee1..4ea6ed1b8 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -722,6 +722,21 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { ); }); + it("reports a payment past its window below the floor beside a marked one when auto recovery is off", async () => { + config.moneriumB2b.autoRecovery = "off"; + // D1's recover took its EURe off the clone; its manual redeem is still open. + const recovering = await accountWithDeposit(MoneriumFiatDepositStatus.Recovering, 2n * EUR); + await execution(recovering, MoneriumConversionExecutionKind.Recover, MoneriumConversionExecutionStatus.Confirmed); + const due = await addDeposit(recovering.accountId, MoneriumFiatDepositStatus.Minted, EUR / 2n); + await due.update({ mintedAt: new Date(Date.now() - config.moneriumB2b.recoveryDeadlineMinutes * 60_000 - 60_000) }); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("REFUND DUE BELOW FLOOR"); + expect(warnings[0]).toContain(due.id); + expect(warnings[0]).not.toContain(recovering.id); + }); + for (const mode of ["auto", "alert"] as const) { it(`leaves a payment past its window to the deadline pass with auto recovery ${mode}`, async () => { config.moneriumB2b.autoRecovery = mode; diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index c30d50e43..651ec053e 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -50,7 +50,8 @@ import { refundAccountFor } from "./refund-wallet"; * MIN_SWAP_FLOOR errors too: the contract cannot recover it until the operator tops the * clone up to the floor (one whose funds already left the clone only warns: reconcile; the * others' top-up waits for that, and for the client's open refund). With auto recovery off, - * a payment there past its window that nobody marked warns, since nothing else reports it. + * every unmarked payment there past its window warns, marked siblings or not, since nothing + * else reports it. * A marked deposit left unrecovered RECOVERY_DELAY plus an hour after marking warns * (REFUND OVERDUE), and errors past TRIGGER_DELAY. * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault @@ -443,9 +444,10 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi const marked = await MoneriumFiatDeposit.count({ where: { accountId: account.id, status: MoneriumFiatDepositStatus.Recovering } }); - if (marked === 0 && config.moneriumB2b.autoRecovery === "off") { + if (config.moneriumB2b.autoRecovery === "off") { // In `off` mode nothing marks or reports a payment past its window, and no batch arms - // below the floor, so the marker-age alert below never fires for it either. + // below the floor, so the marker-age alert below never fires for it either. Marked + // siblings change nothing: the lines below name only marked deposits, never these. const deadlineMs = config.moneriumB2b.recoveryDeadlineMinutes * 60_000; const settling = await MoneriumFiatDeposit.findAll({ attributes: ["createdAt", "id", "mintedAt"], diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index c5a289ee3..1d48c0f78 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -603,8 +603,9 @@ read-only — no keys, no transactions: never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path. With `MONERIUM_B2B_AUTO_RECOVERY=off` nothing marks or reports a payment past its window, and below the floor no marker arms, so on a clone below the - floor with nothing marked a provider-attributed payment past the window warns REFUND - DUE BELOW FLOOR: mark it for recovery, then follow REFUND NEEDS OPERATOR. + floor every unmarked, provider-attributed payment past the window warns REFUND DUE + BELOW FLOOR, marked siblings or not: mark it for recovery, then follow REFUND NEEDS + OPERATOR. 4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized fee-policy changes (timelocked) are reconciled into the DB with a version bump; a destination change (no setter exists), bytecode or registration drift is a diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 828d32e4d..91f1849ce 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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 DEPTH BELOW FLOOR line, error-level on mainnet and a warning off mainnet (below), 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 line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. With `MONERIUM_B2B_AUTO_RECOVERY=off` the deadline pass neither marks nor reports anything and a clone below the floor arms no marker, so a sub-floor payment would reach nobody: on a clone below the floor with no USDC and nothing marked, every chain-indexed, provider-attributed `minted` or `converting` deposit past `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (`isPastDeadline`) raises the REFUND DUE BELOW FLOOR warning, which says to mark it for recovery and then follow REFUND NEEDS OPERATOR (in `alert` and `auto` the deadline pass reports or marks it instead). +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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. With `MONERIUM_B2B_AUTO_RECOVERY=off` the deadline pass neither marks nor reports anything and a clone below the floor arms no marker, so a sub-floor payment would reach nobody: on a clone below the floor with no USDC, every chain-indexed, provider-attributed `minted` or `converting` deposit past `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (`isPastDeadline`) raises the REFUND DUE BELOW FLOOR warning, also while other deposits of the clone are marked (the lines above name only marked deposits, so none is named twice), which says to mark it for recovery and then follow REFUND NEEDS OPERATOR (in `alert` and `auto` the deadline pass reports or marks it instead). 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. @@ -127,7 +127,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] The admin registration list filters by status and withdraws only a `requested` registration (409 otherwise, 404 when unknown), and `onboarding → suspended` is accepted and logged (`moneriumB2b.controller.test.ts`) - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` - [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, only warns "reconcile" when funds left by an unrecorded path, and holds the top-up while another marked payment on the clone needs that reconcile or another refund of the client is open (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…", "holds the top-up while another marked payment on the clone needs reconciling", "holds the top-up while another refund of the client is open…") -- [ ] With `MONERIUM_B2B_AUTO_RECOVERY=off`, REFUND DUE BELOW FLOOR warns for a provider-attributed payment past the refund window on a clone below the floor with nothing marked, and stays out of `alert` and `auto` (`monitoring.test.ts`: "reports a payment past its window below the floor when auto recovery is off", "leaves a payment past its window to the deadline pass…") +- [ ] With `MONERIUM_B2B_AUTO_RECOVERY=off`, REFUND DUE BELOW FLOOR warns for a provider-attributed payment past the refund window on a clone below the floor, also beside a marked deposit, and stays out of `alert` and `auto` (`monitoring.test.ts`: "reports a payment past its window below the floor when auto recovery is off", "reports a payment past its window below the floor beside a marked one…", "leaves a payment past its window to the deadline pass…") - [ ] RECOVER STUCK PENDING warns for a `recover` execution still pending one hour after its creation and errors after four, so a pending `recover` the keeper cannot resolve never keeps the account's refund alerts silent (`monitoring.test.ts`: "warns on a recover still pending 2h after it was sent", "errors on a recover still pending 5h after it was sent") - [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, asks to reconcile with the latest error once its `recover` attempts failed, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") - [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; guardian fee-policy changes (R07/P11) are warn-level reconciliations, a destination change is an incident From 41a6afd0ddb5067d4ad2c01af2308571f1bd456a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 13:14:12 +0200 Subject: [PATCH 78/80] fix(api): name the open refund in the sub-floor top-up wait line The operator had to find the refund that holds the top-up back by hand, and in alert and off mode the hold lifts only once that deposit is closed after its manual redeem, not when the redeem went out. --- .../services/monerium-b2b/monitoring.test.ts | 6 ++++-- .../api/services/monerium-b2b/monitoring.ts | 13 +++++++----- .../src/api/services/monerium-b2b/recovery.ts | 21 +++++++++++-------- docs/adr-0005-monerium-b2b-onramp.md | 4 +++- docs/architecture-monerium-b2b-onramp.md | 4 +++- .../05-integrations/monerium-b2b.md | 4 ++-- 6 files changed, 32 insertions(+), 20 deletions(-) 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 4ea6ed1b8..13585fd82 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -630,8 +630,10 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(errors).toEqual([]); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain(deposit.id); - expect(warnings[0]).toContain("another refund of this client is still open"); - expect(warnings[0]).toContain("do not top up the clone before that; REFUND NEEDS OPERATOR then returns"); + expect(warnings[0]).toContain(`another refund of this client (deposit ${earlier.id}) is still open`); + expect(warnings[0]).toContain( + "until it is redeemed and closed; do not top up the clone before that; REFUND NEEDS OPERATOR then returns" + ); expect(warnings[0]).not.toContain("send exactly"); await refund.update({ phase: MoneriumRecoveryPhase.Redeemed }); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 651ec053e..8f1e17353 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -28,7 +28,7 @@ import { import { settlementState } from "./conversion-executor"; import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; -import { activeRecoveryExists, isPastDeadline } from "./recovery"; +import { activeRecoveryDepositIds, isPastDeadline } from "./recovery"; import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; import { refundAccountFor } from "./refund-wallet"; @@ -523,6 +523,7 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi ); const onClone = ledgerEure <= eureBalance ? unswapped : []; const elsewhere = stuck.filter(deposit => !onClone.includes(deposit)); + const openRefunds = onClone.length > 0 ? await activeRecoveryDepositIds(account.id) : []; if (onClone.length > 0 && elsewhere.length > 0) { // A top-up would arm the marker for those payments too, whose `recover` cannot succeed // before they are reconciled (a swapped one's always reverts on this USDC-less clone), @@ -534,14 +535,16 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi "arm the marker, so the keeper could retry that payment's recover ahead of these; do not top up the clone " + "before it is reconciled; REFUND NEEDS OPERATOR then returns with the amount (the internal B2B runbook §2.7)" ); - } else if (onClone.length > 0 && (await activeRecoveryExists(account.id))) { + } else if (openRefunds.length > 0) { // The keeper sends no second `recover` meanwhile (its own gate), so a top-up now would - // only leave the clone armed, past TRIGGER_DELAY open to the permissionless swap. + // only leave the clone armed, past TRIGGER_DELAY open to the permissionless swap. In + // `alert` and `off` the gate opens only once the operator closes the redeemed refund. logger.warn( `monerium-b2b: deposit(s) ${onClone.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + `(account ${account.id}) are marked for recovery below MIN_SWAP_FLOOR but wait: another refund of this ` + - "client is still open, and the keeper sends no second recover until it is redeemed; do not top up the " + - "clone before that; REFUND NEEDS OPERATOR then returns with the amount (the internal B2B runbook §2.7)" + `client (deposit ${openRefunds.join(", ")}) is still open, and the keeper sends no second recover until ` + + "it is redeemed and closed; do not top up the clone before that; REFUND NEEDS OPERATOR then returns with " + + "the amount (the internal B2B runbook §2.7)" ); } else if (onClone.length > 0) { // A top-up to just above the floor lets `poke` arm the marker, and the keeper's diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index e27d0e8b3..8d1937782 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -302,13 +302,13 @@ export async function runRecoveryDeadlines(now: number = Date.now()): Promise { - const rows = await sequelize.query<{ id: string }>( - `SELECT e.id +export async function activeRecoveryDepositIds(accountId: string): Promise { + const rows = await sequelize.query<{ deposit_id: string }>( + `SELECT DISTINCT e.deposit_id FROM monerium_conversion_executions AS e JOIN monerium_fiat_deposits AS d ON d.id = e.deposit_id LEFT JOIN monerium_recoveries AS r ON r.deposit_id = e.deposit_id @@ -316,11 +316,14 @@ export async function activeRecoveryExists(accountId: string): Promise AND e.kind = 'recover' AND e.status IN ('pending', 'confirmed') AND d.status IN ('recovering', 'recovery_failed') - AND (r.id IS NULL OR r.phase <> 'redeemed') - LIMIT 1`, + AND (r.id IS NULL OR r.phase <> 'redeemed')`, { replacements: { accountId }, type: QueryTypes.SELECT } ); - return rows.length > 0; + return rows.map(row => row.deposit_id); +} + +export async function activeRecoveryExists(accountId: string): Promise { + return (await activeRecoveryDepositIds(accountId)).length > 0; } /** Forward-only status change under the forwarder lock: why it was refused, or null once the deposit has `status`. */ diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index aa0a5f33f..e9ccc09cb 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -305,7 +305,9 @@ the promised window. Decisions: payment shows `below_minimum` lands the refund at about the normal deadline, but not while the client's refund queue is busy: while another refund of the client is open the keeper sends no `recover`, so an armed clone could wait into the permissionless - window, and the alert holds the top-up back until that refund is redeemed. + window, and the alert holds the top-up back, naming that refund's deposit, until it + is redeemed and closed (in `alert` or `off` the operator closes it once the manual + redeem went out). ## Final parameters (decided 2026-08-26 unless noted) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 1d48c0f78..a76a68b8b 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -387,7 +387,9 @@ permissionless `poke` and `applyFeePolicy`): client's refund wallet, and the alert points to the manual redeem from that wallet instead. While another refund of the client is open, the keeper sends no `recover` (one refund per client), so the alert holds the top-up back and only warns that the - payment waits; it returns with the amount once that refund is redeemed. Leftover + payment waits, naming that refund's deposit; it returns with the amount once that + refund is redeemed and closed (in `alert` or `off`, once the operator sets that deposit + `refunded`). Leftover top-ups stay on the clone as Vortex EURe, below EUR 1 per clone, so `recover` switches the marker off by itself (no follow-up step), and a later sub-floor payment on that clone that reaches the floor with them arms the marker and is refunded diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 91f1849ce..55b9158a0 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -50,7 +50,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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 DEPTH BELOW FLOOR line, error-level on mainnet and a warning off mainnet (below), 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 line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. -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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that the payment waits until that refund is redeemed: the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. With `MONERIUM_B2B_AUTO_RECOVERY=off` the deadline pass neither marks nor reports anything and a clone below the floor arms no marker, so a sub-floor payment would reach nobody: on a clone below the floor with no USDC, every chain-indexed, provider-attributed `minted` or `converting` deposit past `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (`isPastDeadline`) raises the REFUND DUE BELOW FLOOR warning, also while other deposits of the clone are marked (the lines above name only marked deposits, so none is named twice), which says to mark it for recovery and then follow REFUND NEEDS OPERATOR (in `alert` and `auto` the deadline pass reports or marks it instead). +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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that names that refund's deposit (`activeRecoveryDepositIds`, the gate's own query) and says the payment waits until that refund is redeemed and closed (in `alert` or `off` the operator closes it by setting the deposit `refunded` once the manual redeem went out): the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. With `MONERIUM_B2B_AUTO_RECOVERY=off` the deadline pass neither marks nor reports anything and a clone below the floor arms no marker, so a sub-floor payment would reach nobody: on a clone below the floor with no USDC, every chain-indexed, provider-attributed `minted` or `converting` deposit past `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (`isPastDeadline`) raises the REFUND DUE BELOW FLOOR warning, also while other deposits of the clone are marked (the lines above name only marked deposits, so none is named twice), which says to mark it for recovery and then follow REFUND NEEDS OPERATOR (in `alert` and `auto` the deadline pass reports or marks it instead). 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. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. @@ -126,7 +126,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] Registered accounts auto-activate only when `SANDBOX_ENABLED=true`, only with an IBAN and only from a mapped registration; activation sets `activated_at`, the dormancy anchor is the later of the last conversion and the activation, and a deposit on an account that cannot convert carries `account_not_active` until it can (`registration.test.ts`, `dormancy.test.ts`, `conversion-executor.test.ts`) - [ ] The admin registration list filters by status and withdraws only a `requested` registration (409 otherwise, 404 when unknown), and `onboarding → suspended` is accepted and logged (`moneriumB2b.controller.test.ts`) - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` -- [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, only warns "reconcile" when funds left by an unrecorded path, and holds the top-up while another marked payment on the clone needs that reconcile or another refund of the client is open (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…", "holds the top-up while another marked payment on the clone needs reconciling", "holds the top-up while another refund of the client is open…") +- [ ] REFUND NEEDS OPERATOR prescribes the exact top-up (`MIN_SWAP_FLOOR - clone EURe + 1 wei`, one wei more when that is a whole number of cents) and, in auto mode, never a hand refund (in `alert`/`off` it points to the manual redeem from the refund wallet), fires only while the clone holds every EURe the ledger places there (also after a failed `recover`), skips the account while any of its `recover`s is pending, goes quiet once the top-up landed, only warns "reconcile" when funds left by an unrecorded path, and holds the top-up while another marked payment on the clone needs that reconcile or another refund of the client is open, naming that refund's deposit (`monitoring.test.ts`: "asks the operator for the exact top-up…", "keeps a later top-up off a whole cent when an earlier top-up's odd wei is on the clone", "points to the manual redeem instead of the automatic refund with auto recovery alert/off", "asks for the top-up when the keeper's recover failed…", "goes quiet once the top-up landed…", "waits for a sibling's mined recover to confirm…", "never asks to refund a swapped payment…", "holds the top-up while another marked payment on the clone needs reconciling", "holds the top-up while another refund of the client is open…") - [ ] With `MONERIUM_B2B_AUTO_RECOVERY=off`, REFUND DUE BELOW FLOOR warns for a provider-attributed payment past the refund window on a clone below the floor, also beside a marked deposit, and stays out of `alert` and `auto` (`monitoring.test.ts`: "reports a payment past its window below the floor when auto recovery is off", "reports a payment past its window below the floor beside a marked one…", "leaves a payment past its window to the deadline pass…") - [ ] RECOVER STUCK PENDING warns for a `recover` execution still pending one hour after its creation and errors after four, so a pending `recover` the keeper cannot resolve never keeps the account's refund alerts silent (`monitoring.test.ts`: "warns on a recover still pending 2h after it was sent", "errors on a recover still pending 5h after it was sent") - [ ] REFUND OVERDUE warns for a `recovering` deposit on a funded clone with no pending or confirmed `recover` once `RECOVERY_DELAY` plus one hour has passed since `refundStartedAt`, errors past `TRIGGER_DELAY`, asks to reconcile with the latest error once its `recover` attempts failed, and stays out of the sub-floor case (`monitoring.test.ts`: "a marked deposit the keeper never recovers") From 9384161274ba76a6cbaf3665600c16e0e86ee235 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 13:14:50 +0200 Subject: [PATCH 79/80] test(api): pin the off-mode report's chain-indexed filter Dropping the blockNumber filter passed every test, although the admin recover route refuses an unindexed deposit, so the report would ask to mark one that cannot be marked. --- .../src/api/services/monerium-b2b/monitoring.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 13585fd82..2f6748cd2 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -712,6 +712,15 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { status: MoneriumFiatDepositStatus.Minted, txHash: "0xorphan" }); + // Reported by the webhook before the mint watcher indexed it: the admin recover route refuses it. + const unindexed = await MoneriumFiatDeposit.create({ + accountId: due.accountId, + amountRaw: (EUR / 4n).toString(), + createdAt: late, + currency: "eur", + moneriumOrderId: "order-unindexed", + status: MoneriumFiatDepositStatus.Minted + }); await runStrandedBalanceMonitor(); expect(errors).toEqual([]); expect(warnings).toHaveLength(1); @@ -719,6 +728,7 @@ describe("runStrandedBalanceMonitor below the swap floor", () => { expect(warnings[0]).toContain(due.id); expect(warnings[0]).not.toContain(fresh.id); expect(warnings[0]).not.toContain(orphan.id); + expect(warnings[0]).not.toContain(unindexed.id); expect(warnings[0]).toContain( "mark each via POST /v1/admin/monerium-b2b/deposits/:id/recover, then follow REFUND NEEDS OPERATOR" ); From bfb400a43ab0674eae991a3edf5773720319db1e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 19 Oct 2026 13:15:05 +0200 Subject: [PATCH 80/80] docs(api): cite the internal B2B runbook in the monitor docs --- apps/api/src/api/services/monerium-b2b/monitoring.ts | 2 +- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 8f1e17353..43dc62f14 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -649,7 +649,7 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi * Association monitor (S1 detective control): compares the Monerium-side linked * addresses + IBAN state per active account against the DB record and alerts on ANY * change. Error-level: an unexplained association change is an incident trigger - * (B2B operations runbook). + * (the internal B2B runbook). */ export async function runAssociationMonitor(): Promise { const accounts = await monitoredAccounts([MoneriumAccountStatus.Active]); diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 55b9158a0..c5bd0a226 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -48,7 +48,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). +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 internal B2B 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 DEPTH BELOW FLOOR line, error-level on mainnet and a warning off mainnet (below), 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 line therefore also fires whenever the Sepolia pool drifts from the Sepolia oracle; there it is a pool-calibration signal, not a production incident, so off mainnet (chain id other than 1) it is logged as a warning naming the chain with a hint to re-centre the sandbox pool, keeping invariant 1's rule that an error is an incident trigger. 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). Every clone is judged by its own `RECOVERY_DELAY` and `TRIGGER_DELAY` and its factory's `MIN_SWAP_FLOOR`; a clone whose `FACTORY()` is not the configured trusted factory (e.g. one an unfinished migration left on the old factory) is skipped with an error-level line, since the keeper refuses it and a top-up sized by another factory's floor could leave Vortex EURe on it or never arm its marker. A clone below that threshold arms no batch, so `recover` reverts: a deposit marked `recovering` there without a pending or confirmed `recover`, with no confirmed swap, raises the error-level REFUND NEEDS OPERATOR line. Its remedy is a top-up: the line names the clone, its EURe balance and the exact amount, `MIN_SWAP_FLOOR - balance + 1 wei` (one wei more when that is a whole number of cents; decimal and raw) of the EURe token, which the operator sends exactly as printed to the clone from any wallet (e.g. the float). The permissionless `poke()` (or the keeper's) then arms the marker, and after `RECOVERY_DELAY` the keeper's normal `recover(remaining EURe, 0)` and refund pipeline refund the payer's own EURe, under the same one-refund-per-client gate (`activeRecoveryExists`) and with the full record (recover transaction, redeem order, `refundedAt`). While that gate is closed (another refund of the client is open), the line is held back for a warning that names that refund's deposit (`activeRecoveryDepositIds`, the gate's own query) and says the payment waits until that refund is redeemed and closed (in `alert` or `off` the operator closes it by setting the deposit `refunded` once the manual redeem went out): the keeper sends no `recover` meanwhile, so a top-up would only leave the clone armed, past `TRIGGER_DELAY` open to the permissionless swap and `forwardAll`. That refund pipeline runs only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, where the line forbids a hand refund; in `alert` or `off` the keeper's `recover` still moves the payment to the client's refund wallet, and the line says so and points the operator to the manual redeem from that wallet instead. The top-up stays on the clone, below the floor, as Vortex EURe, so `recover`'s `_syncBatch(true)` switches the marker off again. The top-up amount is never a whole number of cents (orders and the floor are), so the mint watcher can never match it to a Monerium order by amount, even when an earlier top-up's odd wei is still on the clone; it is recorded as one `unattr:` Minted row (one warn) that the keeper never converts. Leftover top-ups stay below EUR 1 per clone; a later sub-floor payment that reaches the floor with them arms the marker and is refunded without an operator step. The line fires only while the clone holds at least all the EURe the ledger places there: every minted (including `unattr:` top-ups), converting, recovering or refunded deposit's unswapped rest without a confirmed `recover` (a legacy hand refund left its EURe on the clone). A landed top-up lifts the clone to the floor, so the check stays quiet until the keeper's `recover` took the payer's EURe. While any `recover` of the account is still pending, the account is skipped for that pass: a mined `recover` whose row is not yet confirmed would make the ledger count EURe that already left, and turn a sibling's alert into a false "reconcile". On any clone, a `recover` still pending more than one hour after it was created (the linger margin) raises RECOVER STUCK PENDING, a warning, and an error after four hours: the keeper keeps skipping it (e.g. failing receipt lookups), and the checks here take a pending `recover` for progress, so without it the account would stay silent. On a clone at or above the floor (or holding USDC), a deposit marked `recovering` with no pending or confirmed `recover` whose `refundStartedAt` is older than `RECOVERY_DELAY` plus one hour (the refund monitor's linger margin) raises REFUND OVERDUE, a warning, and an error once it is older than `TRIGGER_DELAY`: younger forwards may keep re-timing the marker so it never becomes recover-eligible, or another refund of the client may be stuck. Once its `recover` attempts have failed, the line says that no `recover` has succeeded, names the latest error and asks to reconcile the clone's balances against the ledger before any refund, never a hand refund: a `recover` that keeps reverting may mean the funds left by an unrecorded path, where a hand refund would pay twice. After a late operator top-up it can show until the keeper's `recover` lands. A swapped deposit, or any shortfall, means part or all of the funds left by an unrecorded path (e.g. the permissionless swap/`forwardAll`), so it only warns "part or all of their funds left the clone: reconcile before refunding; do not refund and do not top up the clone". While such a payment is flagged, the clone's unswapped marked payments get no top-up either, only a warning that they wait for that reconcile: a top-up would arm the marker for the flagged payment too, whose `recover` cannot succeed, so the keeper could keep retrying it instead of refunding them. With `MONERIUM_B2B_AUTO_RECOVERY=off` the deadline pass neither marks nor reports anything and a clone below the floor arms no marker, so a sub-floor payment would reach nobody: on a clone below the floor with no USDC, every chain-indexed, provider-attributed `minted` or `converting` deposit past `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (`isPastDeadline`) raises the REFUND DUE BELOW FLOOR warning, also while other deposits of the clone are marked (the lines above name only marked deposits, so none is named twice), which says to mark it for recovery and then follow REFUND NEEDS OPERATOR (in `alert` and `auto` the deadline pass reports or marks it instead). 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.