diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index aa00fbeec..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 = [ @@ -200,8 +208,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 +438,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..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 @@ -125,6 +125,40 @@ function swapRow(eureInRaw: bigint, usdcNetRaw: bigint): MoneriumConversionExecu } as unknown as MoneriumConversionExecution; } +async function activeAccountWithDeposit(forwarderAddress = "0x1111111111111111111111111111111111111111") { + 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, + 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() }, [ @@ -217,11 +251,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", () => { @@ -644,7 +685,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 +697,7 @@ describe("pricePlannedSwap", () => { } else { fetchSpy.mockResolvedValue(fetched); } + return { quoteSpy }; } const price = (maxSubsidyBps = 50) => pricePlannedSwap(FORWARDER, FACTORY, 1_000n * EUR, maxSubsidyBps); @@ -688,9 +730,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 +747,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 () => { @@ -890,16 +940,19 @@ 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 MIN_SWAP_FLOOR: 1n, minSwapAmount: 25n * EUR, + pendingFeePolicyEffectiveAt: 0n, perSwapCap: 10_000n * EUR, subsidyVault: "0x0000000000000000000000000000000000000000", targetPpm: 1_250 @@ -965,13 +1018,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 () => { @@ -988,6 +1043,52 @@ 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("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("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 } }); @@ -999,6 +1100,305 @@ 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 (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: options.minSwapFloor ?? 1n, + minSwapAmount: 25n * EUR, + pendingFeePolicyEffectiveAt: 0n, + 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 ? (options.eureOnClone ?? 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; + } + + const executions = (accountId: string) => MoneriumConversionExecution.findAll({ where: { accountId } }); + + 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, + 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(); + + 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([]); + 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, and pokes in the next cycle's backoff", 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]); + + // 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 () => { + 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]); + }); + + // 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 // `send`) holds a nonce nobody will use; the keeper re-sends the identical call there. describe("runConversionExecutor reserved-nonce re-send", () => { @@ -1297,3 +1697,205 @@ 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; + 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; + /** 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; + config.moneriumB2b.forwarderFactoryAddress = FACTORY; + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = saved.rpcUrl; + config.moneriumB2b.forwarderFactoryAddress = saved.factory; + }); + + beforeEach(async () => { + await resetTestDatabase(); + writes = []; + writeError = null; + nonces = { latest: 0, pending: 0 }; + receiptTimesOut = false; + privateTransport = false; + receiptStatus = "success"; + simulateError = null; + sentHashes = new Set(); + forwarder = `0x${crypto.randomUUID().replaceAll("-", "")}${"0".repeat(8)}`; + 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 }), + 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]; + }, + 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: receiptStatus }; + } + } as unknown as ReturnType); + spyOn(chain, "getKeeperWalletClient").mockReturnValue({ + account: { address: "0x9999999999999999999999999999999999999999" }, + writeContract: async ({ functionName }: { functionName: string }) => { + if (writeError) throw writeError; + writes.push(functionName); + if (!privateTransport) nonces.pending += 1; + const hash = `0x${crypto.randomUUID().replaceAll("-", "")}${"0".repeat(32)}`; + sentHashes.add(hash); + return hash; + } + } as unknown as ReturnType); + }); + + afterEach(() => mock.restore()); + + it("applies a due increase once, before the cycle prices its swap", async () => { + reads.pendingFeePolicyEffectiveAt = NOW; + const { accountId, deposit } = await activeAccountWithDeposit(forwarder); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["applyFeePolicy", "read targetPpm"]); + expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); // the cycle went on + }); + + 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(forwarder); + + 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(forwarder); + + 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(forwarder); + + 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(forwarder); + + 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(forwarder); + await runConversionExecutor(accountId); + reads.pendingFeePolicyEffectiveAt = NOW + 1n; + await runConversionExecutor(accountId); + 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(forwarder); + + 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()"); + const warn = spyOn(logger, "warn"); + const { accountId, deposit } = await activeAccountWithDeposit(forwarder); + + 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 087b1617f..42d7a93f2 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, @@ -51,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 — @@ -472,7 +474,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 } @@ -590,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 @@ -716,9 +725,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, @@ -804,6 +818,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 } } : {}) } }); @@ -811,7 +826,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" }; @@ -833,15 +848,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 +869,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 +911,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"); } @@ -952,6 +969,7 @@ export function canConvert(account: Pick, @@ -1020,35 +1039,33 @@ 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 /** - * 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 { @@ -1066,6 +1083,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; @@ -1096,6 +1114,9 @@ export async function runConversionExecutor(accountId: string): Promise { 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); + // 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. @@ -1122,6 +1143,32 @@ 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 } } } + ); + } + // A lowered minimum ends that wait even for a deposit queued behind the one planned. + 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, convertible, @@ -1172,8 +1219,8 @@ 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); - if (preparation.kind === "skip") { + const preparation = await prepareExecutionSlot(account, transaction, call.kind); + if (preparation.kind !== "proceed") { return preparation; } // Execution-before-send record: committed before any broadcast so a crash leaves an @@ -1203,11 +1250,18 @@ 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 (slot.kind === "backoff" && pokeNeeded) { + await sendPoke(forwarder); + } return; } const { attempt, execution } = slot; + // 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 { const keeper = getKeeperWalletClient(); @@ -1246,6 +1300,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, @@ -1284,6 +1339,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); + } } } @@ -1365,7 +1424,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(); @@ -1389,6 +1448,92 @@ 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, 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(); + +/** + * P11: finalizes a guardian fee increase whose timelock has elapsed (`applyFeePolicy` is + * 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(); + const effectiveAt = await client.readContract({ + abi: forwarderAbi, + address: forwarder, + functionName: "pendingFeePolicyEffectiveAt" + }); + 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); + 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" }) + ]); + // 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) { + 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})`); + } 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 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)) { 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..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,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 { runRecoveryOrchestrator, setDepositStatus } from "./recovery"; const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; @@ -335,6 +336,89 @@ 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("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, + 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 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()) + .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 4427c8201..c0c07a17c 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, + // 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, @@ -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 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. 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, 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); } 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..2f6748cd2 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,19 @@ -import { describe, expect, it } from "bun:test"; +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 MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + 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"; +import * as chain from "./chain"; import { classifyExecutableDepth, classifyRefundQueue, @@ -8,7 +23,9 @@ import { detectConfigDrift, diffAssociation, eip1167RuntimeCode, - normalizeIban + normalizeIban, + runExecutableDepthCheck, + runStrandedBalanceMonitor } from "./monitoring"; // Pure monitoring logic (implementation plan D3): quote-impact math against the T6 @@ -246,3 +263,637 @@ 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 }; + } + + for (const [chainId, level, quoter] of [ + [1, "error", "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"], + [11_155_111, "warn", "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3"] + ] as const) { + 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: [] }; + 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(); + 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"); + 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 internal B2B runbook §3"); + }); + } + + 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(); + }); +}); + +// 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 = { + autoRecovery: config.moneriumB2b.autoRecovery, + factory: config.moneriumB2b.forwarderFactoryAddress, + rpcUrl: config.moneriumB2b.rpcUrl + }; + let errors: string[]; + 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 + config.moneriumB2b.forwarderFactoryAddress = FACTORY; + await setupTestDatabase(); + }); + + 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; + batchOpenedAt = 0n; + cloneReadsFail = false; + const reads: Record = { 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 }) => { + if (cloneReadsFail && functionName === "balanceOf") throw new Error("rpc down"); + return functionName === "balanceOf" + ? address === EURE + ? eureOnClone + : 0n + : functionName === "batchOpenedAt" + ? batchOpenedAt + : reads[functionName]; + } + } as unknown as ReturnType); + 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, + amountRaw = EUR / 2n, + forwarderAddress = "0x1111111111111111111111111111111111111111" + ) { + 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, + managerProfileId: manager.id, + moneriumProfileId: crypto.randomUUID() + }); + 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: orders, + mintedAt: new Date(), + moneriumOrderId: `order-${orders}`, + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status, + txHash: `0xorder${orders}` + }); + } + + 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, + kind, + status, + usdcNetRaw: "0" + }); + } + + 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("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"); + }); + + 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); + 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([]); + }); + + 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 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({ + 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); + await execution(deposit, MoneriumConversionExecutionKind.Recover, status); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + }); + } + + // 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("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); + 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); + await runStrandedBalanceMonitor(); + expect(errors).toEqual([]); + 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([]); + // 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"); + }); + + 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 (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 }); + 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. + 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 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); + 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]) { + 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(); + 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" + }); + // 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); + 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]).not.toContain(unindexed.id); + expect(warnings[0]).toContain( + "mark each via POST /v1/admin/monerium-b2b/deposits/:id/recover, then follow REFUND NEEDS OPERATOR" + ); + }); + + 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; + 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", () => { + 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", () => { + 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("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); + 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, new Date(now - 10 * 60_000)); + 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 99ee289a1..43dc62f14 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, @@ -15,11 +20,15 @@ import { getPublicClient, moneriumChainForChainId, quoteRouteOutput, + quoterV2ForChainId, readEnabledRoutes, readSubsidyVaultState, SubsidyVaultState } from "./chain"; +import { settlementState } from "./conversion-executor"; +import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; +import { activeRecoveryDepositIds, isPastDeadline } from "./recovery"; import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; import { refundAccountFor } from "./refund-wallet"; @@ -31,12 +40,20 @@ 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. Mainnet-only (QuoterV2 pin). + * (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 * 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; the + * others' top-up waits for that, and for the client's open refund). With auto recovery off, + * 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 * (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. @@ -283,10 +300,12 @@ async function monitoredAccounts(statuses: MoneriumAccountStatus[]): Promise { - if ((await getChainId()) !== 1) { + const chainId = await getChainId(); + const quoter = quoterV2ForChainId(chainId); + if (!quoter) { return; } const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); @@ -321,8 +340,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), @@ -343,8 +362,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") { - logger.error(`monerium-b2b: DEPTH BELOW FLOOR — ${verdict.reason}; triage per the B2B operations runbook §3. ${detail}`); + if (verdict.severity === "error" && chainId === 1) { + 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( + `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 { @@ -363,28 +388,237 @@ 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 { const forwarder = account.forwarderAddress as Address; - const { eure, usdc } = await getForwarderImmutables(forwarder); - const [eureBalance, usdcBalance, batchOpenedAt] = await Promise.all([ + // 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, + // before the chain reads a failing RPC would make throw. + 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 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); + } + // 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 + // payment marked for the refund path (one already recovered is the refund monitor's). + const marked = await MoneriumFiatDeposit.count({ + where: { accountId: account.id, status: MoneriumFiatDepositStatus.Recovering } + }); + 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. 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"], + 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). + // 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: { + accountId: account.id, + status: { + [Op.in]: [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Converting, + MoneriumFiatDepositStatus.Recovering, + MoneriumFiatDepositStatus.Refunded + ] + } + } + }); + const executions = await MoneriumConversionExecution.findAll({ + attributes: ["depositId", "eureInRaw", "kind", "status"], + where: { + depositId: { [Op.in]: deposits.map(deposit => deposit.id) }, + [Op.or]: [ + { + kind: MoneriumConversionExecutionKind.Recover, + status: { [Op.in]: [MoneriumConversionExecutionStatus.Pending, MoneriumConversionExecutionStatus.Confirmed] } + }, + { kind: MoneriumConversionExecutionKind.Swap, status: MoneriumConversionExecutionStatus.Confirmed } + ] + } + }); + const swapsOf = (deposit: MoneriumFiatDeposit) => + executions.filter(e => e.depositId === deposit.id && e.kind === MoneriumConversionExecutionKind.Swap); + const recoverOf = (deposit: MoneriumFiatDeposit, status: MoneriumConversionExecutionStatus) => + executions.some( + 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 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 = unrecovered.reduce( + (sum, deposit) => sum + settlementState(deposit, swapsOf(deposit)).remainingEureRaw, + 0n + ); + 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), + // 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 " + + "before it is reconciled; REFUND NEEDS OPERATOR then returns with the amount (the internal B2B runbook §2.7)" + ); + } 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. 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 (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 + // 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 + // 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 + // 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 ${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) { + logger.warn( + `monerium-b2b: deposit(s) ${elsewhere.map(deposit => deposit.id).join(", ")} on forwarder ${forwarder} ` + + `(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; } + // 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", "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 && 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 ` + + (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); + } + } const severity = classifyStranding(batchOpenedAt, BigInt(recoveryDelaySeconds), triggerDelay, now); if (severity === "ok") { continue; @@ -415,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/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( 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/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..2d6f91a0a --- /dev/null +++ b/apps/api/src/api/workers/monerium-b2b.worker.test.ts @@ -0,0 +1,94 @@ +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"; +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]); + }); + + 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 01a3e7fa2..2cb8255f6 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,18 +110,24 @@ 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. 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 block_number IS NOT NULL + AND monerium_order_id NOT LIKE :unattributed + UNION + SELECT account_id FROM monerium_conversion_executions WHERE status = 'pending'`, { - replacements: { settling: SETTLING_STATUSES }, + replacements: { settling: SETTLING_STATUSES, unattributed: `${UNATTRIBUTED_ORDER_PREFIX}%` }, type: QueryTypes.SELECT } ); diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index bb92e518e..e9ccc09cb 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -285,6 +285,30 @@ 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. 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`). 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, 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, 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) | ID | Parameter | Value | diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 8c7081310..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; /** @@ -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. 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"; + 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..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"] }, @@ -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. New reasons may be added; treat an unknown reason as a generic wait.", "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..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), 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/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index f77f4ce19..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.** Payments from EUR 1 are converted; a smaller payment is refunded after the refund window. +- **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). @@ -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 | 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/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index b3d080083..a76a68b8b 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -206,6 +206,12 @@ same per-forwarder advisory lock: the **webhooks** carry the provider order acco 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. +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 @@ -322,7 +328,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 @@ -344,7 +352,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 @@ -361,16 +370,67 @@ 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 with the exact top-up: + `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`); 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. 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, 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 + 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. 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` + 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 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 a cycle + that sends no swap: nothing to do, a deferral, a retry backoff or a failed attempt, so + 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 → @@ -431,6 +491,17 @@ 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 failed read of the pending policy, a revert + 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; 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 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 @@ -452,8 +523,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 @@ -498,11 +570,44 @@ 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 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 — - a keeper-outage signal; funds are never at risk). + 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:` + 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), 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 + 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 + 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 + 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. 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 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 377e03340..c5bd0a226 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -35,22 +35,22 @@ 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. -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. +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; 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), 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. 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. +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 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. -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). +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. 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. @@ -111,17 +111,25 @@ 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 (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 +- [ ] `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`) - [ ] 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, 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") - [ ] 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 diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 084e12926..9bbaac786 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -188,8 +188,11 @@ 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. New reasons may be added; clients treat an unknown one as a + * generic wait. */ export type DepositWaitingReason = | "monerium_pending" @@ -198,7 +201,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";