From d93d814f48db7dea321c4b28b55e6b114a494e13 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:42:08 +0200 Subject: [PATCH 1/5] fix(api): poke the batch marker when a monerium cycle sends no swap The marker starts the RECOVERY_DELAY clock. A failed attempt or a retry backoff skip sent nothing, so a deposit whose every attempt reverted kept batchOpenedAt at zero until the deadline job marked it recovering, and its refund landed a whole extra delay late. --- .../monerium-b2b/conversion-executor.test.ts | 177 ++++++++++++++++++ .../monerium-b2b/conversion-executor.ts | 12 +- 2 files changed, 188 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 265d84436..fbfcd3825 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 @@ -999,6 +999,183 @@ describe("runConversionExecutor activation gate", () => { }); }); +// The batch marker starts the RECOVERY_DELAY clock, so a cycle that sends no swap must +// still arm it: otherwise a deposit whose every attempt fails waits a whole extra delay +// for its refund. +describe("runConversionExecutor batch marker", () => { + const FACTORY = "0x2222222222222222222222222222222222222222" as Address; + const EURE = "0x4444444444444444444444444444444444444444" as Address; + const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl }; + + beforeAll(async () => { + config.moneriumB2b.rpcUrl = undefined; // provisioning skips the on-chain clone check + config.moneriumB2b.forwarderFactoryAddress = FACTORY; + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = saved.rpcUrl; + config.moneriumB2b.forwarderFactoryAddress = saved.factory; + }); + + beforeEach(() => resetTestDatabase()); + afterEach(() => mock.restore()); + + /** A funded clone with a ready-priced 100 EURe swap; returns every keeper write as `fn:nonce`. */ + function arrange(options: { batchOpenedAt: bigint; failPokeWrite?: boolean; swapReverts?: boolean }) { + const writes: string[] = []; + const reads: Record = { + batchOpenedAt: options.batchOpenedAt, + floorPpm: 1_500, + latestRoundData: [1n, 114_000_000n, 0n, 0n, 1n], + MIN_SWAP_FLOOR: 1n, + minSwapAmount: 25n * EUR, + perSwapCap: 10_000n * EUR, + subsidyVault: "0x0000000000000000000000000000000000000000", + targetPpm: 1_250 + }; + spyOn(chain, "getForwarderImmutables").mockResolvedValue({ + eure: EURE, + factory: FACTORY, + maxReferenceDeviationBps: 100, + oracle: "0x5555555555555555555555555555555555555555", + oracleDecimals: 8, + recoveryDelaySeconds: 7_200, + usdc: "0x6666666666666666666666666666666666666666" + } as unknown as chain.ForwarderImmutables); + spyOn(chain, "getChainId").mockResolvedValue(11_155_111); + spyOn(chain, "readEnabledRoutes").mockResolvedValue([{ index: 0, path: "0xaa" as Hex }]); + spyOn(referenceRate, "fetchCoinbaseReference").mockResolvedValue({ + price: "1.14000000", + rateRaw: 114_000_000n, + source: "test", + time: new Date(0) + }); + spyOn(chain, "getPublicClient").mockReturnValue({ + getBlockNumber: async () => 100n, + getTransactionCount: async () => 7, + readContract: async ({ address, functionName }: { address: Address; functionName: string }) => + functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName], + simulateContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "swap" && options.swapReverts) throw new Error("execution reverted"); + return {}; + }, + waitForTransactionReceipt: async () => { + throw new Error("receipt timeout"); + } + } as unknown as ReturnType); + spyOn(chain, "getKeeperWalletClient").mockReturnValue({ + account: { address: "0x9999999999999999999999999999999999999999" }, + writeContract: async ({ functionName, nonce }: { functionName: string; nonce?: number }) => { + writes.push(`${functionName}:${nonce ?? "auto"}`); + if (functionName === "poke" && options.failPokeWrite) throw new Error("poke rejected"); + return `0x${writes.length.toString(16).padStart(64, "0")}`; + } + } as unknown as ReturnType); + return writes; + } + + async function activeAccountWithDeposit() { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const { accountId } = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: "0x5555555555555555555555555555555555555555", + externalSubjectId: "client-1", + forwarderAddress: "0x1111111111111111111111111111111111111111", + managerProfileId: manager.id, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + const deposit = await MoneriumFiatDeposit.create({ + accountId, + amountRaw: (100n * EUR).toString(), + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + mintedAt: new Date(), + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xorder1" + }); + return { accountId, deposit }; + } + + const executions = (accountId: string) => MoneriumConversionExecution.findAll({ where: { accountId } }); + + async function failedAttempt(accountId: string, depositId: string) { + await MoneriumConversionExecution.create({ + accountId, + depositId, + destination: "0x5555555555555555555555555555555555555555", + eureInRaw: (100n * EUR).toString(), + error: "attempt 1: execution reverted", + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Failed + }); + } + + it("pokes once when the swap attempt fails before sending", async () => { + const writes = arrange({ batchOpenedAt: 0n, swapReverts: true }); + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["poke:auto"]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]); + }); + + it("pokes during the retry backoff without starting an attempt", async () => { + const writes = arrange({ batchOpenedAt: 0n }); + const { accountId, deposit } = await activeAccountWithDeposit(); + await failedAttempt(accountId, deposit.id); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["poke:auto"]); + expect(await executions(accountId)).toHaveLength(1); + }); + + it("never pokes an armed batch, on a failed attempt or during backoff", async () => { + const writes = arrange({ batchOpenedAt: 1n, swapReverts: true }); + const { accountId, deposit } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); // fails the attempt + await failedAttempt(accountId, deposit.id); + await runConversionExecutor(accountId); // backoff + + expect(writes).toEqual([]); + }); + + it("does not poke again after the sequenced poke was attempted", async () => { + const writes = arrange({ batchOpenedAt: 0n, failPokeWrite: true }); + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["poke:7"]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]); + }); + + it("keeps the poke-then-swap sequence on a sent attempt, with no extra poke", async () => { + const writes = arrange({ batchOpenedAt: 0n }); + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); // the receipt wait times out: the row stays pending + + expect(writes).toEqual(["poke:7", "swap:8"]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Pending]); + }); +}); + // A reservation whose broadcast never happened (the process died between `reserve` and // `send`) holds a nonce nobody will use; the keeper re-sends the identical call there. describe("runConversionExecutor reserved-nonce re-send", () => { diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index 087b1617f..ef75260ec 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1205,9 +1205,14 @@ export async function runConversionExecutor(accountId: string): Promise { }); if (slot.kind === "skip") { logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${slot.reason}`); + if (pokeNeeded) { + await sendPoke(forwarder); + } return; } const { attempt, execution } = slot; + // Cleared once the sequenced poke is attempted, so a failed attempt never pokes twice. + let pokeOutstanding = pokeNeeded; try { const keeper = getKeeperWalletClient(); @@ -1246,6 +1251,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 +1290,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 +1375,7 @@ function executionCall( } } -/** Standalone batch-marker poke for funds the keeper cannot act on yet. */ +/** Standalone batch-marker poke for funds the keeper does not act on this cycle. */ async function sendPoke(forwarder: Address): Promise { try { const client = getPublicClient(); From 39d66bc9508bbd7b8c2e1edfe4abc85189e3c0cd Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:42:10 +0200 Subject: [PATCH 2/5] docs(api): note the marker poke on cycles that send no swap --- docs/architecture-monerium-b2b-onramp.md | 5 ++++- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index b3d080083..99eba5f1a 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -366,7 +366,10 @@ and sends at most one transaction per account per cycle: endpoint, or once automated by the missed window — is moved off the clone with `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the clone's fixed `recoveryAddress` (the client's refund wallet), and only once the clone's `batchOpenedAt` marker is older - than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive, is never + than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive (the keeper + sends the permissionless `poke()` ahead of the first swap, and on its own in any cycle + that sends no swap: nothing to do, a deferral, a retry backoff or a failed attempt, so + a deposit whose every attempt fails is still refundable on time), is never re-timed by a chunk swap, and is re-timed for whatever remains after a forward or a recovery, so a younger payment sharing the clone gets its own clock. The keeper recovers before it converts anything else, and still does so on suspended or dormant diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 377e03340..73623bc3c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -111,7 +111,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] The reference midpoint rejects a malformed ticker, an inverted or empty book and a spread above 50 bps (`reference-rate.test.ts`) - [ ] Keeper, guardian, and attestor private keys are three distinct keys in production, the deployer key differs from those and the float key (`vars.test.ts`); none logged - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) -- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` +- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; a cycle that sends no swap (deferral, retry backoff, failed attempt) still pokes an unarmed funded batch, at most once per cycle; no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` - [ ] Every execution carries `kind` and `deposit_id` (migration 080); the migration refuses an execution that spanned several deposits under the former allocation join instead of guessing - [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`) - [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch From b31d55e1bebeeca67bec4e3413979989d2b2c13a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:51 +0200 Subject: [PATCH 3/5] test(api): assert the armed-batch marker test takes the backoff --- .../src/api/services/monerium-b2b/conversion-executor.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index fbfcd3825..f313be250 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 @@ -1153,6 +1153,7 @@ describe("runConversionExecutor batch marker", () => { await runConversionExecutor(accountId); // backoff expect(writes).toEqual([]); + expect(await executions(accountId)).toHaveLength(2); // no third attempt: the backoff held }); it("does not poke again after the sequenced poke was attempted", async () => { From 98dc64f6d0188a55b6b19e8ba20e4fd4a9130883 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:57 +0200 Subject: [PATCH 4/5] fix(api): poke only on the backoff when a monerium cycle skips A skip that waits on another execution leaves the poke to that execution's own sequence, so an implicit-nonce poke never races it. A backoff reached by the top-of-cycle pending check now falls through to the slot check, which pokes, instead of returning a cycle early. --- .../monerium-b2b/conversion-executor.test.ts | 41 +++++++++++++++++++ .../monerium-b2b/conversion-executor.ts | 16 +++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index f313be250..5659a702f 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 @@ -1156,6 +1156,47 @@ describe("runConversionExecutor batch marker", () => { expect(await executions(accountId)).toHaveLength(2); // no third attempt: the backoff held }); + it("pokes when a crashed pre-send reservation expires into a backoff", async () => { + const writes = arrange({ batchOpenedAt: 0n }); + const { accountId, deposit } = await activeAccountWithDeposit(); + await MoneriumConversionExecution.create({ + accountId, + createdAt: new Date(Date.now() - 10 * 60_000), + depositId: deposit.id, + destination: "0x5555555555555555555555555555555555555555", + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Pending + }); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["poke:auto"]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Failed]); + }); + + it("leaves the poke to another executor whose execution appears mid-cycle", async () => { + const writes = arrange({ batchOpenedAt: 0n }); + const { accountId, deposit } = await activeAccountWithDeposit(); + // Another process reserves its execution after this cycle's first pending check. + spyOn(referenceRate, "fetchCoinbaseReference").mockImplementation(async () => { + await MoneriumConversionExecution.create({ + accountId, + depositId: deposit.id, + destination: "0x5555555555555555555555555555555555555555", + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Pending + }); + return { price: "1.14000000", rateRaw: 114_000_000n, source: "test", time: new Date(0) }; + }); + + await runConversionExecutor(accountId); + + expect(writes).toEqual([]); + expect((await executions(accountId)).map(row => row.status)).toEqual([MoneriumConversionExecutionStatus.Pending]); + }); + it("does not poke again after the sequenced poke was attempted", async () => { const writes = arrange({ batchOpenedAt: 0n, failPokeWrite: true }); const { accountId } = await activeAccountWithDeposit(); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index ef75260ec..791c282f3 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -472,7 +472,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 } @@ -811,7 +816,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" }; @@ -1066,6 +1071,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; @@ -1173,7 +1179,7 @@ export async function runConversionExecutor(accountId: string): Promise { // transactions, two concurrent executors could both pass the check and both broadcast. const slot = await withForwarderLock(account.forwarderAddress, async transaction => { const preparation = await prepareExecutionSlot(account, transaction); - if (preparation.kind === "skip") { + if (preparation.kind !== "proceed") { return preparation; } // Execution-before-send record: committed before any broadcast so a crash leaves an @@ -1203,9 +1209,9 @@ export async function runConversionExecutor(accountId: string): Promise { } return { attempt: preparation.attempt, execution, kind: "proceed" as const }; }); - if (slot.kind === "skip") { + if (slot.kind !== "proceed") { logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${slot.reason}`); - if (pokeNeeded) { + if (slot.kind === "backoff" && pokeNeeded) { await sendPoke(forwarder); } return; From bb8e8adb7458d6273e61e1cf766bc07162591fec Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:58 +0200 Subject: [PATCH 5/5] docs(api): note that a pending execution carries its own poke --- docs/architecture-monerium-b2b-onramp.md | 5 +++-- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 99eba5f1a..8e29922b3 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -367,9 +367,10 @@ and sends at most one transaction per account per cycle: `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the clone's fixed `recoveryAddress` (the client's refund wallet), and only once the clone's `batchOpenedAt` marker is older than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive (the keeper - sends the permissionless `poke()` ahead of the first swap, and on its own in any cycle + sends the permissionless `poke()` ahead of the first swap, and on its own in a cycle that sends no swap: nothing to do, a deferral, a retry backoff or a failed attempt, so - a deposit whose every attempt fails is still refundable on time), is never + a deposit whose every attempt fails is still refundable on time; a cycle waiting on a + pending execution leaves the poke to that execution's own sequence), is never re-timed by a chunk swap, and is re-timed for whatever remains after a forward or a recovery, so a younger payment sharing the clone gets its own clock. The keeper recovers before it converts anything else, and still does so on suspended or dormant diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 73623bc3c..b1721c232 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -111,7 +111,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] The reference midpoint rejects a malformed ticker, an inverted or empty book and a spread above 50 bps (`reference-rate.test.ts`) - [ ] Keeper, guardian, and attestor private keys are three distinct keys in production, the deployer key differs from those and the float key (`vars.test.ts`); none logged - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) -- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; a cycle that sends no swap (deferral, retry backoff, failed attempt) still pokes an unarmed funded batch, at most once per cycle; no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` +- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; a cycle that sends no swap (deferral, retry backoff, failed attempt) still pokes an unarmed funded batch, at most once per cycle (a cycle waiting on a pending execution leaves it to that execution's sequence); no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` - [ ] Every execution carries `kind` and `deposit_id` (migration 080); the migration refuses an execution that spanned several deposits under the former allocation join instead of guessing - [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`) - [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch