From 8b44f69191138b656510e874033c9a00aed06182 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:43:04 +0200 Subject: [PATCH 1/5] fix(api): apply due fee-policy increases in the keeper cycle applyFeePolicy is permissionless and the contract expects the keeper to finalize an announced increase once its 24 h timelock elapsed, but no keeper code called it, so increases stayed pending forever. --- .../src/api/services/monerium-b2b/chain.ts | 10 +- .../monerium-b2b/conversion-executor.test.ts | 128 ++++++++++++++++++ .../monerium-b2b/conversion-executor.ts | 34 ++++- 3 files changed, 169 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index aa00fbeec..4fc4b8cb9 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -129,6 +129,7 @@ export const forwarderAbi = [ type: "function" }, { inputs: [], name: "forwardAll", outputs: [], stateMutability: "nonpayable", type: "function" }, + { inputs: [], name: "applyFeePolicy", outputs: [], stateMutability: "nonpayable", type: "function" }, { inputs: [ { name: "eureAmount", type: "uint256" }, @@ -166,7 +167,14 @@ export const forwarderAbi = [ type: "function" }, { inputs: [], name: "targetPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, - { inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" } + { inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, + { + inputs: [], + name: "pendingFeePolicyEffectiveAt", + outputs: [{ name: "", type: "uint64" }], + stateMutability: "view", + type: "function" + } ] as const; export const factoryAbi = [ diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 265d84436..3f3540388 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 @@ -900,6 +900,7 @@ describe("runConversionExecutor activation gate", () => { latestRoundData: [1n, 0n, 0n, 0n, 1n], // Chainlink down: a planned swap defers before sending anything MIN_SWAP_FLOOR: 1n, minSwapAmount: 25n * EUR, + pendingFeePolicyEffectiveAt: 0n, perSwapCap: 10_000n * EUR, subsidyVault: "0x0000000000000000000000000000000000000000", targetPpm: 1_250 @@ -999,6 +1000,133 @@ describe("runConversionExecutor activation gate", () => { }); }); +// P11: an announced fee increase only takes effect once someone calls the permissionless +// applyFeePolicy() after the timelock; the keeper does so in the account's cycle. +describe("runConversionExecutor fee-policy apply", () => { + const FACTORY = "0x2222222222222222222222222222222222222222" as Address; + const EURE = "0x4444444444444444444444444444444444444444" as Address; + const NOW = 1_800_000_000n; + const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl }; + let reads: Record; + let writes: string[]; + let writeError: Error | null; + + beforeAll(async () => { + config.moneriumB2b.rpcUrl = undefined; + config.moneriumB2b.forwarderFactoryAddress = FACTORY; + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = saved.rpcUrl; + config.moneriumB2b.forwarderFactoryAddress = saved.factory; + }); + + beforeEach(async () => { + await resetTestDatabase(); + writes = []; + writeError = null; + reads = { + batchOpenedAt: 1n, + floorPpm: 1_500, + latestRoundData: [1n, 0n, 0n, 0n, 1n], // Chainlink down: the planned swap defers, nothing else is sent + MIN_SWAP_FLOOR: 1n, + minSwapAmount: 25n * EUR, + pendingFeePolicyEffectiveAt: 0n, + perSwapCap: 10_000n * EUR, + subsidyVault: "0x0000000000000000000000000000000000000000", + targetPpm: 1_250 + }; + spyOn(chain, "getForwarderImmutables").mockResolvedValue({ + eure: EURE, + factory: FACTORY, + usdc: "0x6666666666666666666666666666666666666666", + recoveryDelaySeconds: 7_200 + } as unknown as chain.ForwarderImmutables); + spyOn(chain, "getPublicClient").mockReturnValue({ + getBlock: async () => ({ timestamp: NOW }), + readContract: async ({ address, functionName }: { address: Address; functionName: string }) => + functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName], + simulateContract: async () => ({}), + waitForTransactionReceipt: async () => ({ status: "success" }) + } as unknown as ReturnType); + spyOn(chain, "getKeeperWalletClient").mockReturnValue({ + account: { address: "0x9999999999999999999999999999999999999999" }, + writeContract: async ({ functionName }: { functionName: string }) => { + if (writeError) throw writeError; + writes.push(functionName); + return "0xapply"; + } + } as unknown as ReturnType); + }); + + afterEach(() => mock.restore()); + + async function activeAccountWithDeposit() { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const { accountId } = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: "0x5555555555555555555555555555555555555555", + externalSubjectId: "client-1", + forwarderAddress: "0x1111111111111111111111111111111111111111", + managerProfileId: manager.id, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } }); + const deposit = await MoneriumFiatDeposit.create({ + accountId, + amountRaw: (100n * EUR).toString(), + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + mintedAt: new Date(), + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xorder1" + }); + return { accountId, deposit }; + } + + it("applies a due increase once, before the cycle prices its swap", async () => { + reads.pendingFeePolicyEffectiveAt = NOW; + const { accountId, deposit } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(writes).toEqual(["applyFeePolicy"]); + expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); // the cycle went on + }); + + it("sends nothing while no increase is pending or its timelock still runs", async () => { + const { accountId } = await activeAccountWithDeposit(); + await runConversionExecutor(accountId); + reads.pendingFeePolicyEffectiveAt = NOW + 1n; + await runConversionExecutor(accountId); + expect(writes).toEqual([]); + }); + + it("logs a reverted apply and carries on with the cycle", async () => { + reads.pendingFeePolicyEffectiveAt = NOW - 60n; + writeError = new Error("NoPendingFeePolicy()"); + const warn = spyOn(logger, "warn"); + const { accountId, deposit } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); + + expect(warn.mock.calls.some(([message]) => String(message).includes("applyFeePolicy"))).toBe(true); + expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); + }); +}); + // 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..8e98e1c9d 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1087,15 +1087,19 @@ export async function runConversionExecutor(accountId: string): Promise { ) { throw new Error(`Forwarder ${forwarder} is not bound to the configured trusted factory`); } - const [eureBalance, usdcBalance, batchOpenedAt, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([ + const [eureBalance, usdcBalance, batchOpenedAt, minSwapAmount, minSwapFloor, perSwapCap, feeIncreaseAt] = await Promise.all([ client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }), client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), - client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "pendingFeePolicyEffectiveAt" }) ]); + // Before pricing, so a swap this cycle is projected under the policy it will settle at. + await applyDueFeePolicy(forwarder, feeIncreaseAt); + // Arm the batch marker whenever funds are present, even below the (guardian-tunable) // minSwapAmount: the recovery and trigger clocks must run regardless of whether a swap // is currently possible. @@ -1389,6 +1393,32 @@ async function sendPoke(forwarder: Address): Promise { } } +/** + * P11: finalizes a guardian fee increase whose timelock has elapsed (`applyFeePolicy` is + * permissionless; nothing else would ever apply it). Waits for the receipt so the cycle + * then reads the applied policy. Best-effort like sendPoke: a revert (applied by someone + * else, clock skew) or a timeout is logged and the cycle goes on, since the contract always + * settles under its current on-chain policy. The monitor's config reconciliation mirrors + * the applied values into the account row. + */ +async function applyDueFeePolicy(forwarder: Address, effectiveAt: bigint): Promise { + if (effectiveAt === 0n) return; + try { + const client = getPublicClient(); + const { timestamp } = await client.getBlock(); + if (timestamp < effectiveAt) return; + const keeper = getKeeperWalletClient(); + const call = { abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "applyFeePolicy" } as const; + await client.simulateContract(call); + const hash = await withKeeperSendLock(() => keeper.writeContract({ ...call, chain: null })); + const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }); + if (receipt.status !== "success") throw new Error(`transaction ${hash} reverted`); + logger.info(`monerium-b2b: applied the pending fee policy on forwarder ${forwarder} (${hash})`); + } catch (error) { + logger.warn(`monerium-b2b: applyFeePolicy on forwarder ${forwarder} failed: ${errorText(error)}`); + } +} + /** * Marks a settling deposit for the refund path. Under the forwarder lock so it cannot * race a chunk swap being reserved; the keeper then sends `recover` once the clone's From c807e60c796612d4c511443f435c64d50f1807e6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:43:05 +0200 Subject: [PATCH 2/5] docs(api): document the keeper's fee-policy apply step --- docs/architecture-monerium-b2b-onramp.md | 3 +++ docs/security-spec/05-integrations/monerium-b2b.md | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index b3d080083..00a7cb3d7 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -431,6 +431,9 @@ contract settles every fill into three bands against that reference (decisions: (permissionlessly) only after the 24 h `FEE_INCREASE_TIMELOCK`, so a client whose SEPA transfer is already in flight cannot be swapped under a silently worse policy; lowering is immediate (registry P11). Swaps always use the currently applied policy. + Once an increase is due, the keeper sends `applyFeePolicy()` in the account's next + cycle, before pricing, and waits for it; a revert or timeout is logged and the cycle + goes on. - **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded from the treasury. It pays only when called by a factory-registered clone, to the clone itself (the subsidy is forwarded with the payment), within a guardian-settable diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 377e03340..76b6fd38a 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -35,14 +35,14 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CYCLE_SECONDS`, default 20: webhook inbox → onboarding → mint watcher → per-account conversion executor → dormancy gate → refund deadlines and orchestrator → partner registrations → manager events and monitoring) holds signing keys and submits transactions; the registration step runs after the money steps and isolated, so a slow Monerium or RPC read never holds back a conversion or refund. Its invariants: -1. **Three-way key separation, bounded fund movement** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`, `swap()`, `forward()` and `recover()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can redirect funds: `swap` only executes a factory-whitelisted, oracle-floored swap whose USDC stays on the clone, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `forward` only ever pays the clone's own `destination`; `recover` only ever pays the clone's `recoveryAddress`, the client's refund wallet fixed at deployment with no setter, and only once the clone's batch marker has been open for `RECOVERY_DELAY` (the contract, not the keeper, enforces the promised window); `setGuardianPaused` is protective-only by contract invariant (it blocks swaps and forwards, never a recovery); the attestor signs the fixed link statement. Besides the guardian, an address the guardian registers on the factory as a deployer (`setDeployer`) may call `deployForwarder` and no other factory or clone function, so deploying needs no guardian signature; the backend's deployer key (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, distinct from the other keys) holds that role for partner registrations (invariant 12). The role is not harmless, though: every clone a deployer deploys is registered (`isForwarder`), the trust anchor of the subsidy vault's `pay` and of the backend's mapping check, and a deployer may pick any destination and recovery address. A leaked deployer key can therefore create registered clones of its own, fund them itself and draw vault subsidies within the vault's per-swap cap and daily budget; it can touch no existing clone's funds or configuration. The guardian bounds it: `setDeployer(address, false)` stops new deployments but does not unregister clones already deployed, and `revokeForwarder(clone)` (guardian-only, one-way, emits `ForwarderRevoked`, reverts `UnknownForwarder` for an unregistered address) removes one from the registry, after which the vault refuses its subsidy calls, so a swap that needs a subsidy reverts, and the backend refuses to map it (the config reconciliation, R07, reports the revoked clone of an onboarding or active account as an error); its forward and recover paths and its funds are untouched. The factory binds each clone's CREATE2 address to its whole initialization, `keccak256(abi.encode(destination, recoveryAddress, targetPpm, floorPpm, salt))`, so `predictAddress` takes all five arguments (manual deploys too) and a deployer cannot squat an address predicted for other settings. Deployers survive a guardian transfer like keepers: a new guardian must review the `DeployerSet` events and reset them. The role takes the guardian key out of deployment only, not off the server: the dormancy pause (invariant 6) still signs with it. A clone still passes the account mapping's on-chain verification (trusted factory, `isForwarder`, destination, fee policy, derived refund wallet) before it is used. Vortex therefore holds a client's funds only on the refund path, only in that client's own refund wallet, and only for a payment the promised window was missed on (adr-0005, amendments 2026-09-17 and 2026-10-01). All keys are env-only and never logged. +1. **Three-way key separation, bounded fund movement** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`, `applyFeePolicy()`, `swap()`, `forward()` and `recover()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can redirect funds: `swap` only executes a factory-whitelisted, oracle-floored swap whose USDC stays on the clone, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `forward` only ever pays the clone's own `destination`; `recover` only ever pays the clone's `recoveryAddress`, the client's refund wallet fixed at deployment with no setter, and only once the clone's batch marker has been open for `RECOVERY_DELAY` (the contract, not the keeper, enforces the promised window); `setGuardianPaused` is protective-only by contract invariant (it blocks swaps and forwards, never a recovery); the attestor signs the fixed link statement. Besides the guardian, an address the guardian registers on the factory as a deployer (`setDeployer`) may call `deployForwarder` and no other factory or clone function, so deploying needs no guardian signature; the backend's deployer key (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, distinct from the other keys) holds that role for partner registrations (invariant 12). The role is not harmless, though: every clone a deployer deploys is registered (`isForwarder`), the trust anchor of the subsidy vault's `pay` and of the backend's mapping check, and a deployer may pick any destination and recovery address. A leaked deployer key can therefore create registered clones of its own, fund them itself and draw vault subsidies within the vault's per-swap cap and daily budget; it can touch no existing clone's funds or configuration. The guardian bounds it: `setDeployer(address, false)` stops new deployments but does not unregister clones already deployed, and `revokeForwarder(clone)` (guardian-only, one-way, emits `ForwarderRevoked`, reverts `UnknownForwarder` for an unregistered address) removes one from the registry, after which the vault refuses its subsidy calls, so a swap that needs a subsidy reverts, and the backend refuses to map it (the config reconciliation, R07, reports the revoked clone of an onboarding or active account as an error); its forward and recover paths and its funds are untouched. The factory binds each clone's CREATE2 address to its whole initialization, `keccak256(abi.encode(destination, recoveryAddress, targetPpm, floorPpm, salt))`, so `predictAddress` takes all five arguments (manual deploys too) and a deployer cannot squat an address predicted for other settings. Deployers survive a guardian transfer like keepers: a new guardian must review the `DeployerSet` events and reset them. The role takes the guardian key out of deployment only, not off the server: the dormancy pause (invariant 6) still signs with it. A clone still passes the account mapping's on-chain verification (trusted factory, `isForwarder`, destination, fee policy, derived refund wallet) before it is used. Vortex therefore holds a client's funds only on the refund path, only in that client's own refund wallet, and only for a payment the promised window was missed on (adr-0005, amendments 2026-09-17 and 2026-10-01). All keys are env-only and never logged. 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. ## Monitoring @@ -113,6 +113,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) - [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; no row is failed on elapsed time alone after nonce persistence (the only elapsed-time actions are the identical re-send of a never-mined reserved nonce and, when its call no longer executes or its account is not convertible, the consumption of that nonce by a zero-value no-op; the row fails only once the nonce is seen consumed); exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` - [ ] Every execution carries `kind` and `deposit_id` (migration 080); the migration refuses an execution that spanned several deposits under the former allocation join instead of guessing +- [ ] A due fee increase is applied once per cycle before pricing, nothing is sent while none is pending or its timelock runs, and a reverted apply is logged without stopping the cycle (`conversion-executor.test.ts`) - [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`) - [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch - [ ] `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` is the factory queried during provisioning/monitoring, and a self-reported mismatch is rejected before local persistence From 2b520223a5b89d60cb811632bb06fd50b165ad95 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:21 +0200 Subject: [PATCH 3/5] fix(api): skip the fee-policy apply while a keeper send is unmined A timed-out apply left the increase looking pending, so every later cycle re-sent it, stacked keeper nonces and held the worker loop for another receipt timeout. The test also pins that the apply lands before the swap reads the fee policy. --- .../monerium-b2b/conversion-executor.test.ts | 33 ++++++++++++++++--- .../monerium-b2b/conversion-executor.ts | 18 +++++++--- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 3f3540388..ed6ce578c 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 @@ -1010,6 +1010,8 @@ describe("runConversionExecutor fee-policy apply", () => { let reads: Record; let writes: string[]; let writeError: Error | null; + let nonces: { latest: number; pending: number }; + let receiptTimesOut: boolean; beforeAll(async () => { config.moneriumB2b.rpcUrl = undefined; @@ -1026,6 +1028,8 @@ describe("runConversionExecutor fee-policy apply", () => { await resetTestDatabase(); writes = []; writeError = null; + nonces = { latest: 0, pending: 0 }; + receiptTimesOut = false; reads = { batchOpenedAt: 1n, floorPpm: 1_500, @@ -1045,16 +1049,24 @@ describe("runConversionExecutor fee-policy apply", () => { } as unknown as chain.ForwarderImmutables); spyOn(chain, "getPublicClient").mockReturnValue({ getBlock: async () => ({ timestamp: NOW }), - readContract: async ({ address, functionName }: { address: Address; functionName: string }) => - functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName], + getTransactionCount: async ({ blockTag }: { blockTag: "latest" | "pending" }) => nonces[blockTag], + readContract: async ({ address, functionName }: { address: Address; functionName: string }) => { + if (functionName === "targetPpm") writes.push("read targetPpm"); + return functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName]; + }, simulateContract: async () => ({}), - waitForTransactionReceipt: async () => ({ status: "success" }) + waitForTransactionReceipt: async () => { + if (receiptTimesOut) throw new Error("WaitForTransactionReceiptTimeoutError"); + nonces.latest = nonces.pending; + return { status: "success" }; + } } as unknown as ReturnType); spyOn(chain, "getKeeperWalletClient").mockReturnValue({ account: { address: "0x9999999999999999999999999999999999999999" }, writeContract: async ({ functionName }: { functionName: string }) => { if (writeError) throw writeError; writes.push(functionName); + nonces.pending += 1; return "0xapply"; } } as unknown as ReturnType); @@ -1102,16 +1114,27 @@ describe("runConversionExecutor fee-policy apply", () => { await runConversionExecutor(accountId); - expect(writes).toEqual(["applyFeePolicy"]); + expect(writes).toEqual(["applyFeePolicy", "read targetPpm"]); expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); // the cycle went on }); + it("does not re-send while an earlier apply is still unmined", async () => { + reads.pendingFeePolicyEffectiveAt = NOW; + receiptTimesOut = true; + const { accountId } = await activeAccountWithDeposit(); + + await runConversionExecutor(accountId); // sent, receipt wait times out + await runConversionExecutor(accountId); // still looks pending on chain + + expect(writes.filter(write => write === "applyFeePolicy")).toEqual(["applyFeePolicy"]); + }); + it("sends nothing while no increase is pending or its timelock still runs", async () => { const { accountId } = await activeAccountWithDeposit(); await runConversionExecutor(accountId); reads.pendingFeePolicyEffectiveAt = NOW + 1n; await runConversionExecutor(accountId); - expect(writes).toEqual([]); + expect(writes.filter(write => write === "applyFeePolicy")).toEqual([]); }); it("logs a reverted apply and carries on with the cycle", async () => { diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index 8e98e1c9d..52f4f4d18 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1396,9 +1396,10 @@ async function sendPoke(forwarder: Address): Promise { /** * P11: finalizes a guardian fee increase whose timelock has elapsed (`applyFeePolicy` is * permissionless; nothing else would ever apply it). Waits for the receipt so the cycle - * then reads the applied policy. Best-effort like sendPoke: a revert (applied by someone - * else, clock skew) or a timeout is logged and the cycle goes on, since the contract always - * settles under its current on-chain policy. The monitor's config reconciliation mirrors + * then reads the applied policy, and skips while any keeper transaction is still unmined so + * a timed-out apply is not re-sent every cycle. Best-effort like sendPoke: a revert (applied + * by someone else, clock skew) or a timeout is logged and the cycle goes on, since the + * contract always settles under its current on-chain policy. The monitor's config reconciliation mirrors * the applied values into the account row. */ async function applyDueFeePolicy(forwarder: Address, effectiveAt: bigint): Promise { @@ -1410,7 +1411,16 @@ async function applyDueFeePolicy(forwarder: Address, effectiveAt: bigint): Promi const keeper = getKeeperWalletClient(); const call = { abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "applyFeePolicy" } as const; await client.simulateContract(call); - const hash = await withKeeperSendLock(() => keeper.writeContract({ ...call, chain: null })); + const hash = await withKeeperSendLock(async () => { + const [latest, pending] = await Promise.all([ + client.getTransactionCount({ address: keeper.account.address, blockTag: "latest" }), + client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }) + ]); + // An earlier apply (or any keeper send) still unmined: re-sending would only stack nonces. + if (pending !== latest) return null; + return keeper.writeContract({ ...call, chain: null }); + }); + if (hash === null) return; const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }); if (receipt.status !== "success") throw new Error(`transaction ${hash} reverted`); logger.info(`monerium-b2b: applied the pending fee policy on forwarder ${forwarder} (${hash})`); From d07c34b89a9d8ab949e7c5744d7b6948f804da4a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:22 +0200 Subject: [PATCH 4/5] test(api): stub the fee-policy read in the recovery executor fixture Without it the fixture fell into the apply path and logged a spurious getBlock failure on every run. --- apps/api/src/api/services/monerium-b2b/recovery.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/api/services/monerium-b2b/recovery.test.ts b/apps/api/src/api/services/monerium-b2b/recovery.test.ts index 73ed78e0c..f5346f004 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.test.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.test.ts @@ -1229,6 +1229,7 @@ describe("refund deadlines and orchestration", () => { balanceOf: 100n * EUR, batchOpenedAt: 1_000n, // long past the recovery delay minSwapAmount: 25n * EUR, + pendingFeePolicyEffectiveAt: 0n, perSwapCap: 10_000n * EUR }; spies.push( From b4ed39f5e8eac41fde026b6784b52b24e008a13e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 18:49:22 +0200 Subject: [PATCH 5/5] docs(api): note the unmined guard and idle-clone timing of fee applies --- docs/architecture-monerium-b2b-onramp.md | 4 +++- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 00a7cb3d7..857c947dc 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -433,7 +433,9 @@ contract settles every fill into three bands against that reference (decisions: lowering is immediate (registry P11). Swaps always use the currently applied policy. Once an increase is due, the keeper sends `applyFeePolicy()` in the account's next cycle, before pricing, and waits for it; a revert or timeout is logged and the cycle - goes on. + goes on, and no apply is re-sent while a keeper transaction is still unmined. The + executor only runs for accounts with a settling deposit, so an idle clone's increase + is applied before its next swap. - **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded from the treasury. It pays only when called by a factory-registered clone, to the clone itself (the subsidy is forwarded with the payment), within a guardian-settable diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 76b6fd38a..5e74e4592 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -42,7 +42,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. A guardian fee increase (registry P11) takes effect only through the permissionless `applyFeePolicy()` once `FEE_INCREASE_TIMELOCK` (24 h) has elapsed: in each account cycle, before pricing, the keeper sends it when the latest block's timestamp has reached `pendingFeePolicyEffectiveAt` and waits for the receipt, so the cycle's swap is projected under the policy it settles at; it skips the send while any keeper transaction is still unmined (latest and pending nonce differ), so a timed-out apply is not re-sent and stacked every cycle; a revert (already applied by someone else, clock skew) or a timeout is logged and the cycle goes on, because the contract always settles under its currently applied policy. The account row follows through the config reconciliation (Monitoring invariant 5). The keeper only runs for accounts with a settling deposit, so a due increase on an idle clone is applied before that clone's next swap, not at the deadline. ## Monitoring