From 3cad47f67e336bbddc41247e34843ff967b0adf0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:03:03 +0200 Subject: [PATCH 01/44] docs(repo): add the forwarder fee and subsidy implementation plan --- docs/README.md | 1 + ...proposal-monerium-forwarder-fee-subsidy.md | 114 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 docs/proposal-monerium-forwarder-fee-subsidy.md diff --git a/docs/README.md b/docs/README.md index b767fa270..9fc651352 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | | [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md) | Phase-2 proposal for the consumer (Safe + passkey) Monerium onramp; the B2B variant shipped | +| [`proposal-monerium-forwarder-fee-subsidy.md`](proposal-monerium-forwarder-fee-subsidy.md) | Implementation plan for reference-priced fee bands, the subsidy vault, configurable swap routes and the 7 day sweep in the B2B forwarder | | [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | | [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | diff --git a/docs/proposal-monerium-forwarder-fee-subsidy.md b/docs/proposal-monerium-forwarder-fee-subsidy.md new file mode 100644 index 000000000..d93676f61 --- /dev/null +++ b/docs/proposal-monerium-forwarder-fee-subsidy.md @@ -0,0 +1,114 @@ +# Proposal: reference-priced fees and subsidies for the Monerium B2B forwarder + +Status: implementation plan, agreed 2026-09-15. The forwarder contracts are not deployed, +so every change below lands in the current contract before launch; no clone migration is +involved. Parameter decisions fold into `adr-0005-monerium-b2b-onramp.md` once shipped. + +## Context + +The partner agreement fixes the client's rate against a reference: the client receives +the Coinbase EURC-USD reference minus 12.5 bps, and never worse than 15 bps below it. +Today `feeBps` is a flat skim on whatever the DEX returns, and nothing tops a poor fill +up. This proposal moves the fee to a band model enforced on chain, adds a treasury-funded +subsidy vault, makes the swap route configurable within bounds, and records three +decisions taken alongside: no payment bouncing, no guardian-triggered fallback sweep, and +a 7 day dead-man sweep. + +## Decisions + +- **No bouncing.** Returning EURe to a bank account is a Monerium redeem order, which + needs the forwarder to approve the payout message via EIP-1271. The forwarder approves + exactly one message, the link message, so no key can move funds to fiat. Adding a + redeem path would let whoever holds the whitelabel credentials plus the attestor key + drain every clone to an arbitrary IBAN. Liquidity problems are handled by waiting, + lowering `perSwapCap`, and Monerium's own recovery for compliance cases. +- **No guardian sweep to the fallback address.** Only the client (via `sweep`) and the + permissionless dead-man sweep move EURe to the fallback. A guardian shortcut would + weaken "Vortex keys cannot move client funds". +- **Dead-man sweep delay (P3) becomes 7 days.** Because the sweep ignores pauses, 7 days + is also the longest any Vortex-side hold can last; token-level freezes are Monerium's + lever. Dormancy (P5) stays at 60 days; the two windows are independent. +- **Accepted limitation.** After the 24 h permissionless trigger anyone may execute the + swap. That path prices against Chainlink and pays no subsidy, so a forced swap can land + below the 15 bps floor. The rate guarantee applies to keeper-executed swaps; the partner + terms say so. + +## Parameters + +| Parameter | Decision | +|---|---| +| Fee unit | parts per million (ppm) | +| Target, per clone | 1250 ppm; increases behind the 24 h timelock | +| Floor, per clone | 1500 ppm; same timelock; floor >= target | +| Max fee and max floor, immutable | 10000 ppm | +| Reference source | Coinbase Exchange EURC-USD ticker, fetched by the keeper per swap; price, timestamp and trade id stored per execution | +| Reference band vs Chainlink, immutable | 100 bps (to confirm; must survive weekend Chainlink staleness) | +| Oracle floor, immutable | 40 bps on the client's net after fee and subsidy; router minimum set to zero, the post-condition is the guard | +| Max subsidy per swap | guardian-settable on the vault, default 50 bps of the reference value | +| Daily subsidy budget | guardian-settable on the vault, default 200 USDC, UTC day bucket; one vault shared by all clones | +| Vault cannot cover | `pay` reverts, the whole swap reverts, funds wait, alert | +| Vault withdrawals | treasury only, immutable | +| Swap routes | guardian-managed whitelist on the factory, validated on chain, caller-selected index, no timelock | +| Route validation | hop tokens only EURe, EURC, USDC; Uniswap tiers 100/500/3000/10000; at most two hops; immutable router | +| Sweep delay (P3) | 7 days | +| Dormancy window (P5) | 60 days, unchanged | + +Known tuning consequence: at the EUR 25k per-swap cap a worst-case subsidy is about +135 USDC, so the 200 USDC budget covers roughly one and a half such swaps per day across +all clients before the executor starts deferring. Both limits are live-adjustable. + +## Contract design + +Per swap the caller passes a reference rate (Chainlink decimals) and a route index. + +1. Chainlink price is read with the existing staleness and sign checks. A privileged + caller's reference must lie within `MAX_REFERENCE_DEVIATION_BPS` of it; a + permissionless caller's argument is ignored and Chainlink is the reference. +2. The whitelisted route is executed through the immutable router with a zero minimum + output. Atomic delta checks stay. +3. With `referenceOut = amountIn x reference`, `targetOut = referenceOut x (1 - target)` + and `floorOut = referenceOut x (1 - floor)`: + - output above `targetOut`: fee = output - targetOut, capped at `MAX_FEE_PPM`; + - output between `floorOut` and `targetOut`: no fee, no subsidy; + - output below `floorOut`: no fee; a privileged swap pulls `floorOut - output` from the + vault straight to the destination, a permissionless swap pays nothing. +4. Post-condition: output - fee + subsidy >= Chainlink value x (1 - `SLIPPAGE_BPS`), + otherwise revert. This catches a bad reference or an EURC depeg that a subsidy would + otherwise paper over. +5. `SwapExecuted` carries reference, route index, fee, subsidy and forwarded amount. + +One subsidy vault, shared by every clone, holds treasury-funded USDC, pays only when +called by a factory-registered clone, enforces the per-swap cap and the daily budget, can be paused, +and can be withdrawn only to the treasury. The factory holds the vault address and the +route list, both guardian-managed with events. + +The subsidy widens the band a sandwich attacker can exploit from the floor to floor plus +the per-swap cap, paid by the vault. Keeper swaps keep going through the private relay; +the permissionless path has no subsidy and keeps the plain floor. + +## Phases + +1. **Contracts.** Vault; factory route whitelist and vault hook; forwarder fee policy in + ppm with shared timelock, reference and route arguments, band check, fee bands, vault + call, post-subsidy floor, extended event; 7 day sweep delay in fixtures; unit, invariant + and fork suites; manifest scripts. +2. **Backend.** Reference module (Coinbase ticker, stored per execution; no reference + means no swap). Executor quotes every whitelisted route, projects fee and subsidy, + defers while the projected subsidy exceeds the per-swap cap or the remaining budget, + passes reference and index over the private relay, parses the new event. Execution + rows gain reference, route and subsidy; net becomes gross - fee + subsidy. Monitoring: + vault runway, reference divergence, per-route quotes, stranding warning before the + sweep. Provisioning takes ppm inputs. Converted event and deposits endpoint expose + fee, subsidy and reference. Later: a minimum deposit age before budget may be spent. +3. **Docs.** ADR-0005 dated amendment (fee model, vault, reference, routes, P3, the + decisions above); narrow "new routes need a new implementation" to new tokens or a + new router; security spec pinned-path invariant becomes a validated route set with + the floor as the bound, plus the sandwich-band note; architecture fees section, + runbook, rollout terms, API docs. +4. **Rollout.** Sepolia validation with a funded vault and two routes, then mainnet + deployment of implementation, factory and vault, vault funding, client onboarding. + +## Open items + +- Reference band value against Chainlink. +- Pacing rule for subsidies when many clients ramp at once (backend, later). From 1b11b3de0deb23b3eef40433fc6e3fbafe92087d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:04:38 +0200 Subject: [PATCH 02/44] feat(repo): add the shared subsidy vault for forwarder swaps --- .../src/VortexForwarder.sol | 2 + .../src/VortexForwarderFactory.sol | 11 ++ .../src/VortexSubsidyVault.sol | 115 ++++++++++++++++ .../test/VortexSubsidyVault.t.sol | 130 ++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 contracts/monerium-forwarder/src/VortexSubsidyVault.sol create mode 100644 contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index 88689bbba..1f1149b26 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -36,6 +36,8 @@ interface IVortexForwarderFactory { function minSwapAmount() external view returns (uint256); function perSwapCap() external view returns (uint256); function MIN_SWAP_FLOOR() external view returns (uint256); + function isForwarder(address account) external view returns (bool); + function subsidyVault() external view returns (address); } /// @title VortexForwarder diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol index 0e12fde06..021347648 100644 --- a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -25,6 +25,11 @@ contract VortexForwarderFactory { mapping(address => bool) public isForwarder; + /// @notice The VortexSubsidyVault clones draw from; address(0) disables subsidies. + /// Guardian-settable without a timelock: the vault only ever pays Vortex + /// money to a clone's fixed destination, so a swap cannot be harmed by it. + address public subsidyVault; + event ForwarderDeployed( address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt ); @@ -32,6 +37,7 @@ contract VortexForwarderFactory { event GlobalPausedSet(bool paused); event MinSwapAmountSet(uint256 value); event PerSwapCapSet(uint256 value); + event SubsidyVaultSet(address indexed vault); event GuardianTransferStarted(address indexed current, address indexed pending); event GuardianTransferred(address indexed previous, address indexed current); @@ -101,6 +107,11 @@ contract VortexForwarderFactory { _setPerSwapCap(value); } + function setSubsidyVault(address vault) external onlyGuardian { + subsidyVault = vault; + emit SubsidyVaultSet(vault); + } + /// @dev Two-step transfer: guardian is load-bearing for every clone's pause and /// keeper gating, so a fat-fingered transfer must not be possible. function transferGuardian(address newGuardian) external onlyGuardian { diff --git a/contracts/monerium-forwarder/src/VortexSubsidyVault.sol b/contracts/monerium-forwarder/src/VortexSubsidyVault.sol new file mode 100644 index 000000000..f6eb0aee3 --- /dev/null +++ b/contracts/monerium-forwarder/src/VortexSubsidyVault.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IERC20, IVortexForwarderFactory} from "./VortexForwarder.sol"; + +/// @title VortexSubsidyVault +/// @notice Treasury-funded USDC pool that tops a forwarder swap up to the client's floor +/// rate (docs/proposal-monerium-forwarder-fee-subsidy.md). Only factory-registered +/// clones can draw; every draw is bounded by a per-swap cap (ppm of the swap's +/// reference value) and a daily budget; withdrawals can only go back to the +/// treasury. The vault never holds client funds — it only ever pushes Vortex +/// money to a clone's destination — so its guardian-settable limits bound Vortex's +/// exposure, not the client's. +contract VortexSubsidyVault { + uint32 private constant PPM = 1_000_000; + + IERC20 public immutable USDC; + address public immutable TREASURY; + IVortexForwarderFactory public immutable FACTORY; + + /// @notice Per-swap cap, relative to the swap's reference value (amountIn x reference). + uint32 public maxSubsidyPpm; + /// @notice USDC base units the vault may pay out per UTC day. + uint256 public dailyBudget; + /// @dev UTC day index (block.timestamp / 1 days) the `spentToday` counter belongs to. + uint256 public currentDay; + uint256 public spentToday; + bool public paused; + + event SubsidyPaid(address indexed forwarder, address indexed to, uint256 amount); + event MaxSubsidyPpmSet(uint32 value); + event DailyBudgetSet(uint256 value); + event PausedSet(bool paused); + event Withdrawn(uint256 amount); + + error NotGuardian(); + error NotForwarder(); + error VaultPaused(); + error SubsidyCapExceeded(); + error BudgetExhausted(); + error ZeroAddress(); + error TransferFailed(); + + modifier onlyGuardian() { + if (msg.sender != FACTORY.guardian()) revert NotGuardian(); + _; + } + + constructor( + IERC20 usdc, + address treasury, + IVortexForwarderFactory factory, + uint32 initialMaxSubsidyPpm, + uint256 initialDailyBudget + ) { + if (address(usdc) == address(0) || treasury == address(0) || address(factory) == address(0)) { + revert ZeroAddress(); + } + USDC = usdc; + TREASURY = treasury; + FACTORY = factory; + maxSubsidyPpm = initialMaxSubsidyPpm; + dailyBudget = initialDailyBudget; + } + + /// @notice Pays `amount` USDC to `to` on behalf of the calling clone. Reverts — and + /// with it the clone's whole swap — whenever the cap, the budget, the pause + /// or the balance cannot cover it, so a swap is never partially subsidized. + /// @param referenceOut The swap's reference value in USDC base units; the cap basis. + function pay(address to, uint256 amount, uint256 referenceOut) external { + if (!FACTORY.isForwarder(msg.sender)) revert NotForwarder(); + if (paused) revert VaultPaused(); + if (amount > (referenceOut * maxSubsidyPpm) / PPM) revert SubsidyCapExceeded(); + + uint256 day = block.timestamp / 1 days; + if (day != currentDay) { + currentDay = day; + spentToday = 0; + } + if (spentToday + amount > dailyBudget) revert BudgetExhausted(); + spentToday += amount; + + _transfer(to, amount); + emit SubsidyPaid(msg.sender, to, amount); + } + + // ----------------------------------------------------------- guardian authority + + function setMaxSubsidyPpm(uint32 value) external onlyGuardian { + maxSubsidyPpm = value; + emit MaxSubsidyPpmSet(value); + } + + function setDailyBudget(uint256 value) external onlyGuardian { + dailyBudget = value; + emit DailyBudgetSet(value); + } + + function setPaused(bool paused_) external onlyGuardian { + paused = paused_; + emit PausedSet(paused_); + } + + /// @notice Returns funds to the treasury. There is no other withdrawal target. + function withdraw(uint256 amount) external onlyGuardian { + _transfer(TREASURY, amount); + emit Withdrawn(amount); + } + + function _transfer(address to, uint256 amount) internal { + if (amount == 0) return; + (bool success, bytes memory data) = address(USDC).call(abi.encodeCall(IERC20.transfer, (to, amount))); + if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } +} diff --git a/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol b/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol new file mode 100644 index 000000000..bf863588d --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {IERC20, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; +import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; +import {MockERC20} from "./VortexForwarder.t.sol"; + +/// Minimal stand-in for the factory: the vault only reads the guardian and the clone registry. +contract MockFactory { + address public guardian; + mapping(address => bool) public isForwarder; + + constructor(address guardian_) { + guardian = guardian_; + } + + function register(address forwarder, bool enabled) external { + isForwarder[forwarder] = enabled; + } +} + +contract VortexSubsidyVaultTest is Test { + MockERC20 usdc; + MockFactory factory; + VortexSubsidyVault vault; + + address treasury = makeAddr("treasury"); + address forwarder = makeAddr("forwarder"); + address destination = makeAddr("destination"); + address rando = makeAddr("rando"); + + uint32 constant MAX_SUBSIDY_PPM = 5_000; // 50 bps + uint256 constant DAILY_BUDGET = 100e6; // 100 USDC + uint256 constant REFERENCE_OUT = 10_000e6; // a EUR 10k swap at reference -> cap = 50 USDC + + function setUp() public { + usdc = new MockERC20("USDC", 6); + factory = new MockFactory(address(this)); + factory.register(forwarder, true); + vault = new VortexSubsidyVault( + IERC20(address(usdc)), treasury, IVortexForwarderFactory(address(factory)), MAX_SUBSIDY_PPM, DAILY_BUDGET + ); + usdc.mint(address(vault), 1_000e6); + } + + function test_pay_onlyRegisteredForwarders() public { + vm.prank(rando); + vm.expectRevert(VortexSubsidyVault.NotForwarder.selector); + vault.pay(destination, 1e6, REFERENCE_OUT); + + factory.register(forwarder, false); + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.NotForwarder.selector); + vault.pay(destination, 1e6, REFERENCE_OUT); + } + + function test_pay_transfersAndCountsAgainstTheDay() public { + vm.prank(forwarder); + vault.pay(destination, 30e6, REFERENCE_OUT); + assertEq(usdc.balanceOf(destination), 30e6); + assertEq(vault.spentToday(), 30e6); + assertEq(vault.currentDay(), block.timestamp / 1 days); + } + + function test_pay_enforcesPerSwapCap() public { + vm.prank(forwarder); + vault.pay(destination, 50e6, REFERENCE_OUT); // exactly the cap is fine + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); + vault.pay(destination, 50e6 + 1, REFERENCE_OUT); + } + + function test_pay_enforcesDailyBudget_andResetsNextDay() public { + vm.startPrank(forwarder); + vault.pay(destination, 50e6, REFERENCE_OUT); + vault.pay(destination, 50e6, REFERENCE_OUT); // budget fully used + vm.expectRevert(VortexSubsidyVault.BudgetExhausted.selector); + vault.pay(destination, 1, REFERENCE_OUT); + + vm.warp((block.timestamp / 1 days + 1) * 1 days); // next UTC day + vault.pay(destination, 50e6, REFERENCE_OUT); + assertEq(vault.spentToday(), 50e6); + vm.stopPrank(); + } + + function test_pay_revertsWhenPausedOrUnderfunded() public { + vault.setPaused(true); + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.VaultPaused.selector); + vault.pay(destination, 1e6, REFERENCE_OUT); + vault.setPaused(false); + + vault.withdraw(1_000e6); // drain to treasury + assertEq(usdc.balanceOf(treasury), 1_000e6); + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.TransferFailed.selector); + vault.pay(destination, 1e6, REFERENCE_OUT); + } + + function test_guardianAuthority_gated() public { + vm.startPrank(rando); + vm.expectRevert(VortexSubsidyVault.NotGuardian.selector); + vault.setMaxSubsidyPpm(1); + vm.expectRevert(VortexSubsidyVault.NotGuardian.selector); + vault.setDailyBudget(1); + vm.expectRevert(VortexSubsidyVault.NotGuardian.selector); + vault.setPaused(true); + vm.expectRevert(VortexSubsidyVault.NotGuardian.selector); + vault.withdraw(1); + vm.stopPrank(); + + vault.setMaxSubsidyPpm(1_000); + vault.setDailyBudget(1e6); + assertEq(vault.maxSubsidyPpm(), 1_000); + assertEq(vault.dailyBudget(), 1e6); + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); + vault.pay(destination, 10e6 + 1, REFERENCE_OUT); // new cap: 10 USDC + } + + function test_withdraw_onlyEverReachesTreasury() public { + vault.withdraw(400e6); + assertEq(usdc.balanceOf(treasury), 400e6); + assertEq(usdc.balanceOf(address(vault)), 600e6); + // There is no withdrawal signature that takes a recipient. + (bool ok,) = address(vault).call(abi.encodeWithSignature("withdraw(address,uint256)", rando, 1)); + assertFalse(ok); + } +} From f0bbeb00e0ecb81d528ab8598036a6c77e383c7f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:07:31 +0200 Subject: [PATCH 03/44] feat(repo): let the guardian whitelist forwarder swap routes --- .../src/VortexForwarder.sol | 28 ++-- .../src/VortexForwarderFactory.sol | 89 +++++++++++- .../test/VortexForwarder.fork.t.sol | 23 +-- .../test/VortexForwarder.invariants.t.sol | 7 +- .../test/VortexForwarder.t.sol | 132 ++++++++++++++---- 5 files changed, 225 insertions(+), 54 deletions(-) diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index 1f1149b26..b8b127ea5 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -38,6 +38,7 @@ interface IVortexForwarderFactory { function MIN_SWAP_FLOOR() external view returns (uint256); function isForwarder(address account) external view returns (bool); function subsidyVault() external view returns (address); + function route(uint256 index) external view returns (bytes memory path, bool enabled); } /// @title VortexForwarder @@ -46,7 +47,7 @@ interface IVortexForwarderFactory { /// Deployed as an EIP-1167 clone by VortexForwarderFactory; the clone address is /// linked to the client's Monerium profile, EURe mints land here, and the only /// ways assets can ever leave are: -/// 1. the pinned EURe -> EURC -> USDC swap (oracle-checked minOut, output to self), +/// 1. a factory-whitelisted EURe -> USDC swap (oracle-checked minOut, output to self), /// 2. USDC to the client's `destination` (plus fee <= feeBps to FEE_RECIPIENT), /// 3. EURe to the client's `fallbackAddress` (delayed permissionless sweep), /// 4. anything, by the client's `fallbackAddress` itself (`sweep`). @@ -80,8 +81,6 @@ contract VortexForwarder { uint16 public immutable MAX_FEE_BPS; // registry P2 uint256 public immutable SWEEP_DELAY; // registry P3 uint256 public immutable TRIGGER_DELAY; // registry P4 - uint24 public immutable POOL_FEE_EURE_EURC; // registry P10 - uint24 public immutable POOL_FEE_EURC_USDC; // registry P10 /// @dev EIP-191 personal-message hash and raw keccak of LINK_MESSAGE. Monerium's /// exact hashing scheme is a G0 spike output (task 4); accepting both is safe @@ -107,8 +106,6 @@ contract VortexForwarder { uint16 maxFeeBps; uint256 sweepDelay; uint256 triggerDelay; - uint24 poolFeeEureEurc; - uint24 poolFeeEurcUsdc; bytes32 recoveryHash; } @@ -142,7 +139,9 @@ contract VortexForwarder { event FeeBpsIncreaseApplied(uint16 previous, uint16 current); event FeeBpsIncreaseCancelled(uint16 pending); event Poked(uint64 strandedSince); - event SwapExecuted(address indexed caller, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded); + event SwapExecuted( + address indexed caller, uint256 routeIndex, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded + ); event StrandedEureSwept(address indexed caller, uint256 amount); event DestinationUpdated(address previous, address current); event FallbackAddressUpdated(address previous, address current); @@ -171,6 +170,7 @@ contract VortexForwarder { error DelayNotElapsed(); error TransferFailed(); error Reentrancy(); + error InvalidRoute(); // ------------------------------------------------------------ constructor @@ -189,8 +189,6 @@ contract VortexForwarder { MAX_FEE_BPS = cfg.maxFeeBps; SWEEP_DELAY = cfg.sweepDelay; TRIGGER_DELAY = cfg.triggerDelay; - POOL_FEE_EURE_EURC = cfg.poolFeeEureEurc; - POOL_FEE_EURC_USDC = cfg.poolFeeEurcUsdc; RECOVERY_HASH = cfg.recoveryHash; LINK_HASH_191 = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n45", LINK_MESSAGE)); @@ -298,8 +296,14 @@ contract VortexForwarder { /// @notice Convert EURe held by this contract to USDC and forward to `destination`. /// Callable by guardian/keepers any time; by anyone once the stranding /// marker is older than TRIGGER_DELAY (liveness fallback). - function swapAndForward() external nonReentrant { + /// @param routeIndex Which factory-whitelisted route to execute. The keeper quotes + /// every enabled route off-chain and picks the best; a poor pick only ever + /// costs Vortex (more subsidy, less fee), never the client, whose outcome is + /// bounded by the oracle floor whichever route runs. + function swapAndForward(uint256 routeIndex) external nonReentrant { if (clientPaused || guardianPaused || FACTORY.globalPaused()) revert Paused(); + (bytes memory path, bool routeEnabled) = FACTORY.route(routeIndex); + if (!routeEnabled) revert InvalidRoute(); bool privileged = msg.sender == FACTORY.guardian() || FACTORY.isKeeper(msg.sender); if (!privileged) { @@ -319,9 +323,7 @@ contract VortexForwarder { _approve(EURE, address(ROUTER), amountIn); ROUTER.exactInput( ISwapRouter02.ExactInputParams({ - path: abi.encodePacked( - address(EURE), POOL_FEE_EURE_EURC, address(EURC), POOL_FEE_EURC_USDC, address(USDC) - ), + path: path, recipient: address(this), amountIn: amountIn, amountOutMinimum: minOut @@ -347,7 +349,7 @@ contract VortexForwarder { // P2): otherwise the remainder's dead-man/permissionless timers would silently // restart from zero only after a fresh poke(). strandedSince = EURE.balanceOf(address(this)) >= FACTORY.MIN_SWAP_FLOOR() ? uint64(block.timestamp) : 0; - emit SwapExecuted(msg.sender, amountIn, usdcReceived, fee, forwarded); + emit SwapExecuted(msg.sender, routeIndex, amountIn, usdcReceived, fee, forwarded); } /// @dev minOut = amountIn * price * (1 - slippage), rescaled EURe(18) -> USDC(6). diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol index 021347648..56ddbba5a 100644 --- a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -10,6 +10,11 @@ import {VortexForwarder} from "./VortexForwarder.sol"; contract VortexForwarderFactory { address public immutable implementation; + /// @dev Route validation only ever admits paths between these three tokens. + address public immutable EURE; + address public immutable EURC; + address public immutable USDC; + /// @dev Immutable bounds for the operational parameters (R10): the guardian can /// tune values only inside [floor, ceiling]; the bounds themselves never move. uint256 public immutable MIN_SWAP_FLOOR; @@ -25,6 +30,18 @@ contract VortexForwarderFactory { mapping(address => bool) public isForwarder; + /// @notice Swap routes clones may execute (Uniswap V3 packed paths). Guardian-managed + /// without a timelock: every entry is validated to run only between EURe, EURC + /// and USDC on the implementation's immutable router, and the client's outcome + /// is bounded by the oracle floor whichever route is chosen. Entries are never + /// removed, only disabled, so an index stays stable for the keeper. + struct Route { + bytes path; + bool enabled; + } + + Route[] private _routes; + /// @notice The VortexSubsidyVault clones draw from; address(0) disables subsidies. /// Guardian-settable without a timelock: the vault only ever pays Vortex /// money to a clone's fixed destination, so a swap cannot be harmed by it. @@ -38,6 +55,8 @@ contract VortexForwarderFactory { event MinSwapAmountSet(uint256 value); event PerSwapCapSet(uint256 value); event SubsidyVaultSet(address indexed vault); + event RouteAdded(uint256 indexed index, bytes path); + event RouteEnabledSet(uint256 indexed index, bool enabled); event GuardianTransferStarted(address indexed current, address indexed pending); event GuardianTransferred(address indexed previous, address indexed current); @@ -45,6 +64,7 @@ contract VortexForwarderFactory { error NotPendingGuardian(); error OutOfBounds(); error CloneFailed(); + error InvalidRoute(); modifier onlyGuardian() { if (msg.sender != guardian) revert NotGuardian(); @@ -56,14 +76,19 @@ contract VortexForwarderFactory { uint256 minSwapFloor, uint256 capCeiling, uint256 initialMinSwapAmount, - uint256 initialPerSwapCap + uint256 initialPerSwapCap, + bytes memory initialRoute ) { guardian = msg.sender; implementation = address(new VortexForwarder(cfg)); + EURE = cfg.eure; + EURC = cfg.eurc; + USDC = cfg.usdc; MIN_SWAP_FLOOR = minSwapFloor; CAP_CEILING = capCeiling; _setMinSwapAmount(initialMinSwapAmount); _setPerSwapCap(initialPerSwapCap); + _addRoute(initialRoute); } // ------------------------------------------------------------- deployment @@ -112,6 +137,28 @@ contract VortexForwarderFactory { emit SubsidyVaultSet(vault); } + // ------------------------------------------------------------------ routes + + function addRoute(bytes calldata path) external onlyGuardian returns (uint256 index) { + return _addRoute(path); + } + + function setRouteEnabled(uint256 index, bool enabled) external onlyGuardian { + if (index >= _routes.length) revert InvalidRoute(); + _routes[index].enabled = enabled; + emit RouteEnabledSet(index, enabled); + } + + function routeCount() external view returns (uint256) { + return _routes.length; + } + + function route(uint256 index) external view returns (bytes memory path, bool enabled) { + if (index >= _routes.length) revert InvalidRoute(); + Route storage entry = _routes[index]; + return (entry.path, entry.enabled); + } + /// @dev Two-step transfer: guardian is load-bearing for every clone's pause and /// keeper gating, so a fat-fingered transfer must not be possible. function transferGuardian(address newGuardian) external onlyGuardian { @@ -140,6 +187,46 @@ contract VortexForwarderFactory { emit PerSwapCapSet(value); } + /// @dev Admits only EURe -> USDC or EURe -> EURC -> USDC over Uniswap V3's four fee + /// tiers (packed path: token, fee, token[, fee, token]). Anything else, including + /// any other intermediate token, is rejected so a route can never introduce a + /// token the forwarder does not already trust. + function _addRoute(bytes memory path) internal returns (uint256 index) { + uint256 hops; + if (path.length == 43) hops = 1; + else if (path.length == 66) hops = 2; + else revert InvalidRoute(); + + if (_addressAt(path, 0) != EURE) revert InvalidRoute(); + if (_addressAt(path, path.length - 20) != USDC) revert InvalidRoute(); + if (hops == 2 && _addressAt(path, 23) != EURC) revert InvalidRoute(); + for (uint256 i = 0; i < hops; i++) { + if (!_isKnownFeeTier(_feeAt(path, 20 + i * 23))) revert InvalidRoute(); + } + + index = _routes.length; + _routes.push(Route({path: path, enabled: true})); + emit RouteAdded(index, path); + } + + function _isKnownFeeTier(uint24 fee) internal pure returns (bool) { + return fee == 100 || fee == 500 || fee == 3000 || fee == 10000; + } + + function _addressAt(bytes memory data, uint256 offset) internal pure returns (address value) { + // solhint-disable-next-line no-inline-assembly + assembly { + value := shr(96, mload(add(add(data, 32), offset))) + } + } + + function _feeAt(bytes memory data, uint256 offset) internal pure returns (uint24 value) { + // solhint-disable-next-line no-inline-assembly + assembly { + value := shr(232, mload(add(add(data, 32), offset))) + } + } + /// @dev Standard EIP-1167 minimal proxy init code for `target`. function _cloneInitCode(address target) internal pure returns (bytes memory) { return abi.encodePacked( diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol index aff5591ec..9420473cb 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -27,6 +27,9 @@ contract VortexForwarderForkTest is Test { address constant UNIV3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; // Chainlink EUR/USD proxy — verify against data.chain.link before deploy (registry P8). address constant CHAINLINK_EUR_USD = 0xb49f677943BC038e9857d61E7d053CaA2C1734C1; + // Initial whitelisted route: EURe -> EURC -> USDC on the 5 bps tiers (registry P10). + uint24 constant POOL_FEE_EURE_EURC = 500; + uint24 constant POOL_FEE_EURC_USDC = 500; VortexForwarderFactory factory; VortexForwarder fwd; @@ -58,14 +61,13 @@ contract VortexForwarderForkTest is Test { maxFeeBps: 100, sweepDelay: 60 days, triggerDelay: 24 hours, - poolFeeEureEurc: 500, - poolFeeEurcUsdc: 500, recoveryHash: bytes32(0) }), 1e18, 50_000e18, 25e18, - 10_000e18 + 10_000e18, + abi.encodePacked(EURE_V2, POOL_FEE_EURE_EURC, EURC, POOL_FEE_EURC_USDC, USDC) ); factory.setKeeper(keeper, true); fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); @@ -85,13 +87,16 @@ contract VortexForwarderForkTest is Test { assertGt(updatedAt, 0); } - function test_fork_pinnedPathUsesV2AndPoolsExist() public onlyForked { + function test_fork_initialRouteUsesV2AndPoolsExist() public onlyForked { assertEq(address(fwd.EURE()), EURE_V2); assertTrue(address(fwd.EURE()) != EURE_V1_DEPRECATED, "route must never touch deprecated V1 EURe"); + (bytes memory path, bool enabled) = factory.route(0); + assertTrue(enabled); + assertEq(path, abi.encodePacked(EURE_V2, POOL_FEE_EURE_EURC, EURC, POOL_FEE_EURC_USDC, USDC)); - // Both hops of the pinned path must exist on-chain with the pinned fee tiers. - address hop1 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURE_V2, EURC, fwd.POOL_FEE_EURE_EURC()); - address hop2 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURC, USDC, fwd.POOL_FEE_EURC_USDC()); + // Both hops of the initial route must exist on-chain with the chosen fee tiers. + address hop1 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURE_V2, EURC, POOL_FEE_EURE_EURC); + address hop2 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURC, USDC, POOL_FEE_EURC_USDC); assertTrue(hop1 != address(0), "EURe/EURC pool missing at pinned fee tier"); assertTrue(hop2 != address(0), "EURC/USDC pool missing at pinned fee tier"); // The V2 pool must actually hold V2 tokens (stale-pool trap check). @@ -106,7 +111,7 @@ contract VortexForwarderForkTest is Test { uint256 fair = (amountIn * uint256(answer)) / 1e20; // 6-dec USDC at oracle rate vm.prank(keeper); - fwd.swapAndForward(); + fwd.swapAndForward(0); uint256 received = IERC20Meta(USDC).balanceOf(destination); assertGe(received, (fair * 9_900) / 10_000, "below oracle-bounded minOut"); @@ -118,7 +123,7 @@ contract VortexForwarderForkTest is Test { function test_fork_perSwapCapLeavesRemainder() public onlyForked { deal(EURE_V2, address(fwd), 12_000e18); // cap is 10k vm.prank(keeper); - fwd.swapAndForward(); + fwd.swapAndForward(0); assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 2_000e18); assertGt(IERC20Meta(USDC).balanceOf(destination), 0); } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol index b959d0b9f..7fea0f0df 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -51,14 +51,13 @@ contract ForwarderHandler is Test { maxFeeBps: 100, sweepDelay: 60 days, triggerDelay: 24 hours, - poolFeeEureEurc: 500, - poolFeeEurcUsdc: 500, recoveryHash: bytes32(0) }), 1e18, 50_000e18, 25e18, - 10_000e18 + 10_000e18, + abi.encodePacked(address(eure), uint24(500), address(eurc), uint24(500), address(usdc)) ); factory.setKeeper(keeper, true); fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, INITIAL_FEE_BPS, bytes32(uint256(1)))); @@ -102,7 +101,7 @@ contract ForwarderHandler is Test { uint256 routerUsdcBefore = usdc.totalMinted(); vm.prank(caller); - try fwd.swapAndForward() { + try fwd.swapAndForward(0) { ghostUsdcPaidByRouter += usdc.totalMinted() - routerUsdcBefore; } catch {} } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 049f341f4..009fe4855 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -61,6 +61,7 @@ contract MockRouter { MockERC20 public immutable eure; MockERC20 public immutable usdc; uint256 public nextOut; + bytes public lastPath; constructor(MockERC20 eure_, MockERC20 usdc_) { eure = eure_; @@ -73,6 +74,7 @@ contract MockRouter { function exactInput(ISwapRouter02.ExactInputParams calldata params) external payable returns (uint256) { eure.transferFrom(msg.sender, address(this), params.amountIn); + lastPath = params.path; require(nextOut >= params.amountOutMinimum, "Too little received"); usdc.mint(params.recipient, nextOut); return nextOut; @@ -82,7 +84,7 @@ contract MockRouter { /// Malicious router that tries to re-enter swapAndForward during the swap. contract MockReentrantRouter { function exactInput(ISwapRouter02.ExactInputParams calldata) external payable returns (uint256) { - VortexForwarder(msg.sender).swapAndForward(); // must revert via reentrancy guard + VortexForwarder(msg.sender).swapAndForward(0); // must revert via reentrancy guard return 0; } } @@ -129,14 +131,13 @@ contract VortexForwarderTest is Test { maxFeeBps: 100, sweepDelay: SWEEP_DELAY, triggerDelay: TRIGGER_DELAY, - poolFeeEureEurc: 500, - poolFeeEurcUsdc: 500, recoveryHash: bytes32(0) }), 1e18, // MIN_SWAP_FLOOR 50_000e18, // CAP_CEILING 25e18, // minSwapAmount - 10_000e18 // perSwapCap + 10_000e18, // perSwapCap + _route(500, 500) ); factory.setKeeper(keeper, true); fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); @@ -144,6 +145,11 @@ contract VortexForwarderTest is Test { // ---------------------------------------------------------------- helpers + /// Uniswap V3 packed path EURe -> EURC -> USDC at the given fee tiers. + function _route(uint24 tier1, uint24 tier2) internal view returns (bytes memory) { + return abi.encodePacked(address(eure), tier1, address(eurc), tier2, address(usdc)); + } + function _attest(address forwarder, bytes32 hash) internal view returns (bytes memory) { bytes32 bound = keccak256(abi.encodePacked(block.chainid, forwarder, hash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); @@ -208,14 +214,13 @@ contract VortexForwarderTest is Test { maxFeeBps: 100, sweepDelay: SWEEP_DELAY, triggerDelay: TRIGGER_DELAY, - poolFeeEureEurc: 500, - poolFeeEurcUsdc: 500, recoveryHash: recoveryHash }), 1e18, 50_000e18, 25e18, - 10_000e18 + 10_000e18, + _route(500, 500) ); VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(8)))); // Recovery hash validates with attestor binding; link still validates; others fail. @@ -283,7 +288,7 @@ contract VortexForwarderTest is Test { // minOut = 1000 * 1.14 * 0.99 = 1128.6 USDC router.setNextOut(1_130e6); vm.prank(keeper); - fwd.swapAndForward(); + fwd.swapAndForward(0); assertEq(usdc.balanceOf(destination), 1_130e6); assertEq(eure.balanceOf(address(fwd)), 0); assertEq(eure.allowance(address(fwd), address(router)), 0); @@ -294,7 +299,7 @@ contract VortexForwarderTest is Test { router.setNextOut(1_100e6); // below 1128.6 -> router-side minOut check fires vm.prank(keeper); vm.expectRevert("Too little received"); - fwd.swapAndForward(); + fwd.swapAndForward(0); } function test_swapAndForward_revertsOnStaleOracle() public { @@ -304,7 +309,7 @@ contract VortexForwarderTest is Test { skip(53 hours); // just past the 52h P8 window vm.prank(keeper); vm.expectRevert(VortexForwarder.StalePrice.selector); - fwd.swapAndForward(); + fwd.swapAndForward(0); } function test_swapAndForward_publicOnlyAfterTriggerDelay() public { @@ -313,17 +318,17 @@ contract VortexForwarderTest is Test { vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swapAndForward(); + fwd.swapAndForward(0); fwd.poke(); vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swapAndForward(); + fwd.swapAndForward(0); skip(TRIGGER_DELAY + 1); oracle.set(1.14e8, block.timestamp); vm.prank(rando); - fwd.swapAndForward(); + fwd.swapAndForward(0); assertEq(usdc.balanceOf(destination), 1_130e6); } @@ -333,11 +338,11 @@ contract VortexForwarderTest is Test { oracle.set(0, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swapAndForward(); + fwd.swapAndForward(0); oracle.set(-1, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swapAndForward(); + fwd.swapAndForward(0); } /// Review r1 P2: a perSwapCap remainder must keep its stranding timers armed — @@ -349,7 +354,7 @@ contract VortexForwarderTest is Test { router.setNextOut(11_290e6); skip(1 hours); vm.prank(keeper); - fwd.swapAndForward(); + fwd.swapAndForward(0); assertEq(eure.balanceOf(address(fwd)), 5_000e18); assertEq(fwd.strandedSince(), block.timestamp, "remainder must stay armed (fresh timestamp)"); } @@ -359,7 +364,7 @@ contract VortexForwarderTest is Test { // minOut for 10k at 1.14*0.99 = 11286 USDC router.setNextOut(11_290e6); vm.prank(keeper); - fwd.swapAndForward(); + fwd.swapAndForward(0); assertEq(eure.balanceOf(address(fwd)), 5_000e18); // remainder awaits next execution } @@ -369,7 +374,7 @@ contract VortexForwarderTest is Test { eure.mint(address(feeFwd), 1_000e18); router.setNextOut(1_130e6); vm.prank(keeper); - feeFwd.swapAndForward(); + feeFwd.swapAndForward(0); uint256 fee = (1_130e6 * 50) / 10_000; assertEq(usdc.balanceOf(feeRecipient), fee); assertEq(usdc.balanceOf(destination), 1_130e6 - fee); @@ -382,21 +387,21 @@ contract VortexForwarderTest is Test { fwd.setGuardianPaused(true); // test contract is factory guardian vm.prank(keeper); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(); + fwd.swapAndForward(0); fwd.setGuardianPaused(false); vm.prank(fallbackAddr); fwd.setClientPaused(true); vm.prank(keeper); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(); + fwd.swapAndForward(0); vm.prank(fallbackAddr); fwd.setClientPaused(false); factory.setGlobalPaused(true); vm.prank(keeper); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(); + fwd.swapAndForward(0); } function test_unsolicitedUsdc_forwardedWithNextSwap() public { @@ -404,7 +409,7 @@ contract VortexForwarderTest is Test { _fund(1_000e18); router.setNextOut(1_130e6); vm.prank(keeper); - fwd.swapAndForward(); + fwd.swapAndForward(0); assertEq(usdc.balanceOf(destination), 1_130e6 + 500e6); } @@ -424,21 +429,20 @@ contract VortexForwarderTest is Test { maxFeeBps: 100, sweepDelay: SWEEP_DELAY, triggerDelay: TRIGGER_DELAY, - poolFeeEureEurc: 500, - poolFeeEurcUsdc: 500, recoveryHash: bytes32(0) }), 1e18, 50_000e18, 25e18, - 10_000e18 + 10_000e18, + _route(500, 500) ); f2.setKeeper(keeper, true); VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(7)))); eure.mint(address(fwd2), 1_000e18); vm.prank(keeper); vm.expectRevert(VortexForwarder.Reentrancy.selector); - fwd2.swapAndForward(); + fwd2.swapAndForward(0); } // ---------------------------------------------------------------- recovery @@ -533,6 +537,80 @@ contract VortexForwarderTest is Test { factory.setMinSwapAmount(20_000e18); // above current cap } + // ---------------------------------------------------------------- routes + + function test_routes_initialRouteIsEnabledAndUsed() public { + (bytes memory path, bool enabled) = factory.route(0); + assertEq(path, _route(500, 500)); + assertTrue(enabled); + assertEq(factory.routeCount(), 1); + + _fund(1_000e18); + router.setNextOut(1_130e6); + vm.prank(keeper); + fwd.swapAndForward(0); + assertEq(router.lastPath(), _route(500, 500)); + } + + function test_routes_keeperSelectsAmongWhitelistedRoutes() public { + bytes memory direct = abi.encodePacked(address(eure), uint24(3000), address(usdc)); + uint256 index = factory.addRoute(direct); + assertEq(index, 1); + + _fund(1_000e18); + router.setNextOut(1_130e6); + vm.prank(keeper); + fwd.swapAndForward(1); + assertEq(router.lastPath(), direct); + } + + function test_routes_unknownOrDisabledRouteReverts() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + + vm.prank(keeper); + vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); + fwd.swapAndForward(7); + + factory.setRouteEnabled(0, false); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InvalidRoute.selector); + fwd.swapAndForward(0); + + vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); + factory.setRouteEnabled(7, false); + } + + function test_routes_validationRejectsAnythingOutsideTheThreeTokens() public { + address evil = makeAddr("evilToken"); + bytes[6] memory bad = [ + abi.encodePacked(evil, uint24(500), address(eurc), uint24(500), address(usdc)), // wrong start + abi.encodePacked(address(eure), uint24(500), address(eurc), uint24(500), evil), // wrong end + abi.encodePacked(address(eure), uint24(500), evil, uint24(500), address(usdc)), // wrong hop + abi.encodePacked(address(eure), uint24(250), address(eurc), uint24(500), address(usdc)), // bad tier + abi.encodePacked(address(eure), uint24(500), address(usdc), uint24(500)), // malformed length + abi.encodePacked( + address(eure), uint24(500), address(eurc), uint24(500), address(eurc), uint24(500), address(usdc) + ) // three hops + ]; + for (uint256 i = 0; i < bad.length; i++) { + vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); + factory.addRoute(bad[i]); + } + assertEq(factory.routeCount(), 1); + } + + function test_routes_guardianOnly() public { + vm.startPrank(rando); + vm.expectRevert(VortexForwarderFactory.NotGuardian.selector); + factory.addRoute(_route(100, 100)); + vm.expectRevert(VortexForwarderFactory.NotGuardian.selector); + factory.setRouteEnabled(0, false); + vm.expectRevert(VortexForwarderFactory.NotGuardian.selector); + factory.setSubsidyVault(rando); + vm.stopPrank(); + } + function test_feeBps_cappedAtMax() public { vm.expectRevert(VortexForwarder.FeeTooHigh.selector); factory.deployForwarder(destination, fallbackAddr, 101, bytes32(uint256(9))); @@ -614,7 +692,7 @@ contract VortexForwarderTest is Test { _fund(1_000e18); router.setNextOut(1_140e6); vm.prank(keeper); - fwd.swapAndForward(); + fwd.swapAndForward(0); // Zero fee taken: the announced-but-unapplied increase never touches a swap. assertEq(usdc.balanceOf(feeRecipient), 0); assertEq(usdc.balanceOf(destination), 1_140e6); From 57299e8a5f6d294f7fbec6c74344a1b1233ac61d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:17:43 +0200 Subject: [PATCH 04/44] feat(repo): price forwarder swaps against a reference with fee bands and subsidy --- .../src/VortexForwarder.sol | 270 ++++++++---- .../src/VortexForwarderFactory.sol | 23 +- .../test/VortexForwarder.fork.t.sol | 31 +- .../test/VortexForwarder.invariants.t.sol | 132 ++++-- .../test/VortexForwarder.t.sol | 414 +++++++++++++----- 5 files changed, 613 insertions(+), 257 deletions(-) diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index b8b127ea5..d953f9039 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -41,14 +41,18 @@ interface IVortexForwarderFactory { function route(uint256 index) external view returns (bytes memory path, bool enabled); } +interface IVortexSubsidyVault { + function pay(address to, uint256 amount, uint256 referenceOut) external; +} + /// @title VortexForwarder /// @notice Per-client forwarding account for the Monerium B2B onramp /// (docs/architecture-monerium-b2b-onramp.md §2). /// Deployed as an EIP-1167 clone by VortexForwarderFactory; the clone address is /// linked to the client's Monerium profile, EURe mints land here, and the only /// ways assets can ever leave are: -/// 1. a factory-whitelisted EURe -> USDC swap (oracle-checked minOut, output to self), -/// 2. USDC to the client's `destination` (plus fee <= feeBps to FEE_RECIPIENT), +/// 1. a factory-whitelisted EURe -> USDC swap (oracle-floored, output to self), +/// 2. USDC to the client's `destination` (plus a fee <= MAX_FEE_PPM to FEE_RECIPIENT), /// 3. EURe to the client's `fallbackAddress` (delayed permissionless sweep), /// 4. anything, by the client's `fallbackAddress` itself (`sweep`). /// Vortex (guardian/keeper) can execute the policy, pause it, and nothing else. @@ -61,6 +65,7 @@ contract VortexForwarder { bytes4 private constant EIP1271_MAGIC = 0x1626ba7e; bytes4 private constant EIP1271_FAIL = 0xffffffff; uint16 private constant BPS = 10_000; + uint32 private constant PPM = 1_000_000; string public constant LINK_MESSAGE = "I hereby declare that I am the address owner."; @@ -77,8 +82,12 @@ contract VortexForwarder { address public immutable ATTESTOR; // signs the Monerium link attestation address public immutable FEE_RECIPIENT; uint256 public immutable MAX_ORACLE_AGE; // registry P8 - uint16 public immutable SLIPPAGE_BPS; // registry P1 - uint16 public immutable MAX_FEE_BPS; // registry P2 + uint16 public immutable SLIPPAGE_BPS; // registry P1: floor on the client's NET, after fee and subsidy + uint32 public immutable MAX_FEE_PPM; // registry P2: caps both the fee and the floor policy + /// @dev How far a keeper-supplied reference may sit from Chainlink. Bounds the keeper's + /// pricing power: a wrong reference can move fee/subsidy only inside this band, and + /// MAX_FEE_PPM plus the vault's caps bound it further. + uint16 public immutable MAX_REFERENCE_DEVIATION_BPS; uint256 public immutable SWEEP_DELAY; // registry P3 uint256 public immutable TRIGGER_DELAY; // registry P4 @@ -103,7 +112,8 @@ contract VortexForwarder { address feeRecipient; uint256 maxOracleAge; uint16 slippageBps; - uint16 maxFeeBps; + uint32 maxFeePpm; + uint16 maxReferenceDeviationBps; uint256 sweepDelay; uint256 triggerDelay; bytes32 recoveryHash; @@ -115,12 +125,18 @@ contract VortexForwarder { bool public initialized; address public destination; // client's payout address (may be a CEX deposit address) address public fallbackAddress; // client's self-custodied recovery address (mandatory) - uint16 public feeBps; // guardian-adjustable within MAX_FEE_BPS; increases timelocked (P11) - - /// @dev P11 fee timelock state: a pending increase and when it may be applied. + /// @dev Fee policy, in ppm below the reference rate. The client is targeted at + /// reference x (1 - targetPpm): any fill above that becomes fee (<= MAX_FEE_PPM); + /// a fill below reference x (1 - floorPpm) is topped up from the subsidy vault. + /// Guardian-adjustable; increases (worse for the client) are timelocked (P11). + uint32 public targetPpm; + uint32 public floorPpm; + + /// @dev P11 timelock state: a pending increase and when it may be applied. /// effectiveAt == 0 means no increase is pending. Decreases never pend. - uint16 public pendingFeeBps; - uint64 public pendingFeeBpsEffectiveAt; + uint32 public pendingTargetPpm; + uint32 public pendingFloorPpm; + uint64 public pendingFeePolicyEffectiveAt; bool public clientPaused; // set by fallbackAddress only bool public guardianPaused; // set by guardian only (protective-only; cannot block fallback paths) @@ -133,14 +149,26 @@ contract VortexForwarder { // ----------------------------------------------------------------- events - event Initialized(address destination, address fallbackAddress, uint16 feeBps); - event FeeBpsDecreased(uint16 previous, uint16 current); - event FeeBpsIncreaseAnnounced(uint16 current, uint16 pending, uint64 effectiveAt); - event FeeBpsIncreaseApplied(uint16 previous, uint16 current); - event FeeBpsIncreaseCancelled(uint16 pending); + event Initialized(address destination, address fallbackAddress, uint32 targetPpm, uint32 floorPpm); + event FeePolicyDecreased(uint32 previousTarget, uint32 previousFloor, uint32 target, uint32 floor); + event FeePolicyIncreaseAnnounced( + uint32 currentTarget, uint32 currentFloor, uint32 pendingTarget, uint32 pendingFloor, uint64 effectiveAt + ); + event FeePolicyIncreaseApplied(uint32 previousTarget, uint32 previousFloor, uint32 target, uint32 floor); + event FeePolicyIncreaseCancelled(uint32 pendingTarget, uint32 pendingFloor); event Poked(uint64 strandedSince); + /// @param referenceRate The rate the fee bands were computed against (keeper-supplied + /// for privileged swaps, Chainlink for permissionless ones), ORACLE_DECIMALS. + /// @param subsidy USDC paid by the vault straight to `destination` on top of `forwarded`. event SwapExecuted( - address indexed caller, uint256 routeIndex, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded + address indexed caller, + uint256 routeIndex, + uint256 eureIn, + uint256 usdcOut, + uint256 referenceRate, + uint256 fee, + uint256 subsidy, + uint256 forwarded ); event StrandedEureSwept(address indexed caller, uint256 amount); event DestinationUpdated(address previous, address current); @@ -159,14 +187,16 @@ contract VortexForwarder { error Paused(); error ZeroAddress(); error InvalidConfigAddress(); - error FeeTooHigh(); + error InvalidFeePolicy(); error BelowMinimum(); error StalePrice(); error InvalidPrice(); error InsufficientOutput(); error Overspend(); error NotStranded(); - error NoPendingFee(); + error NoPendingFeePolicy(); + error ReferenceOutOfBand(); + error SubsidyUnavailable(); error DelayNotElapsed(); error TransferFailed(); error Reentrancy(); @@ -186,7 +216,8 @@ contract VortexForwarder { FEE_RECIPIENT = cfg.feeRecipient; MAX_ORACLE_AGE = cfg.maxOracleAge; SLIPPAGE_BPS = cfg.slippageBps; - MAX_FEE_BPS = cfg.maxFeeBps; + MAX_FEE_PPM = cfg.maxFeePpm; + MAX_REFERENCE_DEVIATION_BPS = cfg.maxReferenceDeviationBps; SWEEP_DELAY = cfg.sweepDelay; TRIGGER_DELAY = cfg.triggerDelay; RECOVERY_HASH = cfg.recoveryHash; @@ -219,18 +250,21 @@ contract VortexForwarder { // ---------------------------------------------------------- initialization /// @notice Called by the factory in the same transaction as clone deployment. - function initialize(address destination_, address fallbackAddress_, uint16 feeBps_) external { + function initialize(address destination_, address fallbackAddress_, uint32 targetPpm_, uint32 floorPpm_) + external + { if (msg.sender != address(FACTORY)) revert NotFactory(); if (initialized) revert AlreadyInitialized(); _validateConfigAddress(destination_); _validateConfigAddress(fallbackAddress_); - if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh(); + _validateFeePolicy(targetPpm_, floorPpm_); initialized = true; destination = destination_; fallbackAddress = fallbackAddress_; - feeBps = feeBps_; - emit Initialized(destination_, fallbackAddress_, feeBps_); + targetPpm = targetPpm_; + floorPpm = floorPpm_; + emit Initialized(destination_, fallbackAddress_, targetPpm_, floorPpm_); } // -------------------------------------------------------------- EIP-1271 @@ -296,14 +330,17 @@ contract VortexForwarder { /// @notice Convert EURe held by this contract to USDC and forward to `destination`. /// Callable by guardian/keepers any time; by anyone once the stranding /// marker is older than TRIGGER_DELAY (liveness fallback). + /// @param referenceRate The partner-agreed reference (EUR/USD, ORACLE_DECIMALS) the + /// fee bands are priced against. A privileged caller must supply one within + /// MAX_REFERENCE_DEVIATION_BPS of Chainlink; a permissionless caller's value is + /// ignored and Chainlink is used, and no subsidy is paid on that path — the + /// rate guarantee applies to keeper-executed swaps. /// @param routeIndex Which factory-whitelisted route to execute. The keeper quotes /// every enabled route off-chain and picks the best; a poor pick only ever /// costs Vortex (more subsidy, less fee), never the client, whose outcome is /// bounded by the oracle floor whichever route runs. - function swapAndForward(uint256 routeIndex) external nonReentrant { + function swapAndForward(uint256 referenceRate, uint256 routeIndex) external nonReentrant { if (clientPaused || guardianPaused || FACTORY.globalPaused()) revert Paused(); - (bytes memory path, bool routeEnabled) = FACTORY.route(routeIndex); - if (!routeEnabled) revert InvalidRoute(); bool privileged = msg.sender == FACTORY.guardian() || FACTORY.isKeeper(msg.sender); if (!privileged) { @@ -311,13 +348,43 @@ contract VortexForwarder { if (block.timestamp - strandedSince < TRIGGER_DELAY) revert NotAuthorizedYet(); } - uint256 eureBefore = EURE.balanceOf(address(this)); - if (eureBefore < FACTORY.minSwapAmount()) revert BelowMinimum(); - uint256 amountIn = eureBefore; + uint256 amountIn = EURE.balanceOf(address(this)); + if (amountIn < FACTORY.minSwapAmount()) revert BelowMinimum(); uint256 cap = FACTORY.perSwapCap(); if (amountIn > cap) amountIn = cap; - uint256 minOut = _minOut(amountIn); + uint256 oraclePrice = _oraclePrice(); + uint256 referenceUsed = privileged ? _checkedReference(referenceRate, oraclePrice) : oraclePrice; + + uint256 usdcReceived = _swap(routeIndex, amountIn); + (uint256 fee, uint256 subsidy) = _settle(amountIn, usdcReceived, referenceUsed, privileged); + + // The oracle floor is enforced on the client's NET (fill - fee + subsidy), not on + // the raw fill: a subsidized fill may sit below it, and a subsidy must never + // paper over a depegged reference. Reverting here undoes the swap and the + // subsidy transfer alike. + if (usdcReceived - fee + subsidy < _floorOut(amountIn, oraclePrice)) revert InsufficientOutput(); + + // Full-balance sweep: unsolicited USDC goes to the client's destination too (R09). + uint256 forwarded = USDC.balanceOf(address(this)); + _transfer(USDC, destination, forwarded); + + // Re-arm instead of clearing when a perSwapCap remainder stays behind (review r1 + // P2): otherwise the remainder's dead-man/permissionless timers would silently + // restart from zero only after a fresh poke(). + strandedSince = EURE.balanceOf(address(this)) >= FACTORY.MIN_SWAP_FLOOR() ? uint64(block.timestamp) : 0; + emit SwapExecuted(msg.sender, routeIndex, amountIn, usdcReceived, referenceUsed, fee, subsidy, forwarded); + } + + /// @dev Executes the whitelisted route and returns the USDC received. The router + /// minimum is deliberately 0: the router cannot see the fee and subsidy that + /// determine the client's net, so the floor is enforced by swapAndForward after + /// settlement instead, and a failing floor reverts the whole call. + function _swap(uint256 routeIndex, uint256 amountIn) internal returns (uint256 usdcReceived) { + (bytes memory path, bool routeEnabled) = FACTORY.route(routeIndex); + if (!routeEnabled) revert InvalidRoute(); + + uint256 eureBefore = EURE.balanceOf(address(this)); uint256 usdcBefore = USDC.balanceOf(address(this)); _approve(EURE, address(ROUTER), amountIn); @@ -326,41 +393,70 @@ contract VortexForwarder { path: path, recipient: address(this), amountIn: amountIn, - amountOutMinimum: minOut + amountOutMinimum: 0 }) ); _approve(EURE, address(ROUTER), 0); - uint256 usdcReceived = USDC.balanceOf(address(this)) - usdcBefore; - if (usdcReceived < minOut) revert InsufficientOutput(); + usdcReceived = USDC.balanceOf(address(this)) - usdcBefore; if (eureBefore - EURE.balanceOf(address(this)) > amountIn) revert Overspend(); + } - uint256 fee = 0; - if (feeBps > 0) { - fee = (usdcReceived * feeBps) / BPS; - if (fee > 0) _transfer(USDC, FEE_RECIPIENT, fee); + /// @dev Applies the fee bands (docs/proposal-monerium-forwarder-fee-subsidy.md): + /// - fill above reference x (1 - targetPpm): the surplus is the fee, <= MAX_FEE_PPM; + /// - fill between the floor and the target: no fee, no subsidy; + /// - fill below reference x (1 - floorPpm): a privileged swap draws the shortfall + /// from the vault straight to `destination`; a permissionless swap pays nothing. + /// The vault reverts (and so does the swap) when its cap, budget, pause or + /// balance cannot cover the shortfall — a swap is never partially subsidized. + function _settle(uint256 amountIn, uint256 usdcReceived, uint256 referenceUsed, bool privileged) + internal + returns (uint256 fee, uint256 subsidy) + { + uint256 referenceOut = _usdcValue(amountIn, referenceUsed); + uint256 targetOut = (referenceOut * (PPM - targetPpm)) / PPM; + if (usdcReceived > targetOut) { + fee = usdcReceived - targetOut; + uint256 maxFee = (usdcReceived * MAX_FEE_PPM) / PPM; + if (fee > maxFee) fee = maxFee; + _transfer(USDC, FEE_RECIPIENT, fee); + return (fee, 0); } - - // Full-balance sweep: unsolicited USDC goes to the client's destination too (R09). - uint256 forwarded = USDC.balanceOf(address(this)); - _transfer(USDC, destination, forwarded); - - // Re-arm instead of clearing when a perSwapCap remainder stays behind (review r1 - // P2): otherwise the remainder's dead-man/permissionless timers would silently - // restart from zero only after a fresh poke(). - strandedSince = EURE.balanceOf(address(this)) >= FACTORY.MIN_SWAP_FLOOR() ? uint64(block.timestamp) : 0; - emit SwapExecuted(msg.sender, routeIndex, amountIn, usdcReceived, fee, forwarded); + uint256 floorOut = (referenceOut * (PPM - floorPpm)) / PPM; + if (usdcReceived >= floorOut || !privileged) return (0, 0); + + subsidy = floorOut - usdcReceived; + address vault = FACTORY.subsidyVault(); + if (vault == address(0)) revert SubsidyUnavailable(); + IVortexSubsidyVault(vault).pay(destination, subsidy, referenceOut); + return (0, subsidy); } - /// @dev minOut = amountIn * price * (1 - slippage), rescaled EURe(18) -> USDC(6). - /// Scale denominator: 10^(18 + oracleDecimals - 6). Floor rounding: conservative - /// direction; error < 1 unit of USDC. Assumes USDC/USD = 1 within SLIPPAGE_BPS - /// (documented assumption A4; PRD v2 §7.3). - function _minOut(uint256 amountIn) internal view returns (uint256) { + /// @dev Validated Chainlink EUR/USD price (registry P8 staleness ceiling). + function _oraclePrice() internal view returns (uint256) { (, int256 answer,, uint256 updatedAt,) = ORACLE.latestRoundData(); if (answer <= 0) revert InvalidPrice(); if (updatedAt == 0 || block.timestamp - updatedAt > MAX_ORACLE_AGE) revert StalePrice(); - return (amountIn * uint256(answer) * (BPS - SLIPPAGE_BPS)) / (10 ** (12 + uint256(ORACLE_DECIMALS))) / BPS; + return uint256(answer); + } + + /// @dev A keeper-supplied reference must lie within MAX_REFERENCE_DEVIATION_BPS of Chainlink. + function _checkedReference(uint256 supplied, uint256 oraclePrice) internal view returns (uint256) { + uint256 tolerance = (oraclePrice * MAX_REFERENCE_DEVIATION_BPS) / BPS; + if (supplied + tolerance < oraclePrice || supplied > oraclePrice + tolerance) revert ReferenceOutOfBand(); + return supplied; + } + + /// @dev amountIn (EURe, 18 dec) x price (ORACLE_DECIMALS) rescaled to USDC (6 dec). + /// Scale denominator: 10^(18 + oracleDecimals - 6). Floor rounding: error < 1 + /// unit of USDC. Assumes USDC/USD = 1 within SLIPPAGE_BPS (assumption A4). + function _usdcValue(uint256 amountIn, uint256 price) internal view returns (uint256) { + return (amountIn * price) / (10 ** (12 + uint256(ORACLE_DECIMALS))); + } + + /// @dev The least the client may end up with: Chainlink value x (1 - SLIPPAGE_BPS). + function _floorOut(uint256 amountIn, uint256 oraclePrice) internal view returns (uint256) { + return (_usdcValue(amountIn, oraclePrice) * (BPS - SLIPPAGE_BPS)) / BPS; } // -------------------------------------------------------------- recovery @@ -419,50 +515,62 @@ contract VortexForwarder { emit GuardianPausedSet(paused); } - /// @dev P11: fee increases take effect only this long after their on-chain + /// @dev P11: fee policy increases take effect only this long after their on-chain /// announcement, so a client whose SEPA transfer is already in flight under - /// the current fee cannot be minted-and-swapped under a silently higher one. + /// the current policy cannot be swapped under a silently worse one. /// Decreases are immediate — they only ever favor the client. uint256 public constant FEE_INCREASE_TIMELOCK = 24 hours; - /// @notice Guardian fee adjustment (P11), always bounded by the immutable - /// MAX_FEE_BPS. A decrease (or re-stating the current value) applies - /// immediately and cancels any pending increase; an increase is announced - /// and becomes applicable only after FEE_INCREASE_TIMELOCK. Announcing - /// again replaces the pending increase and restarts its clock. - function setFeeBps(uint16 newFeeBps) external onlyGuardian { - if (newFeeBps > MAX_FEE_BPS) revert FeeTooHigh(); - if (newFeeBps <= feeBps) { - if (pendingFeeBpsEffectiveAt != 0) { - emit FeeBpsIncreaseCancelled(pendingFeeBps); - pendingFeeBps = 0; - pendingFeeBpsEffectiveAt = 0; + /// @notice Guardian fee-policy adjustment (P11), always bounded by the immutable + /// MAX_FEE_PPM. A change that raises neither value (or re-states the current + /// ones) applies immediately and cancels any pending increase; a change that + /// raises either value is announced and becomes applicable only after + /// FEE_INCREASE_TIMELOCK. Announcing again replaces the pending pair and + /// restarts its clock. + function setFeePolicy(uint32 newTargetPpm, uint32 newFloorPpm) external onlyGuardian { + _validateFeePolicy(newTargetPpm, newFloorPpm); + if (newTargetPpm <= targetPpm && newFloorPpm <= floorPpm) { + if (pendingFeePolicyEffectiveAt != 0) { + emit FeePolicyIncreaseCancelled(pendingTargetPpm, pendingFloorPpm); + pendingTargetPpm = 0; + pendingFloorPpm = 0; + pendingFeePolicyEffectiveAt = 0; } - if (newFeeBps != feeBps) { - emit FeeBpsDecreased(feeBps, newFeeBps); - feeBps = newFeeBps; + if (newTargetPpm != targetPpm || newFloorPpm != floorPpm) { + emit FeePolicyDecreased(targetPpm, floorPpm, newTargetPpm, newFloorPpm); + targetPpm = newTargetPpm; + floorPpm = newFloorPpm; } } else { - pendingFeeBps = newFeeBps; - pendingFeeBpsEffectiveAt = uint64(block.timestamp + FEE_INCREASE_TIMELOCK); - emit FeeBpsIncreaseAnnounced(feeBps, newFeeBps, pendingFeeBpsEffectiveAt); + pendingTargetPpm = newTargetPpm; + pendingFloorPpm = newFloorPpm; + pendingFeePolicyEffectiveAt = uint64(block.timestamp + FEE_INCREASE_TIMELOCK); + emit FeePolicyIncreaseAnnounced(targetPpm, floorPpm, newTargetPpm, newFloorPpm, pendingFeePolicyEffectiveAt); } } - /// @notice Applies an announced fee increase once its timelock has elapsed. + /// @notice Applies an announced fee-policy increase once its timelock has elapsed. /// Permissionless: the announcement is the authorization; anyone may /// finalize it (the keeper does so as part of its cycle if needed). - function applyFeeBps() external { - if (pendingFeeBpsEffectiveAt == 0) revert NoPendingFee(); - if (block.timestamp < pendingFeeBpsEffectiveAt) revert DelayNotElapsed(); - emit FeeBpsIncreaseApplied(feeBps, pendingFeeBps); - feeBps = pendingFeeBps; - pendingFeeBps = 0; - pendingFeeBpsEffectiveAt = 0; + function applyFeePolicy() external { + if (pendingFeePolicyEffectiveAt == 0) revert NoPendingFeePolicy(); + if (block.timestamp < pendingFeePolicyEffectiveAt) revert DelayNotElapsed(); + emit FeePolicyIncreaseApplied(targetPpm, floorPpm, pendingTargetPpm, pendingFloorPpm); + targetPpm = pendingTargetPpm; + floorPpm = pendingFloorPpm; + pendingTargetPpm = 0; + pendingFloorPpm = 0; + pendingFeePolicyEffectiveAt = 0; } // ---------------------------------------------------------------- helpers + /// @dev The floor is the worse-for-the-client bound, so it may never sit above the + /// target, and both are capped by the immutable MAX_FEE_PPM. + function _validateFeePolicy(uint32 targetPpm_, uint32 floorPpm_) internal view { + if (targetPpm_ > floorPpm_ || floorPpm_ > MAX_FEE_PPM) revert InvalidFeePolicy(); + } + function _validateConfigAddress(address account) internal view { if (account == address(0)) revert ZeroAddress(); if ( diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol index 56ddbba5a..46b5fafc7 100644 --- a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -48,7 +48,12 @@ contract VortexForwarderFactory { address public subsidyVault; event ForwarderDeployed( - address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt + address indexed forwarder, + address indexed destination, + address fallbackAddress, + uint32 targetPpm, + uint32 floorPpm, + bytes32 salt ); event KeeperSet(address indexed keeper, bool enabled); event GlobalPausedSet(bool paused); @@ -96,15 +101,17 @@ contract VortexForwarderFactory { /// @notice Deploy and initialize a client forwarder in one transaction. The clone /// address is deterministic (CREATE2) so it can be communicated/linked /// reliably; predict it with `predictAddress` before deploying. - function deployForwarder(address destination, address fallbackAddress, uint16 feeBps, bytes32 salt) - external - onlyGuardian - returns (address forwarder) - { + function deployForwarder( + address destination, + address fallbackAddress, + uint32 targetPpm, + uint32 floorPpm, + bytes32 salt + ) external onlyGuardian returns (address forwarder) { forwarder = _cloneDeterministic(implementation, salt); - VortexForwarder(forwarder).initialize(destination, fallbackAddress, feeBps); + VortexForwarder(forwarder).initialize(destination, fallbackAddress, targetPpm, floorPpm); isForwarder[forwarder] = true; - emit ForwarderDeployed(forwarder, destination, fallbackAddress, feeBps, salt); + emit ForwarderDeployed(forwarder, destination, fallbackAddress, targetPpm, floorPpm, salt); } function predictAddress(bytes32 salt) external view returns (address) { diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol index 9420473cb..95d36268d 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -2,8 +2,9 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; -import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarder, IERC20, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; +import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; interface IUniswapV3Factory { function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address); @@ -33,6 +34,7 @@ contract VortexForwarderForkTest is Test { VortexForwarderFactory factory; VortexForwarder fwd; + VortexSubsidyVault vault; address attestor = vm.addr(0xA11CE); address destination = makeAddr("destination"); @@ -57,8 +59,9 @@ contract VortexForwarderForkTest is Test { attestor: attestor, feeRecipient: makeAddr("feeRecipient"), maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 100, - maxFeeBps: 100, + slippageBps: 40, + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, sweepDelay: 60 days, triggerDelay: 24 hours, recoveryHash: bytes32(0) @@ -70,7 +73,18 @@ contract VortexForwarderForkTest is Test { abi.encodePacked(EURE_V2, POOL_FEE_EURE_EURC, EURC, POOL_FEE_EURC_USDC, USDC) ); factory.setKeeper(keeper, true); - fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + vault = new VortexSubsidyVault( + IERC20(USDC), makeAddr("treasury"), IVortexForwarderFactory(address(factory)), 5_000, 200e6 + ); + deal(USDC, address(vault), 1_000e6); + factory.setSubsidyVault(address(vault)); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 1_250, 1_500, bytes32(uint256(1)))); + } + + /// The keeper's reference in these tests is Chainlink itself (trivially inside the band). + function _reference() internal view returns (uint256) { + (, int256 answer,,,) = fwd.ORACLE().latestRoundData(); + return uint256(answer); } modifier onlyForked() { @@ -111,10 +125,13 @@ contract VortexForwarderForkTest is Test { uint256 fair = (amountIn * uint256(answer)) / 1e20; // 6-dec USDC at oracle rate vm.prank(keeper); - fwd.swapAndForward(0); + fwd.swapAndForward(_reference(), 0); + // With the vault funded the client lands at or above the policy floor (15 bps), + // whether by fill, fee, or subsidy; the oracle floor is the hard lower bound. uint256 received = IERC20Meta(USDC).balanceOf(destination); - assertGe(received, (fair * 9_900) / 10_000, "below oracle-bounded minOut"); + assertGe(received, (fair * 998_500) / 1_000_000, "below the policy floor"); + assertGe(received, (fair * 9_960) / 10_000, "below the oracle floor"); assertLe(received, (fair * 10_300) / 10_000, "implausibly above oracle rate"); assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 0, "EURe left behind"); assertEq(IERC20Meta(USDC).balanceOf(address(fwd)), 0, "USDC left behind"); @@ -123,7 +140,7 @@ contract VortexForwarderForkTest is Test { function test_fork_perSwapCapLeavesRemainder() public onlyForked { deal(EURE_V2, address(fwd), 12_000e18); // cap is 10k vm.prank(keeper); - fwd.swapAndForward(0); + fwd.swapAndForward(_reference(), 0); assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 2_000e18); assertGt(IERC20Meta(USDC).balanceOf(destination), 0); } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol index 7fea0f0df..30622b811 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -2,17 +2,20 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; -import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarder, IERC20, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; +import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; import {MockERC20, MockOracle, MockRouter} from "./VortexForwarder.t.sol"; /// Randomized action handler. Ghost variables track every token unit entering the /// system so the invariants below can assert exit-path exhaustiveness (plan §2.3.1): /// EURe may sit in the forwarder, be consumed by the router, or reach the client's -/// fallback; USDC may only reach destination + feeRecipient; nothing else, ever. +/// fallback; USDC may only reach destination + feeRecipient (plus vault subsidies that +/// reach destination); nothing else, ever. contract ForwarderHandler is Test { VortexForwarderFactory public factory; VortexForwarder public fwd; + VortexSubsidyVault public vault; MockERC20 public eure; MockERC20 public usdc; MockERC20 public eurc; @@ -24,11 +27,19 @@ contract ForwarderHandler is Test { address public keeper = makeAddr("keeper"); address public rando = makeAddr("rando"); address public feeRecipient = makeAddr("feeRecipient"); + address public treasury = makeAddr("treasury"); uint256 public ghostEureMinted; uint256 public ghostUsdcPaidByRouter; + uint256 public ghostSubsidyPaid; uint256 public fallbackSweepFailures; - uint16 public immutable INITIAL_FEE_BPS = 50; + /// Successful swaps whose client net landed below the oracle floor, or keeper swaps + /// below the policy floor, or fees above MAX_FEE_PPM. Must stay zero. + uint256 public pricingViolations; + uint32 public immutable INITIAL_TARGET_PPM = 1_250; + uint32 public immutable INITIAL_FLOOR_PPM = 1_500; + uint256 public constant VAULT_FUNDING = 10_000e6; + uint256 constant REFERENCE = 1.14e8; constructor() { eure = new MockERC20("EURe", 18); @@ -47,8 +58,9 @@ contract ForwarderHandler is Test { attestor: vm.addr(0xA11CE), feeRecipient: feeRecipient, maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 100, - maxFeeBps: 100, + slippageBps: 40, + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, sweepDelay: 60 days, triggerDelay: 24 hours, recoveryHash: bytes32(0) @@ -60,8 +72,16 @@ contract ForwarderHandler is Test { abi.encodePacked(address(eure), uint24(500), address(eurc), uint24(500), address(usdc)) ); factory.setKeeper(keeper, true); - fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, INITIAL_FEE_BPS, bytes32(uint256(1)))); - ghostExpectedFeeBps = INITIAL_FEE_BPS; + vault = new VortexSubsidyVault( + IERC20(address(usdc)), treasury, IVortexForwarderFactory(address(factory)), 5_000, 200e6 + ); + usdc.mint(address(vault), VAULT_FUNDING); + factory.setSubsidyVault(address(vault)); + fwd = VortexForwarder( + factory.deployForwarder(destination, fallbackAddr, INITIAL_TARGET_PPM, INITIAL_FLOOR_PPM, bytes32(uint256(1))) + ); + ghostExpectedTargetPpm = INITIAL_TARGET_PPM; + ghostExpectedFloorPpm = INITIAL_FLOOR_PPM; } // ------------------------------------------------------------- actions @@ -80,8 +100,8 @@ contract ForwarderHandler is Test { vm.warp(block.timestamp + bound(uint256(raw), 1, 90 days)); } - /// Router pays a randomized amount around the fair oracle value; underpayment - /// exercises the minOut revert path, overpayment the happy path. + /// Router pays a randomized amount around the fair oracle value: far below exercises + /// the floor/cap reverts, slightly below the subsidy path, above the fee path. function keeperSwap(uint96 raw) external { _swapAs(keeper, raw); } @@ -94,15 +114,23 @@ contract ForwarderHandler is Test { oracle.set(1.14e8, block.timestamp); uint256 balance = eure.balanceOf(address(fwd)); uint256 amountIn = balance > 10_000e18 ? 10_000e18 : balance; - uint256 fair = (amountIn * 1.14e8) / 1e20; - // 95%..105% of fair value; below 99% the swap must revert on minOut. + uint256 fair = (amountIn * REFERENCE) / 1e20; uint256 payout = bound(uint256(raw), (fair * 95) / 100, (fair * 105) / 100); router.setNextOut(payout); uint256 routerUsdcBefore = usdc.totalMinted(); + uint256 vaultBefore = usdc.balanceOf(address(vault)); + uint256 destinationBefore = usdc.balanceOf(destination); + uint256 feeBefore = usdc.balanceOf(feeRecipient); vm.prank(caller); - try fwd.swapAndForward(0) { - ghostUsdcPaidByRouter += usdc.totalMinted() - routerUsdcBefore; + try fwd.swapAndForward(REFERENCE, 0) { + uint256 paid = usdc.totalMinted() - routerUsdcBefore; + ghostUsdcPaidByRouter += paid; + ghostSubsidyPaid += vaultBefore - usdc.balanceOf(address(vault)); + uint256 net = usdc.balanceOf(destination) - destinationBefore; + if (net < (fair * 9_960) / 10_000) pricingViolations++; // Chainlink - 40 bps + if (caller == keeper && net < (fair * (1_000_000 - fwd.floorPpm())) / 1_000_000) pricingViolations++; + if (usdc.balanceOf(feeRecipient) - feeBefore > paid / 100) pricingViolations++; // MAX_FEE_PPM } catch {} } @@ -114,24 +142,28 @@ contract ForwarderHandler is Test { fwd.setGuardianPaused(paused); // handler deployed the factory -> handler is guardian } - /// P11 ghost model: what feeBps is allowed to be right now. Decreases apply + /// P11 ghost model: what the fee policy is allowed to be right now. Decreases apply /// immediately; increases only after their announced timelock elapses AND - /// someone calls applyFeeBps. - uint16 public ghostExpectedFeeBps; - - function guardianSetFee(uint16 raw) external { - uint16 newFee = raw % 120; // exercise the FeeTooHigh branch too (MAX is 100) - try fwd.setFeeBps(newFee) { - if (newFee <= ghostExpectedFeeBps) { - ghostExpectedFeeBps = newFee; // decrease/cancel: immediate + /// someone calls applyFeePolicy. + uint32 public ghostExpectedTargetPpm; + uint32 public ghostExpectedFloorPpm; + + function guardianSetFeePolicy(uint32 rawTarget, uint32 rawFloor) external { + uint32 target = rawTarget % 11_000; + uint32 floor = target + rawFloor % 1_500; // sometimes above MAX_FEE_PPM: exercises InvalidFeePolicy + try fwd.setFeePolicy(target, floor) { + if (target <= ghostExpectedTargetPpm && floor <= ghostExpectedFloorPpm) { + ghostExpectedTargetPpm = target; // decrease/cancel: immediate + ghostExpectedFloorPpm = floor; } - // increase: pending only — ghost updates when applyFee succeeds + // increase: pending only — ghost updates when applyFeePolicy succeeds } catch {} } - function applyFee() external { - try fwd.applyFeeBps() { - ghostExpectedFeeBps = fwd.feeBps(); // apply succeeded past its timelock + function applyFeePolicy() external { + try fwd.applyFeePolicy() { + ghostExpectedTargetPpm = fwd.targetPpm(); // apply succeeded past its timelock + ghostExpectedFloorPpm = fwd.floorPpm(); } catch {} } @@ -151,11 +183,12 @@ contract ForwarderHandler is Test { function randoTriesPrivilegedCalls(uint8 selector) external { vm.startPrank(rando); - if (selector % 5 == 0) try fwd.setDestination(rando) {} catch {} - if (selector % 5 == 1) try fwd.setGuardianPaused(true) {} catch {} - if (selector % 5 == 2) try fwd.setFallbackAddress(rando) {} catch {} - if (selector % 5 == 3) try fwd.sweep(address(eure), rando) {} catch {} - if (selector % 5 == 4) try fwd.setFeeBps(99) {} catch {} + if (selector % 6 == 0) try fwd.setDestination(rando) {} catch {} + if (selector % 6 == 1) try fwd.setGuardianPaused(true) {} catch {} + if (selector % 6 == 2) try fwd.setFallbackAddress(rando) {} catch {} + if (selector % 6 == 3) try fwd.sweep(address(eure), rando) {} catch {} + if (selector % 6 == 4) try fwd.setFeePolicy(99, 99) {} catch {} + if (selector % 6 == 5) try vault.setDailyBudget(type(uint256).max) {} catch {} vm.stopPrank(); } } @@ -176,22 +209,41 @@ contract VortexForwarderInvariantTest is Test { assertEq(accounted, handler.ghostEureMinted(), "EURe leaked to an unexpected address"); } - /// Exit-path exhaustiveness for USDC: everything the router ever paid ends up - /// split between destination and feeRecipient; the forwarder retains nothing. + /// Exit-path exhaustiveness for USDC: everything the router ever paid plus every + /// subsidy the vault ever paid ends up split between destination and feeRecipient; + /// the forwarder retains nothing and the vault only ever shrinks by what it paid. function invariant_usdcOnlyReachesDestinationAndFee() public view { uint256 accounted = handler.usdc().balanceOf(handler.destination()) + handler.usdc().balanceOf(handler.feeRecipient()); - assertEq(accounted, handler.ghostUsdcPaidByRouter(), "USDC leaked to an unexpected address"); + assertEq( + accounted, + handler.ghostUsdcPaidByRouter() + handler.ghostSubsidyPaid(), + "USDC leaked to an unexpected address" + ); assertEq(handler.usdc().balanceOf(address(handler.fwd())), 0, "forwarder retained USDC"); + assertEq( + handler.usdc().balanceOf(address(handler.vault())), + handler.VAULT_FUNDING() - handler.ghostSubsidyPaid(), + "vault balance disagrees with subsidies paid" + ); + } + + /// Every successful swap respects the pricing bounds: the client's net never sits + /// below the oracle floor, a keeper swap never below the policy floor, and the fee + /// never exceeds MAX_FEE_PPM. + function invariant_pricingBounds() public view { + assertEq(handler.pricingViolations(), 0, "a swap violated a pricing bound"); } - /// Config changes only through their authorized paths: feeBps moves exclusively - /// via the guardian's timelocked setter (P11 ghost model tracks every legal - /// transition — a rando call or an early apply can never move it), and it never - /// exceeds MAX_FEE_BPS; destination/fallback never change without their owner. + /// Config changes only through their authorized paths: the fee policy moves + /// exclusively via the guardian's timelocked setter (P11 ghost model tracks every + /// legal transition — a rando call or an early apply can never move it), stays + /// ordered and capped; destination/fallback never change without their owner. function invariant_configIntegrity() public view { - assertEq(handler.fwd().feeBps(), handler.ghostExpectedFeeBps(), "feeBps moved outside the guardian timelock path"); - assertLe(handler.fwd().feeBps(), 100, "feeBps exceeded MAX_FEE_BPS"); + assertEq(handler.fwd().targetPpm(), handler.ghostExpectedTargetPpm(), "target moved outside the timelock path"); + assertEq(handler.fwd().floorPpm(), handler.ghostExpectedFloorPpm(), "floor moved outside the timelock path"); + assertLe(handler.fwd().targetPpm(), handler.fwd().floorPpm(), "target above floor"); + assertLe(handler.fwd().floorPpm(), 10_000, "floor exceeded MAX_FEE_PPM"); assertEq(handler.fwd().destination(), handler.destination()); assertEq(handler.fwd().fallbackAddress(), handler.fallbackAddr()); } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 009fe4855..49271e240 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -2,8 +2,12 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; -import {VortexForwarder, IERC20, ISwapRouter02} from "../src/VortexForwarder.sol"; +import {VortexForwarder, IERC20, ISwapRouter02, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; +import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; + +// Reference rate the keeper passes in the unit tests; equal to the mock oracle price. +uint256 constant REF = 1.14e8; contract MockERC20 { string public name; @@ -84,7 +88,7 @@ contract MockRouter { /// Malicious router that tries to re-enter swapAndForward during the swap. contract MockReentrantRouter { function exactInput(ISwapRouter02.ExactInputParams calldata) external payable returns (uint256) { - VortexForwarder(msg.sender).swapAndForward(0); // must revert via reentrancy guard + VortexForwarder(msg.sender).swapAndForward(REF, 0); // must revert via reentrancy guard return 0; } } @@ -97,10 +101,12 @@ contract VortexForwarderTest is Test { MockRouter router; VortexForwarderFactory factory; VortexForwarder fwd; + VortexSubsidyVault vault; uint256 attestorPk = 0xA11CE; address attestor; address feeRecipient = makeAddr("feeRecipient"); + address treasury = makeAddr("treasury"); address destination = makeAddr("destination"); address fallbackAddr = makeAddr("fallbackAddr"); address keeper = makeAddr("keeper"); @@ -109,6 +115,18 @@ contract VortexForwarderTest is Test { uint256 constant TRIGGER_DELAY = 24 hours; uint256 constant SWEEP_DELAY = 60 days; + // Fee policy defaults (proposal): target 12.5 bps, floor 15 bps below the reference. + uint32 constant TARGET_PPM = 1_250; + uint32 constant FLOOR_PPM = 1_500; + // Vault defaults: 50 bps of the reference value per swap, 200 USDC per day. + uint32 constant MAX_SUBSIDY_PPM = 5_000; + uint256 constant DAILY_BUDGET = 200e6; + // 1000 EURe at 1.14 = 1140 USDC reference value and its derived bounds. + uint256 constant TARGET_1K = 1_138_575_000; // reference - 12.5 bps + uint256 constant FLOOR_1K = 1_138_290_000; // reference - 15 bps + uint256 constant ORACLE_FLOOR_1K = 1_135_440_000; // Chainlink - 40 bps + uint256 constant TARGET_10K = 11_385_750_000; + function setUp() public { attestor = vm.addr(attestorPk); eure = new MockERC20("EURe", 18); @@ -127,8 +145,9 @@ contract VortexForwarderTest is Test { attestor: attestor, feeRecipient: feeRecipient, maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 100, - maxFeeBps: 100, + slippageBps: 40, + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, sweepDelay: SWEEP_DELAY, triggerDelay: TRIGGER_DELAY, recoveryHash: bytes32(0) @@ -140,7 +159,14 @@ contract VortexForwarderTest is Test { _route(500, 500) ); factory.setKeeper(keeper, true); - fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + vault = new VortexSubsidyVault( + IERC20(address(usdc)), treasury, IVortexForwarderFactory(address(factory)), MAX_SUBSIDY_PPM, DAILY_BUDGET + ); + usdc.mint(address(vault), 1_000e6); + factory.setSubsidyVault(address(vault)); + fwd = VortexForwarder( + factory.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(1))) + ); } // ---------------------------------------------------------------- helpers @@ -210,8 +236,9 @@ contract VortexForwarderTest is Test { attestor: attestor, feeRecipient: feeRecipient, maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 100, - maxFeeBps: 100, + slippageBps: 40, + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, sweepDelay: SWEEP_DELAY, triggerDelay: TRIGGER_DELAY, recoveryHash: recoveryHash @@ -222,7 +249,7 @@ contract VortexForwarderTest is Test { 10_000e18, _route(500, 500) ); - VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(8)))); + VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(8)))); // Recovery hash validates with attestor binding; link still validates; others fail. bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(fwd2), recoveryHash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); @@ -257,7 +284,7 @@ contract VortexForwarderTest is Test { function test_linkSignature_rejectsCrossCloneReplay() public { VortexForwarder other = - VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(2)))); + VortexForwarder(factory.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(2)))); bytes32 h = fwd.LINK_HASH_191(); // Signature bound to `fwd` must not validate on `other`. assertEq(other.isValidSignature(h, _attest(address(fwd), h)), bytes4(0xffffffff)); @@ -267,82 +294,91 @@ contract VortexForwarderTest is Test { function test_initialize_onlyFactory_andOnce() public { vm.expectRevert(VortexForwarder.NotFactory.selector); - fwd.initialize(rando, rando, 0); + fwd.initialize(rando, rando, 0, 0); vm.prank(address(factory)); vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); - fwd.initialize(rando, rando, 0); + fwd.initialize(rando, rando, 0, 0); } function test_implementation_isBricked() public { VortexForwarder impl = VortexForwarder(factory.implementation()); vm.prank(address(factory)); vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); - impl.initialize(rando, rando, 0); + impl.initialize(rando, rando, 0, 0); } // ---------------------------------------------------------------- swap function test_swapAndForward_happyPath_forwardsToDestination() public { _fund(1_000e18); - // minOut = 1000 * 1.14 * 0.99 = 1128.6 USDC - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); // exactly the target: no fee, no subsidy vm.prank(keeper); - fwd.swapAndForward(0); - assertEq(usdc.balanceOf(destination), 1_130e6); + fwd.swapAndForward(REF, 0); + assertEq(usdc.balanceOf(destination), TARGET_1K); assertEq(eure.balanceOf(address(fwd)), 0); assertEq(eure.allowance(address(fwd), address(router)), 0); } - function test_swapAndForward_enforcesOracleMinOut() public { + function test_swapAndForward_enforcesOracleFloorOnTheNet() public { + // Permissionless path (no subsidy): a fill below Chainlink - 40 bps must revert in + // the forwarder's own post-condition, not in the router (its minimum is zero). _fund(1_000e18); - router.setNextOut(1_100e6); // below 1128.6 -> router-side minOut check fires - vm.prank(keeper); - vm.expectRevert("Too little received"); - fwd.swapAndForward(0); + fwd.poke(); + skip(TRIGGER_DELAY + 1); + oracle.set(1.14e8, block.timestamp); + router.setNextOut(ORACLE_FLOOR_1K - 1); + vm.prank(rando); + vm.expectRevert(VortexForwarder.InsufficientOutput.selector); + fwd.swapAndForward(REF, 0); + + router.setNextOut(ORACLE_FLOOR_1K); + vm.prank(rando); + fwd.swapAndForward(REF, 0); + assertEq(usdc.balanceOf(destination), ORACLE_FLOOR_1K); } function test_swapAndForward_revertsOnStaleOracle() public { _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); oracle.set(1.14e8, block.timestamp); skip(53 hours); // just past the 52h P8 window vm.prank(keeper); vm.expectRevert(VortexForwarder.StalePrice.selector); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); } function test_swapAndForward_publicOnlyAfterTriggerDelay() public { _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); fwd.poke(); vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); skip(TRIGGER_DELAY + 1); oracle.set(1.14e8, block.timestamp); vm.prank(rando); - fwd.swapAndForward(0); - assertEq(usdc.balanceOf(destination), 1_130e6); + fwd.swapAndForward(REF, 0); + assertEq(usdc.balanceOf(destination), TARGET_1K); } function test_swapAndForward_revertsOnZeroOrNegativePrice() public { _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); oracle.set(0, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); oracle.set(-1, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); } /// Review r1 P2: a perSwapCap remainder must keep its stranding timers armed — @@ -351,10 +387,10 @@ contract VortexForwarderTest is Test { _fund(15_000e18); // cap is 10k fwd.poke(); assertGt(fwd.strandedSince(), 0); - router.setNextOut(11_290e6); + router.setNextOut(TARGET_10K); skip(1 hours); vm.prank(keeper); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); assertEq(eure.balanceOf(address(fwd)), 5_000e18); assertEq(fwd.strandedSince(), block.timestamp, "remainder must stay armed (fresh timestamp)"); } @@ -362,55 +398,43 @@ contract VortexForwarderTest is Test { function test_swapAndForward_respectsPerSwapCap() public { _fund(15_000e18); // cap is 10k // minOut for 10k at 1.14*0.99 = 11286 USDC - router.setNextOut(11_290e6); + router.setNextOut(TARGET_10K); vm.prank(keeper); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); assertEq(eure.balanceOf(address(fwd)), 5_000e18); // remainder awaits next execution } - function test_swapAndForward_feeSkim() public { - VortexForwarder feeFwd = - VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 50, bytes32(uint256(3)))); - eure.mint(address(feeFwd), 1_000e18); - router.setNextOut(1_130e6); - vm.prank(keeper); - feeFwd.swapAndForward(0); - uint256 fee = (1_130e6 * 50) / 10_000; - assertEq(usdc.balanceOf(feeRecipient), fee); - assertEq(usdc.balanceOf(destination), 1_130e6 - fee); - } - function test_swapAndForward_pausedByGuardianOrClientOrGlobal() public { _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); fwd.setGuardianPaused(true); // test contract is factory guardian vm.prank(keeper); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); fwd.setGuardianPaused(false); vm.prank(fallbackAddr); fwd.setClientPaused(true); vm.prank(keeper); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); vm.prank(fallbackAddr); fwd.setClientPaused(false); factory.setGlobalPaused(true); vm.prank(keeper); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); } function test_unsolicitedUsdc_forwardedWithNextSwap() public { usdc.mint(address(fwd), 500e6); // unsolicited direct transfer (R09) _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); vm.prank(keeper); - fwd.swapAndForward(0); - assertEq(usdc.balanceOf(destination), 1_130e6 + 500e6); + fwd.swapAndForward(REF, 0); + assertEq(usdc.balanceOf(destination), TARGET_1K + 500e6); } function test_reentrantRouter_blockedByGuard() public { @@ -425,8 +449,9 @@ contract VortexForwarderTest is Test { attestor: attestor, feeRecipient: feeRecipient, maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 100, - maxFeeBps: 100, + slippageBps: 40, + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, sweepDelay: SWEEP_DELAY, triggerDelay: TRIGGER_DELAY, recoveryHash: bytes32(0) @@ -438,11 +463,11 @@ contract VortexForwarderTest is Test { _route(500, 500) ); f2.setKeeper(keeper, true); - VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(7)))); + VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(7)))); eure.mint(address(fwd2), 1_000e18); vm.prank(keeper); vm.expectRevert(VortexForwarder.Reentrancy.selector); - fwd2.swapAndForward(0); + fwd2.swapAndForward(REF, 0); } // ---------------------------------------------------------------- recovery @@ -522,7 +547,7 @@ contract VortexForwarderTest is Test { function test_predictAddress_matchesDeployment() public { bytes32 salt = bytes32(uint256(42)); address predicted = factory.predictAddress(salt); - address deployed = factory.deployForwarder(destination, fallbackAddr, 0, salt); + address deployed = factory.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, salt); assertEq(predicted, deployed); } @@ -546,9 +571,9 @@ contract VortexForwarderTest is Test { assertEq(factory.routeCount(), 1); _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); vm.prank(keeper); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); assertEq(router.lastPath(), _route(500, 500)); } @@ -558,24 +583,24 @@ contract VortexForwarderTest is Test { assertEq(index, 1); _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); vm.prank(keeper); - fwd.swapAndForward(1); + fwd.swapAndForward(REF, 1); assertEq(router.lastPath(), direct); } function test_routes_unknownOrDisabledRouteReverts() public { _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); vm.prank(keeper); vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); - fwd.swapAndForward(7); + fwd.swapAndForward(REF, 7); factory.setRouteEnabled(0, false); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidRoute.selector); - fwd.swapAndForward(0); + fwd.swapAndForward(REF, 0); vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); factory.setRouteEnabled(7, false); @@ -611,90 +636,237 @@ contract VortexForwarderTest is Test { vm.stopPrank(); } - function test_feeBps_cappedAtMax() public { - vm.expectRevert(VortexForwarder.FeeTooHigh.selector); - factory.deployForwarder(destination, fallbackAddr, 101, bytes32(uint256(9))); + // ---------------------------------------------------------------- fee bands + + function test_swap_aboveTarget_surplusIsTheFee() public { + _fund(1_000e18); + router.setNextOut(1_145e6); + vm.prank(keeper); + fwd.swapAndForward(REF, 0); + assertEq(usdc.balanceOf(destination), TARGET_1K); + assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); + assertEq(usdc.balanceOf(address(vault)), 1_000e6); + } + + function test_swap_feeCappedAtMaxFeePpm() public { + _fund(1_000e18); + router.setNextOut(1_200e6); // ~5% above the reference + vm.prank(keeper); + fwd.swapAndForward(REF, 0); + assertEq(usdc.balanceOf(feeRecipient), 12e6); // 1% of the fill, not the whole surplus + assertEq(usdc.balanceOf(destination), 1_188e6); + } + + function test_swap_betweenFloorAndTarget_noFeeNoSubsidy() public { + _fund(1_000e18); + router.setNextOut(1_138_400_000); + vm.prank(keeper); + fwd.swapAndForward(REF, 0); + assertEq(usdc.balanceOf(destination), 1_138_400_000); + assertEq(usdc.balanceOf(feeRecipient), 0); + assertEq(usdc.balanceOf(address(vault)), 1_000e6); + } + + function test_swap_belowFloor_vaultTopsUpToTheFloor() public { + _fund(1_000e18); + router.setNextOut(1_136e6); + vm.prank(keeper); + fwd.swapAndForward(REF, 0); + uint256 subsidy = FLOOR_1K - 1_136e6; // 2.29 USDC + assertEq(usdc.balanceOf(destination), FLOOR_1K); + assertEq(usdc.balanceOf(address(vault)), 1_000e6 - subsidy); + assertEq(vault.spentToday(), subsidy); + assertEq(usdc.balanceOf(feeRecipient), 0); + assertEq(usdc.balanceOf(address(fwd)), 0); + } + + function test_swap_rawFillBelowOracleFloor_isRescuedBySubsidy() public { + _fund(1_000e18); + router.setNextOut(1_134e6); // below Chainlink - 40 bps, within the vault's per-swap cap + vm.prank(keeper); + fwd.swapAndForward(REF, 0); + assertEq(usdc.balanceOf(destination), FLOOR_1K); + } + + function test_swap_subsidyOverCap_revertsTheWholeSwap() public { + _fund(1_000e18); + router.setNextOut(1_130e6); // needs 8.29 USDC; the cap is 50 bps of 1140 = 5.7 USDC + vm.prank(keeper); + vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); + fwd.swapAndForward(REF, 0); + assertEq(eure.balanceOf(address(fwd)), 1_000e18); + assertEq(usdc.balanceOf(destination), 0); + } + + function test_swap_subsidyOverBudget_reverts() public { + vault.setDailyBudget(1e6); + _fund(1_000e18); + router.setNextOut(1_136e6); + vm.prank(keeper); + vm.expectRevert(VortexSubsidyVault.BudgetExhausted.selector); + fwd.swapAndForward(REF, 0); + } + + function test_swap_withoutVault_onlyFillsAtOrAboveTheFloorSucceed() public { + factory.setSubsidyVault(address(0)); + _fund(1_000e18); + router.setNextOut(1_136e6); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyUnavailable.selector); + fwd.swapAndForward(REF, 0); + + router.setNextOut(1_145e6); + vm.prank(keeper); + fwd.swapAndForward(REF, 0); + assertEq(usdc.balanceOf(destination), TARGET_1K); + } + + function test_swap_depeggedReference_cannotBePaperedOverBySubsidy() public { + _fund(1_000e18); + uint256 lowReference = (REF * 9_910) / 10_000; // 90 bps below Chainlink: inside the band + // The floor at that reference (~1128.05 USDC) is below Chainlink - 40 bps (1135.44): + // the vault would top the client up to it, and the swap must still revert. + router.setNextOut(1_127e6); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InsufficientOutput.selector); + fwd.swapAndForward(lowReference, 0); + assertEq(usdc.balanceOf(address(vault)), 1_000e6, "subsidy transfer must be undone"); + } + + function test_swap_referenceOutsideTheBandReverts() public { + _fund(1_000e18); + router.setNextOut(1_150e6); + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); + fwd.swapAndForward((REF * 10_101) / 10_000, 0); // 101 bps above + vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); + fwd.swapAndForward((REF * 9_899) / 10_000, 0); // 101 bps below + vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); + fwd.swapAndForward(0, 0); + fwd.swapAndForward((REF * 10_100) / 10_000, 0); // exactly 100 bps: allowed + vm.stopPrank(); + assertGt(usdc.balanceOf(destination), 0); + } + + function test_swap_permissionless_pricesAgainstChainlinkAndPaysNoSubsidy() public { + _fund(1_000e18); + fwd.poke(); + skip(TRIGGER_DELAY + 1); + oracle.set(1.14e8, block.timestamp); + router.setNextOut(1_136e6); // below the floor: the client simply gets the fill + vm.prank(rando); + fwd.swapAndForward(1, 0); // garbage reference is ignored on this path + assertEq(usdc.balanceOf(destination), 1_136e6); + assertEq(usdc.balanceOf(address(vault)), 1_000e6); + + _fund(1_000e18); + fwd.poke(); + skip(TRIGGER_DELAY + 1); + oracle.set(1.14e8, block.timestamp); + router.setNextOut(1_145e6); // above the Chainlink-based target: the fee still applies + vm.prank(rando); + fwd.swapAndForward(999, 0); + assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); } - // ------------------------------------------------------- fee timelock (P11) + // ------------------------------------------------------- fee policy (P11) + + function test_feePolicy_validatedAtDeploy() public { + vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); + factory.deployForwarder(destination, fallbackAddr, 2_000, 1_500, bytes32(uint256(9))); // target above floor + vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); + factory.deployForwarder(destination, fallbackAddr, 1_000, 10_001, bytes32(uint256(9))); // floor above cap + } - function test_setFeeBps_onlyGuardianAndCapped() public { + function test_setFeePolicy_onlyGuardianAndValidated() public { vm.prank(rando); vm.expectRevert(VortexForwarder.NotGuardian.selector); - fwd.setFeeBps(10); + fwd.setFeePolicy(1_000, 1_000); - vm.expectRevert(VortexForwarder.FeeTooHigh.selector); - fwd.setFeeBps(101); // above MAX_FEE_BPS, even for the guardian + vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); + fwd.setFeePolicy(1_600, 1_500); + vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); + fwd.setFeePolicy(1_000, 10_001); } - function test_setFeeBps_increaseIsTimelocked() public { - fwd.setFeeBps(50); - // Announced, not applied: swaps in the window still use the old fee. - assertEq(fwd.feeBps(), 0); - assertEq(fwd.pendingFeeBps(), 50); - assertEq(fwd.pendingFeeBpsEffectiveAt(), uint64(block.timestamp + fwd.FEE_INCREASE_TIMELOCK())); + function test_setFeePolicy_increaseIsTimelocked() public { + fwd.setFeePolicy(2_500, 3_000); + // Announced, not applied: swaps in the window still use the old policy. + assertEq(fwd.targetPpm(), TARGET_PPM); + assertEq(fwd.floorPpm(), FLOOR_PPM); + assertEq(fwd.pendingTargetPpm(), 2_500); + assertEq(fwd.pendingFloorPpm(), 3_000); + assertEq(fwd.pendingFeePolicyEffectiveAt(), uint64(block.timestamp + fwd.FEE_INCREASE_TIMELOCK())); vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); - fwd.applyFeeBps(); + fwd.applyFeePolicy(); vm.warp(block.timestamp + 24 hours); - vm.prank(rando); // apply is permissionless — the announcement is the authorization - fwd.applyFeeBps(); - assertEq(fwd.feeBps(), 50); - assertEq(fwd.pendingFeeBps(), 0); - assertEq(fwd.pendingFeeBpsEffectiveAt(), 0); + vm.prank(rando); // apply is permissionless: the announcement is the authorization + fwd.applyFeePolicy(); + assertEq(fwd.targetPpm(), 2_500); + assertEq(fwd.floorPpm(), 3_000); + assertEq(fwd.pendingFeePolicyEffectiveAt(), 0); - vm.expectRevert(VortexForwarder.NoPendingFee.selector); - fwd.applyFeeBps(); + vm.expectRevert(VortexForwarder.NoPendingFeePolicy.selector); + fwd.applyFeePolicy(); } - function test_setFeeBps_decreaseIsImmediateAndCancelsPending() public { - // Raise to 50 through the timelock first. - fwd.setFeeBps(50); + function test_setFeePolicy_raisingEitherValueIsAnIncrease() public { + fwd.setFeePolicy(1_000, 1_600); // target down, floor up: timelocked as a whole + assertEq(fwd.targetPpm(), TARGET_PPM); + assertEq(fwd.floorPpm(), FLOOR_PPM); + assertEq(fwd.pendingTargetPpm(), 1_000); + assertEq(fwd.pendingFloorPpm(), 1_600); + } + + function test_setFeePolicy_decreaseIsImmediateAndCancelsPending() public { + fwd.setFeePolicy(2_500, 3_000); vm.warp(block.timestamp + 24 hours); - fwd.applyFeeBps(); - - // Announce a further increase, then decrease before it applies: the decrease - // is immediate and the pending increase is cancelled. - fwd.setFeeBps(80); - fwd.setFeeBps(25); - assertEq(fwd.feeBps(), 25); - assertEq(fwd.pendingFeeBpsEffectiveAt(), 0); + fwd.applyFeePolicy(); + + fwd.setFeePolicy(4_000, 4_000); // announce a further increase + fwd.setFeePolicy(1_000, 1_200); // decrease before it applies: immediate, cancels + assertEq(fwd.targetPpm(), 1_000); + assertEq(fwd.floorPpm(), 1_200); + assertEq(fwd.pendingFeePolicyEffectiveAt(), 0); vm.warp(block.timestamp + 24 hours); - vm.expectRevert(VortexForwarder.NoPendingFee.selector); - fwd.applyFeeBps(); + vm.expectRevert(VortexForwarder.NoPendingFeePolicy.selector); + fwd.applyFeePolicy(); } - function test_setFeeBps_reannounceReplacesAndRestartsClock() public { - fwd.setFeeBps(50); + function test_setFeePolicy_reannounceReplacesAndRestartsClock() public { + fwd.setFeePolicy(2_500, 3_000); vm.warp(block.timestamp + 12 hours); - fwd.setFeeBps(80); // replaces the pending 50 and restarts the 24h clock - assertEq(fwd.pendingFeeBps(), 80); + fwd.setFeePolicy(4_000, 4_000); // replaces the pending pair and restarts the 24h clock + assertEq(fwd.pendingTargetPpm(), 4_000); - vm.warp(block.timestamp + 12 hours + 1); // 24h after FIRST announcement only + vm.warp(block.timestamp + 12 hours + 1); // 24h after the FIRST announcement only vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); - fwd.applyFeeBps(); + fwd.applyFeePolicy(); vm.warp(block.timestamp + 12 hours); - fwd.applyFeeBps(); - assertEq(fwd.feeBps(), 80); + fwd.applyFeePolicy(); + assertEq(fwd.targetPpm(), 4_000); + assertEq(fwd.floorPpm(), 4_000); } - function test_setFeeBps_restatingCurrentCancelsWithoutChange() public { - fwd.setFeeBps(50); - fwd.setFeeBps(0); // re-state the current value: cancel-only gesture - assertEq(fwd.feeBps(), 0); - assertEq(fwd.pendingFeeBpsEffectiveAt(), 0); + function test_setFeePolicy_restatingCurrentCancelsWithoutChange() public { + fwd.setFeePolicy(2_500, 3_000); + fwd.setFeePolicy(TARGET_PPM, FLOOR_PPM); // re-state the current values: cancel-only gesture + assertEq(fwd.targetPpm(), TARGET_PPM); + assertEq(fwd.floorPpm(), FLOOR_PPM); + assertEq(fwd.pendingFeePolicyEffectiveAt(), 0); } - function test_swapDuringPendingIncrease_usesOldFee() public { - fwd.setFeeBps(50); // pending, not applied + function test_swapDuringPendingIncrease_usesOldPolicy() public { + fwd.setFeePolicy(2_500, 3_000); // pending, not applied _fund(1_000e18); - router.setNextOut(1_140e6); + router.setNextOut(1_145e6); vm.prank(keeper); - fwd.swapAndForward(0); - // Zero fee taken: the announced-but-unapplied increase never touches a swap. - assertEq(usdc.balanceOf(feeRecipient), 0); - assertEq(usdc.balanceOf(destination), 1_140e6); + fwd.swapAndForward(REF, 0); + // The fee closes the gap to the OLD target: the announced policy never touches a swap. + assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); + assertEq(usdc.balanceOf(destination), TARGET_1K); } } From 429fa4dad334c6f1e6961ecda690a517ff186988 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:17:58 +0200 Subject: [PATCH 05/44] test(repo): apply the decided 7 day sweep delay to the contract configs --- contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol | 2 +- .../monerium-forwarder/test/VortexForwarder.invariants.t.sol | 2 +- contracts/monerium-forwarder/test/VortexForwarder.t.sol | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol index 95d36268d..e53c312bb 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -62,7 +62,7 @@ contract VortexForwarderForkTest is Test { slippageBps: 40, maxFeePpm: 10_000, maxReferenceDeviationBps: 100, - sweepDelay: 60 days, + sweepDelay: 7 days, // registry P3 triggerDelay: 24 hours, recoveryHash: bytes32(0) }), diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol index 30622b811..51420615c 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -61,7 +61,7 @@ contract ForwarderHandler is Test { slippageBps: 40, maxFeePpm: 10_000, maxReferenceDeviationBps: 100, - sweepDelay: 60 days, + sweepDelay: 7 days, // registry P3 triggerDelay: 24 hours, recoveryHash: bytes32(0) }), diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 49271e240..3224712b3 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -113,7 +113,7 @@ contract VortexForwarderTest is Test { address rando = makeAddr("rando"); uint256 constant TRIGGER_DELAY = 24 hours; - uint256 constant SWEEP_DELAY = 60 days; + uint256 constant SWEEP_DELAY = 7 days; // registry P3 // Fee policy defaults (proposal): target 12.5 bps, floor 15 bps below the reference. uint32 constant TARGET_PPM = 1_250; From d61c92c0bef0fc1348b5aaefad5392e9130c5c7b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:19:43 +0200 Subject: [PATCH 06/44] chore(repo): teach the deployment manifest the fee policy, vault and routes --- .../script/manifest-core.ts | 84 ++++++++++++------- .../script/verify-manifest.test.ts | 7 +- 2 files changed, 60 insertions(+), 31 deletions(-) diff --git a/contracts/monerium-forwarder/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts index da1707f7f..8e12ba91b 100644 --- a/contracts/monerium-forwarder/script/manifest-core.ts +++ b/contracts/monerium-forwarder/script/manifest-core.ts @@ -11,7 +11,7 @@ import { Address, getAddress, Hex, keccak256, PublicClient, parseAbi, parseAbiIt * source on a block explorer. */ -export const MANIFEST_VERSION = 2; +export const MANIFEST_VERSION = 3; export const MANIFEST_PURPOSE = "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). " + @@ -30,17 +30,21 @@ export const factoryAbi = parseAbi([ "function globalPaused() view returns (bool)", "function minSwapAmount() view returns (uint256)", "function perSwapCap() view returns (uint256)", - "function isForwarder(address forwarder) view returns (bool)" + "function isForwarder(address forwarder) view returns (bool)", + "function subsidyVault() view returns (address)", + "function routeCount() view returns (uint256)", + "function route(uint256 index) view returns (bytes path, bool enabled)" ]); export const forwarderDeployedEvent = parseAbiItem( - "event ForwarderDeployed(address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt)" + "event ForwarderDeployed(address indexed forwarder, address indexed destination, address fallbackAddress, uint32 targetPpm, uint32 floorPpm, bytes32 salt)" ); export const forwarderConfigAbi = parseAbi([ "function destination() view returns (address)", "function fallbackAddress() view returns (address)", - "function feeBps() view returns (uint16)" + "function targetPpm() view returns (uint32)", + "function floorPpm() view returns (uint32)" ]); export const implementationAbi = parseAbi([ @@ -55,11 +59,10 @@ export const implementationAbi = parseAbi([ "function FEE_RECIPIENT() view returns (address)", "function MAX_ORACLE_AGE() view returns (uint256)", "function SLIPPAGE_BPS() view returns (uint16)", - "function MAX_FEE_BPS() view returns (uint16)", + "function MAX_FEE_PPM() view returns (uint32)", + "function MAX_REFERENCE_DEVIATION_BPS() view returns (uint16)", "function SWEEP_DELAY() view returns (uint256)", "function TRIGGER_DELAY() view returns (uint256)", - "function POOL_FEE_EURE_EURC() view returns (uint24)", - "function POOL_FEE_EURC_USDC() view returns (uint24)", "function LINK_HASH_191() view returns (bytes32)", "function RECOVERY_HASH() view returns (bytes32)", "function LINK_MESSAGE() view returns (string)" @@ -76,12 +79,11 @@ export interface ImplementationImmutables { FEE_RECIPIENT: string; LINK_HASH_191: Hex; LINK_MESSAGE: string; - MAX_FEE_BPS: number; + MAX_FEE_PPM: number; MAX_ORACLE_AGE: string; + MAX_REFERENCE_DEVIATION_BPS: number; ORACLE: string; ORACLE_DECIMALS: number; - POOL_FEE_EURC_USDC: number; - POOL_FEE_EURE_EURC: number; RECOVERY_HASH: Hex; ROUTER: string; SLIPPAGE_BPS: number; @@ -106,9 +108,10 @@ export interface ForwarderManifestEntry { salt: Hex; txHash: Hex; }; - /** Guardian-adjustable under the contract's bounded, timelocked fee policy. */ + /** Guardian-adjustable under the contract's bounded, timelocked fee policy (ppm below the reference). */ guardianMutable: { - feeBps: number; + floorPpm: number; + targetPpm: number; }; /** Factory registration is fixed for the lifetime of the clone. Mismatch = incident. */ immutables: { @@ -129,14 +132,16 @@ export interface CoreState { }; /** * Guardian-tunable within the immutable bounds (registry P6/P7) plus role/pause - * state. Drift here is legitimate operation: the verifier reports NOTICE, not - * failure. + * state, the subsidy vault and the on-chain validated route whitelist. Drift here + * is legitimate operation: the verifier reports NOTICE, not failure. */ operational: { globalPaused: boolean; guardian: string; minSwapAmount: string; perSwapCap: string; + routes: { enabled: boolean; path: Hex }[]; + subsidyVault: string; }; runtimeBytecodeHash: Hex; }; @@ -286,15 +291,35 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres const factory = getAddress(factoryAddress); const chainId = await client.getChainId(); - const [implementation, minSwapFloor, capCeiling, guardian, globalPaused, minSwapAmount, perSwapCap] = await Promise.all([ + const [ + implementation, + minSwapFloor, + capCeiling, + guardian, + globalPaused, + minSwapAmount, + perSwapCap, + subsidyVault, + routeCount + ] = await Promise.all([ read
(client, factoryAbi, factory, "implementation"), read(client, factoryAbi, factory, "MIN_SWAP_FLOOR"), read(client, factoryAbi, factory, "CAP_CEILING"), read
(client, factoryAbi, factory, "guardian"), read(client, factoryAbi, factory, "globalPaused"), read(client, factoryAbi, factory, "minSwapAmount"), - read(client, factoryAbi, factory, "perSwapCap") + read(client, factoryAbi, factory, "perSwapCap"), + read
(client, factoryAbi, factory, "subsidyVault"), + read(client, factoryAbi, factory, "routeCount") ]); + const routes = await Promise.all( + Array.from({ length: Number(routeCount) }, (_, index) => + read<[Hex, boolean]>(client, factoryAbi, factory, "route", [BigInt(index)]).then(([path, enabled]) => ({ + enabled, + path + })) + ) + ); const [ eure, @@ -308,11 +333,10 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres feeRecipient, maxOracleAge, slippageBps, - maxFeeBps, + maxFeePpm, + maxReferenceDeviationBps, sweepDelay, triggerDelay, - poolFeeEureEurc, - poolFeeEurcUsdc, linkHash191, recoveryHash, linkMessage @@ -328,11 +352,10 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres read
(client, implementationAbi, implementation, "FEE_RECIPIENT"), read(client, implementationAbi, implementation, "MAX_ORACLE_AGE"), read(client, implementationAbi, implementation, "SLIPPAGE_BPS"), - read(client, implementationAbi, implementation, "MAX_FEE_BPS"), + read(client, implementationAbi, implementation, "MAX_FEE_PPM"), + read(client, implementationAbi, implementation, "MAX_REFERENCE_DEVIATION_BPS"), read(client, implementationAbi, implementation, "SWEEP_DELAY"), read(client, implementationAbi, implementation, "TRIGGER_DELAY"), - read(client, implementationAbi, implementation, "POOL_FEE_EURE_EURC"), - read(client, implementationAbi, implementation, "POOL_FEE_EURC_USDC"), read(client, implementationAbi, implementation, "LINK_HASH_191"), read(client, implementationAbi, implementation, "RECOVERY_HASH"), read(client, implementationAbi, implementation, "LINK_MESSAGE") @@ -351,7 +374,9 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres globalPaused, guardian: getAddress(guardian), minSwapAmount: minSwapAmount.toString(), - perSwapCap: perSwapCap.toString() + perSwapCap: perSwapCap.toString(), + routes, + subsidyVault: subsidyVault === "0x0000000000000000000000000000000000000000" ? subsidyVault : getAddress(subsidyVault) }, runtimeBytecodeHash: await codeHash(client, factory) }, @@ -365,12 +390,11 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres FEE_RECIPIENT: getAddress(feeRecipient), LINK_HASH_191: linkHash191, LINK_MESSAGE: linkMessage, - MAX_FEE_BPS: Number(maxFeeBps), + MAX_FEE_PPM: Number(maxFeePpm), MAX_ORACLE_AGE: maxOracleAge.toString(), + MAX_REFERENCE_DEVIATION_BPS: Number(maxReferenceDeviationBps), ORACLE: getAddress(oracle), ORACLE_DECIMALS: Number(oracleDecimals), - POOL_FEE_EURC_USDC: Number(poolFeeEurcUsdc), - POOL_FEE_EURE_EURC: Number(poolFeeEureEurc), RECOVERY_HASH: recoveryHash, ROUTER: getAddress(router), SLIPPAGE_BPS: Number(slippageBps), @@ -392,10 +416,11 @@ export async function readForwarderEntry( ): Promise { const factory = getAddress(factoryAddress); const forwarder = getAddress(forwarderAddress); - const [destination, fallbackAddress, feeBps, isForwarder, forwarderCodeHash] = await Promise.all([ + const [destination, fallbackAddress, targetPpm, floorPpm, isForwarder, forwarderCodeHash] = await Promise.all([ read
(client, forwarderConfigAbi, forwarder, "destination"), read
(client, forwarderConfigAbi, forwarder, "fallbackAddress"), - read(client, forwarderConfigAbi, forwarder, "feeBps"), + read(client, forwarderConfigAbi, forwarder, "targetPpm"), + read(client, forwarderConfigAbi, forwarder, "floorPpm"), read(client, factoryAbi, factory, "isForwarder", [forwarder]), codeHash(client, forwarder) ]); @@ -411,7 +436,8 @@ export async function readForwarderEntry( txHash: deploy.txHash }, guardianMutable: { - feeBps: Number(feeBps) + floorPpm: Number(floorPpm), + targetPpm: Number(targetPpm) }, immutables: { isForwarder diff --git a/contracts/monerium-forwarder/script/verify-manifest.test.ts b/contracts/monerium-forwarder/script/verify-manifest.test.ts index 420901354..5b079d31a 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.test.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "bun:test"; import { severityFor } from "./verify-manifest"; describe("manifest diff severity", () => { - it("treats guardian fee changes as notices", () => { - expect(severityFor("forwarders.0x123.guardianMutable.feeBps")).toBe("NOTICE"); + it("treats guardian fee policy, vault and route changes as notices", () => { + expect(severityFor("forwarders.0x123.guardianMutable.targetPpm")).toBe("NOTICE"); + expect(severityFor("forwarders.0x123.guardianMutable.floorPpm")).toBe("NOTICE"); + expect(severityFor("factory.operational.subsidyVault")).toBe("NOTICE"); + expect(severityFor("factory.operational.routes.0.enabled")).toBe("NOTICE"); }); it("keeps client changes expected and immutable changes fatal", () => { From e31996f0792cbd183ec10d00e64dd20180f1f356 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:20:22 +0200 Subject: [PATCH 07/44] style(repo): format the forwarder contracts and tests --- contracts/monerium-forwarder/src/VortexForwarder.sol | 9 ++------- .../monerium-forwarder/src/VortexForwarderFactory.sol | 5 ++--- .../test/VortexForwarder.invariants.t.sol | 4 +++- .../monerium-forwarder/test/VortexForwarder.t.sol | 11 +++++++---- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index d953f9039..f147d8a80 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -250,9 +250,7 @@ contract VortexForwarder { // ---------------------------------------------------------- initialization /// @notice Called by the factory in the same transaction as clone deployment. - function initialize(address destination_, address fallbackAddress_, uint32 targetPpm_, uint32 floorPpm_) - external - { + function initialize(address destination_, address fallbackAddress_, uint32 targetPpm_, uint32 floorPpm_) external { if (msg.sender != address(FACTORY)) revert NotFactory(); if (initialized) revert AlreadyInitialized(); _validateConfigAddress(destination_); @@ -390,10 +388,7 @@ contract VortexForwarder { _approve(EURE, address(ROUTER), amountIn); ROUTER.exactInput( ISwapRouter02.ExactInputParams({ - path: path, - recipient: address(this), - amountIn: amountIn, - amountOutMinimum: 0 + path: path, recipient: address(this), amountIn: amountIn, amountOutMinimum: 0 }) ); _approve(EURE, address(ROUTER), 0); diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol index 46b5fafc7..e77a3abc8 100644 --- a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -236,9 +236,8 @@ contract VortexForwarderFactory { /// @dev Standard EIP-1167 minimal proxy init code for `target`. function _cloneInitCode(address target) internal pure returns (bytes memory) { - return abi.encodePacked( - hex"3d602d80600a3d3981f3363d3d373d3d3d363d73", target, hex"5af43d82803e903d91602b57fd5bf3" - ); + return + abi.encodePacked(hex"3d602d80600a3d3981f3363d3d373d3d3d363d73", target, hex"5af43d82803e903d91602b57fd5bf3"); } function _cloneDeterministic(address target, bytes32 salt) internal returns (address instance) { diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol index 51420615c..903551910 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -78,7 +78,9 @@ contract ForwarderHandler is Test { usdc.mint(address(vault), VAULT_FUNDING); factory.setSubsidyVault(address(vault)); fwd = VortexForwarder( - factory.deployForwarder(destination, fallbackAddr, INITIAL_TARGET_PPM, INITIAL_FLOOR_PPM, bytes32(uint256(1))) + factory.deployForwarder( + destination, fallbackAddr, INITIAL_TARGET_PPM, INITIAL_FLOOR_PPM, bytes32(uint256(1)) + ) ); ghostExpectedTargetPpm = INITIAL_TARGET_PPM; ghostExpectedFloorPpm = INITIAL_FLOOR_PPM; diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 3224712b3..f66feee03 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -249,7 +249,8 @@ contract VortexForwarderTest is Test { 10_000e18, _route(500, 500) ); - VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(8)))); + VortexForwarder fwd2 = + VortexForwarder(f2.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(8)))); // Recovery hash validates with attestor binding; link still validates; others fail. bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(fwd2), recoveryHash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); @@ -283,8 +284,9 @@ contract VortexForwarderTest is Test { } function test_linkSignature_rejectsCrossCloneReplay() public { - VortexForwarder other = - VortexForwarder(factory.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(2)))); + VortexForwarder other = VortexForwarder( + factory.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(2))) + ); bytes32 h = fwd.LINK_HASH_191(); // Signature bound to `fwd` must not validate on `other`. assertEq(other.isValidSignature(h, _attest(address(fwd), h)), bytes4(0xffffffff)); @@ -463,7 +465,8 @@ contract VortexForwarderTest is Test { _route(500, 500) ); f2.setKeeper(keeper, true); - VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(7)))); + VortexForwarder fwd2 = + VortexForwarder(f2.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(7)))); eure.mint(address(fwd2), 1_000e18); vm.prank(keeper); vm.expectRevert(VortexForwarder.Reentrancy.selector); From 7769a5d1c0c9b67d37d13d17c25c34072ec254f5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:37:23 +0200 Subject: [PATCH 08/44] feat(api): mirror the forwarder fee policy in ppm on monerium accounts --- .../admin/moneriumB2b.controller.test.ts | 29 ++++-- .../admin/moneriumB2b.controller.ts | 15 +-- .../controllers/monerium-b2b.controller.ts | 5 +- .../monerium-b2b/account-provisioning.ts | 95 +++++++++++++++---- .../conversion-allocation.test.ts | 1 - .../monerium-b2b/deposit-processor.test.ts | 1 - .../services/monerium-b2b/monitoring.test.ts | 9 +- .../api/services/monerium-b2b/monitoring.ts | 38 +++++--- .../services/monerium-b2b/onboarding.test.ts | 2 - .../migrations/078-monerium-fee-policy-ppm.ts | 28 ++++++ apps/api/src/models/moneriumAccount.model.ts | 31 ++++-- ...erium-b2b-account-read.integration.test.ts | 2 +- docs/api/openapi/vortex.openapi.d.ts | 5 +- docs/api/openapi/vortex.openapi.json | 12 ++- docs/operations-monerium-b2b-runbook.md | 3 +- 15 files changed, 205 insertions(+), 71 deletions(-) create mode 100644 apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts index db25244c6..3b9713dbc 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -123,8 +123,9 @@ describe("monerium b2b account mapping admin route", () => { expect(row).toMatchObject({ destination: DESTINATION, fallbackAddress: FALLBACK, - feeBps: 0, + floorPpm: 1500, forwarderAddress: FORWARDER, + targetPpm: 1250, vortexProfileId: account.profileId }); }); @@ -150,7 +151,6 @@ describe("monerium b2b account mapping admin route", () => { await MoneriumAccount.create({ destination: DESTINATION, fallbackAddress: FALLBACK, - feeBps: 0, forwarderAddress: FORWARDER, profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" }); @@ -191,8 +191,8 @@ describe("monerium b2b account mapping admin route", () => { ); expect(differentSubject.status).toBe(409); - // Same everything, different feeBps: divergence, not a silent idempotent replay. - const differentFee = await post(validBody(managerProfileId, { feeBps: 25 })); + // Same everything, different fee policy: divergence, not a silent idempotent replay. + const differentFee = await post(validBody(managerProfileId, { targetPpm: 1_000 })); expect(differentFee.status).toBe(409); expect(await MoneriumAccount.count()).toBe(1); @@ -207,16 +207,25 @@ describe("monerium b2b account mapping admin route", () => { destination: DESTINATION.toLowerCase(), factory: FACTORY.toLowerCase(), fallbackAddress: FALLBACK.toLowerCase(), - feeBps: 0 + floorPpm: 1500, + targetPpm: 1250 + }; + const matching = { + destination: DESTINATION, + factory: FACTORY, + fallbackAddress: FALLBACK, + floorPpm: 1500, + isForwarder: true, + targetPpm: 1250 }; - const matching = { destination: DESTINATION, factory: FACTORY, fallbackAddress: FALLBACK, feeBps: 0, isForwarder: true }; expect(forwarderConfigMismatch(expected, matching)).toBeNull(); expect(forwarderConfigMismatch(expected, { ...matching, factory: FORWARDER })).toContain("trusted factory"); expect(forwarderConfigMismatch(expected, { ...matching, isForwarder: false })).toContain("not a clone"); expect(forwarderConfigMismatch(expected, { ...matching, destination: FALLBACK })).toContain("destination"); expect(forwarderConfigMismatch(expected, { ...matching, fallbackAddress: DESTINATION })).toContain("fallbackAddress"); - expect(forwarderConfigMismatch(expected, { ...matching, feeBps: 30 })).toContain("feeBps"); + expect(forwarderConfigMismatch(expected, { ...matching, targetPpm: 1_000 })).toContain("targetPpm"); + expect(forwarderConfigMismatch(expected, { ...matching, floorPpm: 2_000 })).toContain("floorPpm"); }); it("rejects invalid input and unknown managers", async () => { @@ -227,8 +236,10 @@ describe("monerium b2b account mapping admin route", () => { { destination: "0x12345" }, { fallbackAddress: "" }, { moneriumProfileId: "not-a-uuid" }, - { feeBps: 3.5 }, - { feeBps: -1 }, + { targetPpm: 3.5 }, + { floorPpm: -1 }, + { floorPpm: 10_001 }, + { floorPpm: 1_000, targetPpm: 1_200 }, { externalSubjectId: "" }, { contactEmail: "not-an-email" } ]) { diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts index 29804085a..b9cf13e5e 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -14,10 +14,11 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi destination, externalSubjectId, fallbackAddress, - feeBps, + floorPpm, forwarderAddress, managerProfileId, - moneriumProfileId + moneriumProfileId, + targetPpm } = req.body ?? {}; if ( typeof managerProfileId !== "string" || @@ -30,13 +31,14 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi typeof forwarderAddress !== "string" || typeof destination !== "string" || typeof fallbackAddress !== "string" || - (feeBps !== undefined && typeof feeBps !== "number") + (targetPpm !== undefined && typeof targetPpm !== "number") || + (floorPpm !== undefined && typeof floorPpm !== "number") ) { res.status(httpStatus.BAD_REQUEST).json({ error: { code: "MONERIUM_B2B_INVALID_INPUT", message: - "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; feeBps must be a number when present", + "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; targetPpm and floorPpm must be numbers when present", status: httpStatus.BAD_REQUEST } }); @@ -48,10 +50,11 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi destination, externalSubjectId, fallbackAddress, - feeBps, + floorPpm, forwarderAddress, managerProfileId, - moneriumProfileId + moneriumProfileId, + targetPpm }); res.status(result.created ? httpStatus.CREATED : httpStatus.OK).json({ account: result }); } catch (error) { diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index 7edfe4737..965505f52 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -98,10 +98,11 @@ export const getMoneriumB2bAccount = async (req: Request, res: Response, next: N destination: account.destination, dormantSince: account.dormantSince, fallbackAddress: account.fallbackAddress, - feeBps: account.feeBps, + floorPpm: account.floorPpm, forwarderAddress: account.forwarderAddress, iban: account.iban, - status: account.status + status: account.status, + targetPpm: account.targetPpm } }); } catch (error) { diff --git a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts index 93d3d8a36..2d75d2bae 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -26,10 +26,12 @@ export interface ProvisionMoneriumB2bAccountInput { destination: string; externalSubjectId: string; fallbackAddress: string; - feeBps?: number; + /** Fee policy in ppm below the reference rate; defaults to the agreed launch policy. */ + floorPpm?: number; forwarderAddress: string; managerProfileId: string; moneriumProfileId: string; + targetPpm?: number; } export interface ProvisionMoneriumB2bAccountResult { @@ -49,18 +51,43 @@ function normalizeAddress(value: string, name: string): string { return value.trim().toLowerCase(); } +/** Launch fee policy (docs/proposal-monerium-forwarder-fee-subsidy.md): 12.5 bps target, 15 bps floor. */ +export const DEFAULT_TARGET_PPM = 1_250; +export const DEFAULT_FLOOR_PPM = 1_500; +const MAX_FEE_PPM = 10_000; + +/** Mirrors the contract's _validateFeePolicy: both in [0, MAX_FEE_PPM], target never above floor. */ +export function isValidFeePolicy(targetPpm: number, floorPpm: number): boolean { + return ( + Number.isInteger(targetPpm) && + Number.isInteger(floorPpm) && + targetPpm >= 0 && + floorPpm <= MAX_FEE_PPM && + targetPpm <= floorPpm + ); +} + const forwarderConfigAbi = parseAbi([ "function destination() view returns (address)", "function fallbackAddress() view returns (address)", - "function feeBps() view returns (uint16)", + "function targetPpm() view returns (uint32)", + "function floorPpm() view returns (uint32)", "function FACTORY() view returns (address)" ]); const factoryRegistryAbi = parseAbi(["function isForwarder(address forwarder) view returns (bool)"]); /** Pure comparison of the submitted account data against the deployed clone's config. */ +export interface ForwarderPolicyConfig { + destination: string; + factory: string; + fallbackAddress: string; + floorPpm: number; + targetPpm: number; +} + export function forwarderConfigMismatch( - expected: { destination: string; factory: string; fallbackAddress: string; feeBps: number }, - onchain: { destination: string; factory: string; fallbackAddress: string; feeBps: number; isForwarder: boolean } + expected: ForwarderPolicyConfig, + onchain: ForwarderPolicyConfig & { isForwarder: boolean } ): string | null { if (onchain.factory.toLowerCase() !== expected.factory.toLowerCase()) { return `on-chain factory ${onchain.factory} differs from the trusted factory`; @@ -74,8 +101,11 @@ export function forwarderConfigMismatch( if (onchain.fallbackAddress.toLowerCase() !== expected.fallbackAddress) { return `on-chain fallbackAddress ${onchain.fallbackAddress} differs from the submitted value`; } - if (onchain.feeBps !== expected.feeBps) { - return `on-chain feeBps ${onchain.feeBps} differs from the submitted ${expected.feeBps}`; + if (onchain.targetPpm !== expected.targetPpm) { + return `on-chain targetPpm ${onchain.targetPpm} differs from the submitted ${expected.targetPpm}`; + } + if (onchain.floorPpm !== expected.floorPpm) { + return `on-chain floorPpm ${onchain.floorPpm} differs from the submitted ${expected.floorPpm}`; } return null; } @@ -92,7 +122,8 @@ async function verifyForwarderOnChain( forwarderAddress: string, destination: string, fallbackAddress: string, - feeBps: number + targetPpm: number, + floorPpm: number ): Promise { if (!config.moneriumB2b.rpcUrl) { return; @@ -106,12 +137,13 @@ async function verifyForwarderOnChain( } const client = getPublicClient(); const address = forwarderAddress as Address; - let onchain: { destination: string; factory: string; fallbackAddress: string; feeBps: number; isForwarder: boolean }; + let onchain: ForwarderPolicyConfig & { isForwarder: boolean }; try { - const [onchainDestination, onchainFallback, onchainFeeBps, factory] = await Promise.all([ + const [onchainDestination, onchainFallback, onchainTargetPpm, onchainFloorPpm, factory] = await Promise.all([ client.readContract({ abi: forwarderConfigAbi, address, functionName: "destination" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "fallbackAddress" }), - client.readContract({ abi: forwarderConfigAbi, address, functionName: "feeBps" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "targetPpm" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "floorPpm" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "FACTORY" }) ]); const isForwarder = await client.readContract({ @@ -124,8 +156,9 @@ async function verifyForwarderOnChain( destination: onchainDestination, factory, fallbackAddress: onchainFallback, - feeBps: onchainFeeBps, - isForwarder + floorPpm: onchainFloorPpm, + isForwarder, + targetPpm: onchainTargetPpm }; } catch (error) { throw new MoneriumB2bProvisioningError( @@ -135,7 +168,10 @@ async function verifyForwarderOnChain( }` ); } - const mismatch = forwarderConfigMismatch({ destination, factory: trustedFactory, fallbackAddress, feeBps }, onchain); + const mismatch = forwarderConfigMismatch( + { destination, factory: trustedFactory, fallbackAddress, floorPpm, targetPpm }, + onchain + ); if (mismatch) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", @@ -224,13 +260,15 @@ function accountMatchesInput( forwarderAddress: string, destination: string, fallbackAddress: string, - feeBps: number + targetPpm: number, + floorPpm: number ): boolean { return ( account.forwarderAddress.toLowerCase() === forwarderAddress && account.destination.toLowerCase() === destination && account.fallbackAddress.toLowerCase() === fallbackAddress && - account.feeBps === feeBps && + account.targetPpm === targetPpm && + account.floorPpm === floorPpm && (account.vortexProfileId === null || account.vortexProfileId === childProfileId) ); } @@ -252,14 +290,18 @@ export async function provisionMoneriumB2bAccount( const forwarderAddress = normalizeAddress(input.forwarderAddress, "forwarderAddress"); const destination = normalizeAddress(input.destination, "destination"); const fallbackAddress = normalizeAddress(input.fallbackAddress, "fallbackAddress"); - const feeBps = input.feeBps ?? 0; - if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 10000) { - throw new MoneriumB2bProvisioningError("MONERIUM_B2B_INVALID_INPUT", "feeBps must be an integer between 0 and 10000"); + const targetPpm = input.targetPpm ?? DEFAULT_TARGET_PPM; + const floorPpm = input.floorPpm ?? DEFAULT_FLOOR_PPM; + if (!isValidFeePolicy(targetPpm, floorPpm)) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_INVALID_INPUT", + "targetPpm and floorPpm must be integers between 0 and 10000 with targetPpm <= floorPpm" + ); } // Before any persistence: a wrong clone address must fail here, not become a mapped // account whose config the monitors later legitimize. - await verifyForwarderOnChain(forwarderAddress, destination, fallbackAddress, feeBps); + await verifyForwarderOnChain(forwarderAddress, destination, fallbackAddress, targetPpm, floorPpm); let result: { account: { created: boolean; row: MoneriumAccount }; managedProfile: ProvisionManagedProfileResult }; try { @@ -282,7 +324,17 @@ export async function provisionMoneriumB2bAccount( const existing = await MoneriumAccount.findOne({ transaction, where: { profileId: moneriumProfileId } }); if (existing) { - if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, fallbackAddress, feeBps)) { + if ( + !accountMatchesInput( + existing, + managedProfile.profileId, + forwarderAddress, + destination, + fallbackAddress, + targetPpm, + floorPpm + ) + ) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", "The Monerium profile is already mapped with different account data" @@ -316,10 +368,11 @@ export async function provisionMoneriumB2bAccount( { destination, fallbackAddress, - feeBps, + floorPpm, forwarderAddress, profileId: moneriumProfileId, status: MoneriumAccountStatus.Onboarding, + targetPpm, vortexProfileId: managedProfile.profileId }, { transaction } diff --git a/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts index 5257a2b2a..af3bc0794 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts @@ -17,7 +17,6 @@ describe("confirmed Monerium conversion allocation", () => { const account = await MoneriumAccount.create({ destination: "0x2222222222222222222222222222222222222222", fallbackAddress: "0x3333333333333333333333333333333333333333", - feeBps: 0, forwarderAddress: "0x1111111111111111111111111111111111111111", profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" }); 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 c8bde0fae..27cfa3e81 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 @@ -181,7 +181,6 @@ describe("order-event inbox processing (end to end)", () => { return MoneriumAccount.create({ destination: "0x2222222222222222222222222222222222222222", fallbackAddress: "0x3333333333333333333333333333333333333333", - feeBps: 0, forwarderAddress: FORWARDER, profileId: PROFILE_ID }); 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 c8eaec395..e47df258c 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -134,7 +134,8 @@ describe("detectConfigDrift", () => { const base = { destination: "0x1111111111111111111111111111111111111111", fallbackAddress: "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", - feeBps: 0 + floorPpm: 1500, + targetPpm: 1250 }; it("reports nothing when the chain matches the db (case-insensitively)", () => { @@ -156,10 +157,10 @@ describe("detectConfigDrift", () => { }); }); - it("classifies a feeBps change as a guardian-authorized reconciliation (P11)", () => { - const drift = detectConfigDrift(base, { ...base, feeBps: 50 }); + it("classifies a fee-policy change as a guardian-authorized reconciliation (P11)", () => { + const drift = detectConfigDrift(base, { ...base, floorPpm: 3000, targetPpm: 2500 }); expect(drift.errors).toEqual([]); - expect(drift.ownerAuthorizedUpdates.feeBps).toBe(50); + expect(drift.ownerAuthorizedUpdates).toEqual({ floorPpm: 3000, targetPpm: 2500 }); }); }); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index f0df89938..219a0e621 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -35,7 +35,8 @@ import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./moneri * 4. Config reconciliation (manifest re-verification, R07): re-reads per-clone config * and clone bytecode. destination/fallbackAddress changes are owner-authorized by * construction (`onlyFallback` in the contract) — they are reconciled into the DB - * and logged, not alarmed. feeBps/bytecode/registration drift is an incident. + * and logged, not alarmed, as are guardian fee-policy changes (P11); bytecode or + * registration drift is an incident. * * None of these monitors hold keys or send transactions; they are detection-only. */ @@ -61,7 +62,8 @@ const chainlinkAbi = parseAbi([ const forwarderMonitoringAbi = parseAbi([ "function destination() view returns (address)", "function fallbackAddress() view returns (address)", - "function feeBps() view returns (uint16)", + "function targetPpm() view returns (uint32)", + "function floorPpm() view returns (uint32)", "function EURC() view returns (address)", "function USDC() view returns (address)", "function ORACLE() view returns (address)", @@ -171,29 +173,33 @@ export function diffAssociation(db: AssociationDbRecord, live: LiveAssociationSt export interface ForwarderConfigRecord { destination: string; fallbackAddress: string; - feeBps: number; + floorPpm: number; + targetPpm: number; } export interface ConfigDriftResult { /** Immutable-config violations — should be impossible; alarm, never reconcile. */ errors: string[]; /** Authorized on-chain transitions — reconcile the DB: destination/fallbackAddress - * change only via the client's own key (R07), feeBps only via the guardian's + * change only via the client's own key (R07), the fee policy only via the guardian's * timelocked setter (P11); both leave an on-chain event trail. */ - ownerAuthorizedUpdates: Partial>; + ownerAuthorizedUpdates: Partial; } /** * Classifies drift between the DB config record and on-chain clone state. * destination/fallbackAddress are mutable ONLY by the client's fallbackAddress - * (`onlyFallback`) and feeBps ONLY by the guardian's timelocked setter (P11), so any + * (`onlyFallback`) and the fee policy ONLY by the guardian's timelocked setter (P11), so any * change in those is an expected authorized transition to reconcile; everything else * (bytecode, registration) is immutable and a change there is an incident. */ export function detectConfigDrift(db: ForwarderConfigRecord, onchain: ForwarderConfigRecord): ConfigDriftResult { const result: ConfigDriftResult = { errors: [], ownerAuthorizedUpdates: {} }; - if (db.feeBps !== onchain.feeBps) { - result.ownerAuthorizedUpdates.feeBps = onchain.feeBps; + if (db.targetPpm !== onchain.targetPpm) { + result.ownerAuthorizedUpdates.targetPpm = onchain.targetPpm; + } + if (db.floorPpm !== onchain.floorPpm) { + result.ownerAuthorizedUpdates.floorPpm = onchain.floorPpm; } if (db.destination.toLowerCase() !== onchain.destination.toLowerCase()) { result.ownerAuthorizedUpdates.destination = onchain.destination; @@ -420,10 +426,11 @@ export async function runConfigReconciliation(): Promise { implementationByFactory.set(trustedFactory.toLowerCase(), implementation); } - const [destination, fallbackAddress, feeBps, isForwarder, code] = await Promise.all([ + const [destination, fallbackAddress, targetPpm, floorPpm, isForwarder, code] = await Promise.all([ client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "destination" }), client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "fallbackAddress" }), - client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "feeBps" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "targetPpm" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "floorPpm" }), client.readContract({ abi: factoryMonitoringAbi, address: trustedFactoryAddress, @@ -445,15 +452,20 @@ export async function runConfigReconciliation(): Promise { } const drift = detectConfigDrift( - { destination: account.destination, fallbackAddress: account.fallbackAddress, feeBps: account.feeBps }, - { destination, fallbackAddress, feeBps: Number(feeBps) } + { + destination: account.destination, + fallbackAddress: account.fallbackAddress, + floorPpm: account.floorPpm, + targetPpm: account.targetPpm + }, + { destination, fallbackAddress, floorPpm: Number(floorPpm), targetPpm: Number(targetPpm) } ); for (const error of drift.errors) { logger.error(`monerium-b2b: config violation on forwarder ${forwarder} (account ${account.id}): ${error}`); } if (Object.keys(drift.ownerAuthorizedUpdates).length > 0) { // Authorized transition: destination/fallback change only via the client's - // fallbackAddress (R07), feeBps only via the guardian's timelocked setter + // fallbackAddress (R07), the fee policy only via the guardian's timelocked setter // (P11) — reconcile, do not alarm. await account.update({ ...drift.ownerAuthorizedUpdates, configVersion: account.configVersion + 1 }); logger.warn( diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts index c3dbcfc67..8c4e5514e 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -77,7 +77,6 @@ async function createMappedAccount(overrides: Partial { await MoneriumAccount.create({ destination: DESTINATION, fallbackAddress: FALLBACK, - feeBps: 0, forwarderAddress: "0x9999999999999999999999999999999999999999", profileId: crypto.randomUUID() // no vortexProfileId: pre-mapping row stays operator-managed diff --git a/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts b/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts new file mode 100644 index 000000000..14c4fa541 --- /dev/null +++ b/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts @@ -0,0 +1,28 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// The forwarder prices swaps against a reference rate with a per-clone target and floor +// in parts per million (docs/proposal-monerium-forwarder-fee-subsidy.md). The flat +// fee_bps mirror is replaced by both policy values; defaults are the agreed launch policy. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_accounts", "target_ppm", { + allowNull: false, + defaultValue: 1250, + type: DataTypes.INTEGER + }); + await queryInterface.addColumn("monerium_accounts", "floor_ppm", { + allowNull: false, + defaultValue: 1500, + type: DataTypes.INTEGER + }); + await queryInterface.removeColumn("monerium_accounts", "fee_bps"); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_accounts", "fee_bps", { + allowNull: false, + defaultValue: 0, + type: DataTypes.INTEGER + }); + await queryInterface.removeColumn("monerium_accounts", "floor_ppm"); + await queryInterface.removeColumn("monerium_accounts", "target_ppm"); +} diff --git a/apps/api/src/models/moneriumAccount.model.ts b/apps/api/src/models/moneriumAccount.model.ts index 72e008be2..74d46a1fc 100644 --- a/apps/api/src/models/moneriumAccount.model.ts +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -21,7 +21,8 @@ export interface MoneriumAccountAttributes { forwarderAddress: string; destination: string; fallbackAddress: string; - feeBps: number; + targetPpm: number; + floorPpm: number; configVersion: number; status: MoneriumAccountStatus; dormantSince: Date | null; @@ -31,7 +32,16 @@ export interface MoneriumAccountAttributes { type MoneriumAccountCreationAttributes = Optional< MoneriumAccountAttributes, - "id" | "vortexProfileId" | "iban" | "configVersion" | "status" | "dormantSince" | "createdAt" | "updatedAt" + | "id" + | "vortexProfileId" + | "iban" + | "targetPpm" + | "floorPpm" + | "configVersion" + | "status" + | "dormantSince" + | "createdAt" + | "updatedAt" >; class MoneriumAccount @@ -45,7 +55,8 @@ class MoneriumAccount declare forwarderAddress: string; declare destination: string; declare fallbackAddress: string; - declare feeBps: number; + declare targetPpm: number; + declare floorPpm: number; declare configVersion: number; declare status: MoneriumAccountStatus; declare dormantSince: Date | null; @@ -81,10 +92,12 @@ MoneriumAccount.init( field: "fallback_address", type: DataTypes.STRING(42) }, - feeBps: { + // Fee policy mirror (ppm below the reference rate) for accounting and drift + // detection only; the clone's values are authoritative (P11 reconciliation). + floorPpm: { allowNull: false, - defaultValue: 0, - field: "fee_bps", + defaultValue: 1500, + field: "floor_ppm", type: DataTypes.INTEGER }, forwarderAddress: { @@ -113,6 +126,12 @@ MoneriumAccount.init( defaultValue: MoneriumAccountStatus.Onboarding, type: DataTypes.ENUM(...Object.values(MoneriumAccountStatus)) }, + targetPpm: { + allowNull: false, + defaultValue: 1250, + field: "target_ppm", + type: DataTypes.INTEGER + }, updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, diff --git a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts index cbcaef1e9..740adbfa1 100644 --- a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts +++ b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts @@ -79,7 +79,7 @@ describe("monerium b2b account read surface", () => { accountId: mapped.accountId, destination: DESTINATION, fallbackAddress: FALLBACK, - feeBps: 0, + floorPpm: 1500, forwarderAddress: FORWARDER, iban: null, status: "onboarding" diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 6b7fb2e70..cd4df4f8f 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2836,13 +2836,16 @@ export interface components { dormantSince: string | null; /** @description The client's self-custodied recovery address. */ fallbackAddress: string; - feeBps: number; + /** @description Fee policy floor in parts per million below the reference rate: the least the client receives on a keeper-executed swap. */ + floorPpm: number; /** @description The account's on-chain forwarding contract. */ forwarderAddress: string; /** @description The account's dedicated IBAN; null until issuance completes. */ iban: string | null; /** @enum {string} */ status: "onboarding" | "active" | "suspended" | "closed"; + /** @description Fee policy target in parts per million below the reference rate: what the client receives whenever the swap allows it. */ + targetPpm: number; }; MoneriumB2bAccountResponse: { account: components["schemas"]["MoneriumB2bAccount"]; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index dbc43b835..b49088a02 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2421,7 +2421,8 @@ "description": "The client's self-custodied recovery address.", "type": "string" }, - "feeBps": { + "floorPpm": { + "description": "Fee policy floor in parts per million below the reference rate: the least the client receives on a keeper-executed swap.", "type": "integer" }, "forwarderAddress": { @@ -2435,6 +2436,10 @@ "status": { "enum": ["onboarding", "active", "suspended", "closed"], "type": "string" + }, + "targetPpm": { + "description": "Fee policy target in parts per million below the reference rate: what the client receives whenever the swap allows it.", + "type": "integer" } }, "required": [ @@ -2443,10 +2448,11 @@ "destination", "dormantSince", "fallbackAddress", - "feeBps", + "floorPpm", "forwarderAddress", "iban", - "status" + "status", + "targetPpm" ], "type": "object" }, diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index a8fdce910..6672afa4a 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -90,7 +90,8 @@ POST /v1/admin/monerium-b2b/accounts (Authorization: Bearer $ADMIN_SECRET "forwarderAddress": "", "destination": "", "fallbackAddress": "", - "feeBps": 0 + "targetPpm": 1250, + "floorPpm": 1500 } ``` From 2966dcf1ba2a6d674b4a5da0bd8a0ba3360b8da3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:42:29 +0200 Subject: [PATCH 09/44] feat(api): price keeper swaps against the coinbase reference with route selection --- .../src/api/services/monerium-b2b/chain.ts | 113 ++++++- .../monerium-b2b/conversion-executor.test.ts | 125 ++++++-- .../monerium-b2b/conversion-executor.ts | 299 +++++++++++++++++- .../api/services/monerium-b2b/monitoring.ts | 16 +- .../monerium-b2b/reference-rate.test.ts | 71 +++++ .../services/monerium-b2b/reference-rate.ts | 70 ++++ ...79-add-conversion-reference-and-subsidy.ts | 41 +++ .../moneriumConversionExecution.model.ts | 51 +++ 8 files changed, 734 insertions(+), 52 deletions(-) create mode 100644 apps/api/src/api/services/monerium-b2b/reference-rate.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/reference-rate.ts create mode 100644 apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index e59eb8467..e57fd8b38 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -7,6 +7,7 @@ import { Hex, http, PublicClient, + parseAbi, parseAbiItem, Transport, WalletClient @@ -55,7 +56,7 @@ export const eureTransferEvent = parseAbiItem("event Transfer(address indexed fr // SwapExecuted as a standalone event item for getLogs-based crash recovery (must stay // in sync with the entry in forwarderAbi below). export const swapExecutedEvent = parseAbiItem( - "event SwapExecuted(address indexed caller, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded)" + "event SwapExecuted(address indexed caller, uint256 routeIndex, uint256 eureIn, uint256 usdcOut, uint256 referenceRate, uint256 fee, uint256 subsidy, uint256 forwarded)" ); export const erc20Abi = [ @@ -70,7 +71,16 @@ export const erc20Abi = [ export const forwarderAbi = [ { inputs: [], name: "poke", outputs: [], stateMutability: "nonpayable", type: "function" }, - { inputs: [], name: "swapAndForward", outputs: [], stateMutability: "nonpayable", type: "function" }, + { + inputs: [ + { name: "referenceRate", type: "uint256" }, + { name: "routeIndex", type: "uint256" } + ], + name: "swapAndForward", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, { inputs: [{ name: "paused", type: "bool" }], name: "setGuardianPaused", @@ -82,6 +92,20 @@ export const forwarderAbi = [ { inputs: [], name: "guardianPaused", outputs: [{ name: "", type: "bool" }], stateMutability: "view", type: "function" }, { inputs: [], name: "EURE", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, { inputs: [], name: "FACTORY", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "USDC", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "ORACLE", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "ORACLE_DECIMALS", outputs: [{ name: "", type: "uint8" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "SLIPPAGE_BPS", outputs: [{ name: "", type: "uint16" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "MAX_FEE_PPM", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, + { + inputs: [], + name: "MAX_REFERENCE_DEVIATION_BPS", + outputs: [{ name: "", type: "uint16" }], + stateMutability: "view", + type: "function" + }, + { inputs: [], name: "targetPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, { anonymous: false, inputs: [{ indexed: false, name: "strandedSince", type: "uint64" }], @@ -92,9 +116,12 @@ export const forwarderAbi = [ anonymous: false, inputs: [ { indexed: true, name: "caller", type: "address" }, + { indexed: false, name: "routeIndex", type: "uint256" }, { indexed: false, name: "eureIn", type: "uint256" }, { indexed: false, name: "usdcOut", type: "uint256" }, + { indexed: false, name: "referenceRate", type: "uint256" }, { indexed: false, name: "fee", type: "uint256" }, + { indexed: false, name: "subsidy", type: "uint256" }, { indexed: false, name: "forwarded", type: "uint256" } ], name: "SwapExecuted", @@ -111,9 +138,41 @@ export const forwarderAbi = [ export const factoryAbi = [ { inputs: [], name: "minSwapAmount", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, { inputs: [], name: "perSwapCap", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, - { inputs: [], name: "MIN_SWAP_FLOOR", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" } + { inputs: [], name: "MIN_SWAP_FLOOR", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "subsidyVault", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "routeCount", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { + inputs: [{ name: "index", type: "uint256" }], + name: "route", + outputs: [ + { name: "path", type: "bytes" }, + { name: "enabled", type: "bool" } + ], + stateMutability: "view", + type: "function" + } ] as const; +// VortexSubsidyVault: the guardian-tunable limits the keeper projects a swap against. +export const subsidyVaultAbi = parseAbi([ + "function maxSubsidyPpm() view returns (uint32)", + "function dailyBudget() view returns (uint256)", + "function spentToday() view returns (uint256)", + "function currentDay() view returns (uint256)", + "function paused() view returns (bool)" +]); + +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"; + +export const quoterV2Abi = parseAbi([ + "function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)" +]); + // ------------------------------------------------------------------ clients export type KeeperWalletClient = WalletClient; @@ -208,13 +267,19 @@ export async function getChainId(): Promise { return chainIdCache; } -interface ForwarderImmutables { +export interface ForwarderImmutables { eure: Address; factory: Address; + maxFeePpm: number; + maxReferenceDeviationBps: number; + oracle: Address; + oracleDecimals: number; + slippageBps: number; + usdc: Address; } -// EURE/FACTORY are implementation-level immutables shared by every clone, so one -// lookup per forwarder address is enough for the process lifetime. +// Implementation-level immutables shared by every clone, so one lookup per forwarder +// address is enough for the process lifetime. const forwarderImmutablesCache = new Map(); export async function getForwarderImmutables(forwarderAddress: Address): Promise { @@ -224,11 +289,39 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise return cached; } const client = getPublicClient(); - const [eure, factory] = await Promise.all([ - client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName: "EURE" }), - client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName: "FACTORY" }) + const read = < + T extends + | "EURE" + | "FACTORY" + | "USDC" + | "ORACLE" + | "ORACLE_DECIMALS" + | "SLIPPAGE_BPS" + | "MAX_FEE_PPM" + | "MAX_REFERENCE_DEVIATION_BPS" + >( + functionName: T + ) => client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName }); + const [eure, factory, usdc, oracle, oracleDecimals, slippageBps, maxFeePpm, maxReferenceDeviationBps] = await Promise.all([ + read("EURE"), + read("FACTORY"), + read("USDC"), + read("ORACLE"), + read("ORACLE_DECIMALS"), + read("SLIPPAGE_BPS"), + read("MAX_FEE_PPM"), + read("MAX_REFERENCE_DEVIATION_BPS") ]); - const immutables = { eure, factory }; + const immutables: ForwarderImmutables = { + eure, + factory, + maxFeePpm: Number(maxFeePpm), + maxReferenceDeviationBps: Number(maxReferenceDeviationBps), + oracle, + oracleDecimals: Number(oracleDecimals), + slippageBps: Number(slippageBps), + usdc + }; forwarderImmutablesCache.set(key, immutables); return immutables; } 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 514c97c84..9e37d38f1 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 @@ -12,7 +12,9 @@ import { broadcastSwapSequence, classifyHashlessPending, conversionAmountsFromSwapEvent, + expectedSwapCalldata, isExpectedSwapTransaction, + projectSwap, recoveryBlockRanges, runConversionExecutor, selectDepositsForExecution @@ -125,14 +127,99 @@ describe("allocateUsdcProRata", () => { }); describe("conversionAmountsFromSwapEvent", () => { - it("excludes unsolicited USDC swept alongside this swap", () => { - expect( - conversionAmountsFromSwapEvent({ fee: 8n * USDC, forwarded: 208n * USDC, usdcOut: 108n * USDC }) - ).toEqual({ feeRaw: "8000000", usdcGrossRaw: "108000000", usdcNetRaw: "100000000" }); + it("nets the fee out of this swap's output regardless of what was forwarded", () => { + expect(conversionAmountsFromSwapEvent({ fee: 8n * USDC, subsidy: 0n, usdcOut: 108n * USDC })).toEqual({ + feeRaw: "8000000", + subsidyRaw: "0", + usdcGrossRaw: "108000000", + usdcNetRaw: "100000000" + }); + }); + + it("adds the vault subsidy to the client's net", () => { + expect(conversionAmountsFromSwapEvent({ fee: 0n, subsidy: 2n * USDC, usdcOut: 106n * USDC })).toEqual({ + feeRaw: "0", + subsidyRaw: "2000000", + usdcGrossRaw: "106000000", + usdcNetRaw: "108000000" + }); }); it("refuses an impossible event whose fee exceeds this swap's output", () => { - expect(() => conversionAmountsFromSwapEvent({ fee: 2n, forwarded: 0n, usdcOut: 1n })).toThrow("fee exceeds"); + expect(() => conversionAmountsFromSwapEvent({ fee: 2n, subsidy: 0n, usdcOut: 1n })).toThrow("fee exceeds"); + }); +}); + +// Off-chain mirror of the contract's settlement: same numbers as the Foundry suite +// (1000 EURe at 1.14: reference 1140 USDC, target 1138.575, floor 1138.29, oracle floor 1135.44). +describe("projectSwap", () => { + const vault = { balance: 1_000n * USDC, dailyBudget: 200n * USDC, maxSubsidyPpm: 5_000, paused: false, spentToday: 0n }; + const base = { + amountIn: 1_000n * EUR, + floorPpm: 1_500, + maxFeePpm: 10_000, + oracleDecimals: 8, + oracleRaw: 114_000_000n, + referenceRaw: 114_000_000n, + slippageBps: 40, + targetPpm: 1_250, + vault + }; + + it("takes the surplus above the target as fee, capped at MAX_FEE_PPM", () => { + expect(projectSwap({ ...base, quotedOut: 1_145n * USDC })).toEqual({ + defer: null, + fee: 1_145n * USDC - 1_138_575_000n, + net: 1_138_575_000n, + subsidy: 0n + }); + expect(projectSwap({ ...base, quotedOut: 1_200n * USDC }).fee).toBe(12n * USDC); + }); + + it("leaves a fill between the floor and the target untouched", () => { + expect(projectSwap({ ...base, quotedOut: 1_138_400_000n })).toEqual({ + defer: null, + fee: 0n, + net: 1_138_400_000n, + subsidy: 0n + }); + }); + + it("tops a fill below the floor up from the vault", () => { + expect(projectSwap({ ...base, quotedOut: 1_136n * USDC })).toEqual({ + defer: null, + fee: 0n, + net: 1_138_290_000n, + subsidy: 2_290_000n + }); + }); + + it("defers when the vault cannot cover the subsidy", () => { + expect(projectSwap({ ...base, quotedOut: 1_130n * USDC }).defer).toContain("per-swap cap"); + expect(projectSwap({ ...base, quotedOut: 1_136n * USDC, vault: null }).defer).toContain("no subsidy vault"); + expect(projectSwap({ ...base, quotedOut: 1_136n * USDC, vault: { ...vault, paused: true } }).defer).toContain("paused"); + expect( + projectSwap({ ...base, quotedOut: 1_136n * USDC, vault: { ...vault, spentToday: 199n * USDC } }).defer + ).toContain("daily budget"); + expect(projectSwap({ ...base, quotedOut: 1_136n * USDC, vault: { ...vault, balance: 1n * USDC } }).defer).toContain( + "vault balance" + ); + }); + + it("defers when even the subsidized net sits below the oracle floor (depegged reference)", () => { + const projection = projectSwap({ ...base, quotedOut: 1_127n * USDC, referenceRaw: (114_000_000n * 9_910n) / 10_000n }); + expect(projection.subsidy).toBeGreaterThan(0n); + expect(projection.defer).toContain("oracle floor"); + }); +}); + +describe("expectedSwapCalldata", () => { + it("rebuilds the exact calldata from the persisted reference and route, or nothing", () => { + expect(expectedSwapCalldata({ referenceRateRaw: null, routeIndex: 0 })).toBeNull(); + expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: null })).toBeNull(); + expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: 1 })).toBe( + encodeFunctionData({ abi: forwarderAbi, args: [114_000_000n, 1n], functionName: "swapAndForward" }) + ); }); }); @@ -193,19 +280,21 @@ describe("classifyHashlessPending", () => { describe("isExpectedSwapTransaction", () => { const keeper = "0x1111111111111111111111111111111111111111"; const forwarder = "0x2222222222222222222222222222222222222222"; - const expected = { - from: keeper, - input: encodeFunctionData({ abi: forwarderAbi, functionName: "swapAndForward" }), - nonce: 7, - to: forwarder - }; - - it("requires the exact keeper, nonce, forwarder, and no-arg calldata", () => { - expect(isExpectedSwapTransaction(expected, keeper, forwarder, 7)).toBe(true); - expect(isExpectedSwapTransaction({ ...expected, from: forwarder }, keeper, forwarder, 7)).toBe(false); - expect(isExpectedSwapTransaction({ ...expected, nonce: 8 }, keeper, forwarder, 7)).toBe(false); - expect(isExpectedSwapTransaction({ ...expected, to: keeper }, keeper, forwarder, 7)).toBe(false); - expect(isExpectedSwapTransaction({ ...expected, input: "0x" }, keeper, forwarder, 7)).toBe(false); + const input = encodeFunctionData({ abi: forwarderAbi, args: [114_000_000n, 0n], functionName: "swapAndForward" }); + const expected = { from: keeper, input, nonce: 7, to: forwarder }; + + it("requires the exact keeper, nonce, forwarder, and priced calldata", () => { + expect(isExpectedSwapTransaction(expected, keeper, forwarder, 7, input)).toBe(true); + expect(isExpectedSwapTransaction({ ...expected, from: forwarder }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedSwapTransaction({ ...expected, nonce: 8 }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedSwapTransaction({ ...expected, to: keeper }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedSwapTransaction({ ...expected, input: "0x" }, keeper, forwarder, 7, input)).toBe(false); + const otherReference = encodeFunctionData({ + abi: forwarderAbi, + args: [114_100_000n, 0n], + functionName: "swapAndForward" + }); + expect(isExpectedSwapTransaction({ ...expected, input: otherReference }, keeper, forwarder, 7, input)).toBe(false); }); }); 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 94b30ef80..17a44a504 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1,5 +1,13 @@ import { Op, Transaction } from "sequelize"; -import { Address, encodeFunctionData, Hex, parseEventLogs, TransactionReceipt, TransactionReceiptNotFoundError } from "viem"; +import { + Address, + encodeFunctionData, + Hex, + parseEventLogs, + TransactionReceipt, + TransactionReceiptNotFoundError, + zeroAddress +} from "viem"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; @@ -11,6 +19,7 @@ import MoneriumConversionExecution, { import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import { + chainlinkAbi, erc20Abi, factoryAbi, forwarderAbi, @@ -18,9 +27,13 @@ import { getForwarderImmutables, getKeeperWalletClient, getPublicClient, + MAINNET_QUOTER_V2, + quoterV2Abi, + subsidyVaultAbi, swapExecutedEvent } from "./chain"; import { withForwarderLock } from "./deposit-processor"; +import { fetchCoinbaseReference, isWithinReferenceBand, ReferenceQuote } from "./reference-rate"; /** * Per-account conversion executor (plan §3, "Keeper" + "Attribution (R04)"): @@ -93,9 +106,14 @@ export async function broadcastSwapSequence(input: SwapBroadcastSequence): Promi return input.sendSwap(swapNonce); } -/** Maps SwapExecuted into accounting values; `forwarded` may include pre-existing USDC. */ -export function conversionAmountsFromSwapEvent(event: { fee: bigint; forwarded: bigint; usdcOut: bigint }): { +/** + * Maps SwapExecuted into accounting values. The client's net for this swap is the fill + * minus the fee plus the vault subsidy paid straight to the destination; `forwarded` is + * deliberately ignored because it may include pre-existing (unsolicited) USDC. + */ +export function conversionAmountsFromSwapEvent(event: { fee: bigint; subsidy: bigint; usdcOut: bigint }): { feeRaw: string; + subsidyRaw: string; usdcGrossRaw: string; usdcNetRaw: string; } { @@ -104,11 +122,93 @@ export function conversionAmountsFromSwapEvent(event: { fee: bigint; forwarded: } return { feeRaw: event.fee.toString(), + subsidyRaw: event.subsidy.toString(), usdcGrossRaw: event.usdcOut.toString(), - usdcNetRaw: (event.usdcOut - event.fee).toString() + usdcNetRaw: (event.usdcOut - event.fee + event.subsidy).toString() }; } +// ------------------------------------------------------------------ pricing projection + +const PPM = 1_000_000n; +const BPS = 10_000n; + +export interface SubsidyVaultState { + balance: bigint; + dailyBudget: bigint; + maxSubsidyPpm: number; + paused: boolean; + /** Spent in the current UTC day; the reader zeroes it when the vault's day has rolled over. */ + spentToday: bigint; +} + +export interface SwapProjectionInput { + amountIn: bigint; + floorPpm: number; + maxFeePpm: number; + oracleDecimals: number; + oracleRaw: bigint; + quotedOut: bigint; + referenceRaw: bigint; + slippageBps: number; + targetPpm: number; + /** null when the factory has no subsidy vault configured. */ + vault: SubsidyVaultState | null; +} + +export interface SwapProjection { + /** Why the keeper must not send this swap now, or null when it may proceed. */ + defer: string | null; + fee: bigint; + net: bigint; + subsidy: bigint; +} + +/** + * Off-chain mirror of VortexForwarder's settlement for a quoted fill: the fee band, the + * subsidy band and the oracle floor on the client's net. The keeper defers — funds wait, + * nothing is sent, no execution row is burnt — whenever the contract would revert or the + * vault could not cover the projected subsidy. + */ +export function projectSwap(input: SwapProjectionInput): SwapProjection { + const scale = 10n ** BigInt(12 + input.oracleDecimals); + const referenceOut = (input.amountIn * input.referenceRaw) / scale; + const targetOut = (referenceOut * (PPM - BigInt(input.targetPpm))) / PPM; + const floorOut = (referenceOut * (PPM - BigInt(input.floorPpm))) / PPM; + + let fee = 0n; + let subsidy = 0n; + if (input.quotedOut > targetOut) { + fee = input.quotedOut - targetOut; + const maxFee = (input.quotedOut * BigInt(input.maxFeePpm)) / PPM; + if (fee > maxFee) fee = maxFee; + } else if (input.quotedOut < floorOut) { + subsidy = floorOut - input.quotedOut; + } + const net = input.quotedOut - fee + subsidy; + + let defer: string | null = null; + if (subsidy > 0n) { + const vault = input.vault; + if (!vault) { + defer = `a subsidy of ${subsidy} is needed but no subsidy vault is configured`; + } else if (vault.paused) { + defer = `a subsidy of ${subsidy} is needed but the subsidy vault is paused`; + } else if (subsidy > (referenceOut * BigInt(vault.maxSubsidyPpm)) / PPM) { + defer = `projected subsidy ${subsidy} exceeds the vault's per-swap cap`; + } else if (subsidy > vault.dailyBudget - vault.spentToday) { + defer = `projected subsidy ${subsidy} exceeds the vault's remaining daily budget`; + } else if (subsidy > vault.balance) { + defer = `projected subsidy ${subsidy} exceeds the vault balance ${vault.balance}`; + } + } + const oracleFloor = (((input.amountIn * input.oracleRaw) / scale) * (BPS - BigInt(input.slippageBps))) / BPS; + if (defer === null && net < oracleFloor) { + defer = `projected net ${net} is below the oracle floor ${oracleFloor}`; + } + return { defer, fee, net, subsidy }; +} + // ------------------------------------------------------------------ R04 allocation math export interface AllocatableDeposit { @@ -322,14 +422,17 @@ async function finalizeExecution( return; } const swapEvent = swapEvents[0]; - const { eureIn } = swapEvent.args; + const { eureIn, referenceRate, routeIndex } = swapEvent.args; const conversionAmounts = conversionAmountsFromSwapEvent(swapEvent.args); await execution.update( { blockNumber: Number(receipt.blockNumber), error: null, - // The event's amountIn is authoritative (min(balance, cap) at execution time). + // The event's amountIn, reference and route are authoritative: what the contract + // actually priced and executed, whoever triggered it. eureInRaw: eureIn.toString(), + referenceRateRaw: referenceRate.toString(), + routeIndex: Number(routeIndex), ...conversionAmounts, status: MoneriumConversionExecutionStatus.Confirmed, swapLogIndex: swapEvent.logIndex, @@ -355,20 +458,34 @@ export interface RecoveryTransactionIdentity { to: string | null; } -const SWAP_AND_FORWARD_CALLDATA = encodeFunctionData({ abi: forwarderAbi, functionName: "swapAndForward" }); +/** + * The exact swapAndForward calldata a row would have broadcast, rebuilt from the + * reference and route persisted before the send. Null for a row that never got priced. + */ +export function expectedSwapCalldata(execution: { referenceRateRaw: string | null; routeIndex: number | null }): Hex | null { + if (execution.referenceRateRaw === null || execution.routeIndex === null) { + return null; + } + return encodeFunctionData({ + abi: forwarderAbi, + args: [BigInt(execution.referenceRateRaw), BigInt(execution.routeIndex)], + functionName: "swapAndForward" + }); +} /** Exact transaction identity required before a lost hash may be adopted. */ export function isExpectedSwapTransaction( transaction: RecoveryTransactionIdentity, keeperAddress: string, forwarderAddress: string, - nonce: number + nonce: number, + expectedInput: Hex ): boolean { return ( transaction.from.toLowerCase() === keeperAddress.toLowerCase() && transaction.nonce === nonce && transaction.to?.toLowerCase() === forwarderAddress.toLowerCase() && - transaction.input.toLowerCase() === SWAP_AND_FORWARD_CALLDATA.toLowerCase() + transaction.input.toLowerCase() === expectedInput.toLowerCase() ); } @@ -423,7 +540,8 @@ async function findMatchingSwapTxHashes( account: MoneriumAccount, transaction: Transaction ): Promise<{ matchingSwapTxHashes: string[]; scanComplete: boolean }> { - if (pending.nonce === null || pending.broadcastBlockNumber === null) { + const expectedInput = expectedSwapCalldata(pending); + if (pending.nonce === null || pending.broadcastBlockNumber === null || expectedInput === null) { return { matchingSwapTxHashes: [], scanComplete: false }; } const client = getPublicClient(); @@ -454,7 +572,7 @@ async function findMatchingSwapTxHashes( let claimedExactMatch = false; for (const hash of hashes) { const candidate = await client.getTransaction({ hash }); - if (!isExpectedSwapTransaction(candidate, keeperAddress, account.forwarderAddress, pending.nonce)) { + if (!isExpectedSwapTransaction(candidate, keeperAddress, account.forwarderAddress, pending.nonce, expectedInput)) { continue; } if (claimed.has(hash.toLowerCase())) { @@ -564,6 +682,142 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans return { attempt: failedSince.length + 1, kind: "proceed" }; } +// ------------------------------------------------------------------ pricing + +type PlannedSwap = + | { kind: "defer"; reason: string } + | { kind: "ready"; projection: SwapProjection | null; reference: ReferenceQuote; routeIndex: number }; + +function deferSwap(reason: string): PlannedSwap { + return { kind: "defer", reason }; +} + +async function readEnabledRoutes(factory: Address): Promise> { + const client = getPublicClient(); + const count = Number(await client.readContract({ abi: factoryAbi, address: factory, functionName: "routeCount" })); + const routes = await Promise.all( + Array.from({ length: count }, (_, index) => + client + .readContract({ abi: factoryAbi, address: factory, args: [BigInt(index)], functionName: "route" }) + .then(([path, enabled]) => ({ enabled, index, path })) + ) + ); + return routes.filter(route => route.enabled).map(({ index, path }) => ({ index, path })); +} + +/** Quotes every enabled route on the mainnet QuoterV2; a route that cannot be quoted is skipped with a warning. */ +async function quoteRoutes( + routes: Array<{ index: number; path: Hex }>, + amountIn: bigint +): Promise> { + const client = getPublicClient(); + const quotes: Array<{ index: number; quotedOut: bigint }> = []; + for (const route of routes) { + try { + const { result } = await client.simulateContract({ + abi: quoterV2Abi, + address: MAINNET_QUOTER_V2, + args: [route.path, amountIn], + functionName: "quoteExactInput" + }); + quotes.push({ index: route.index, quotedOut: result[0] }); + } catch (error) { + logger.warn(`monerium-b2b: route ${route.index} could not be quoted: ${errorText(error)}`); + } + } + return quotes; +} + +async function readSubsidyVaultState(vault: Address, usdc: Address): Promise { + if (vault === zeroAddress) { + return null; + } + const client = getPublicClient(); + const [balance, dailyBudget, maxSubsidyPpm, paused, spentToday, currentDay] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: usdc, args: [vault], functionName: "balanceOf" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "dailyBudget" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "maxSubsidyPpm" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "paused" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "spentToday" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "currentDay" }) + ]); + const today = BigInt(Math.floor(Date.now() / 86_400_000)); + return { + balance, + dailyBudget, + maxSubsidyPpm: Number(maxSubsidyPpm), + paused, + spentToday: currentDay === today ? spentToday : 0n + }; +} + +/** + * Reference, route and projection for a swap of `amountIn` + * (docs/proposal-monerium-forwarder-fee-subsidy.md, phase 2). 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. + */ +async function pricePlannedSwap(forwarder: Address, factory: Address, amountIn: bigint): Promise { + const client = getPublicClient(); + const immutables = await getForwarderImmutables(forwarder); + const [targetPpm, floorPpm, roundData, vaultAddress] = await Promise.all([ + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "targetPpm" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "floorPpm" }), + client.readContract({ abi: chainlinkAbi, address: immutables.oracle, functionName: "latestRoundData" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "subsidyVault" }) + ]); + const oracleRaw = roundData[1]; + if (oracleRaw <= 0n) { + return deferSwap(`Chainlink EUR/USD answered ${oracleRaw}`); + } + + let reference: ReferenceQuote; + try { + reference = await fetchCoinbaseReference(immutables.oracleDecimals); + } catch (error) { + return deferSwap(`reference rate unavailable: ${errorText(error)}`); + } + if (!isWithinReferenceBand(reference.rateRaw, oracleRaw, immutables.maxReferenceDeviationBps)) { + return deferSwap( + `reference ${reference.price} is outside the ${immutables.maxReferenceDeviationBps} bps band around Chainlink ${oracleRaw}` + ); + } + + const routes = await readEnabledRoutes(factory); + if (routes.length === 0) { + return deferSwap("the factory has no enabled swap route"); + } + if ((await getChainId()) !== 1) { + return { kind: "ready", projection: null, reference, routeIndex: routes[0].index }; + } + const quotes = await quoteRoutes(routes, amountIn); + if (quotes.length === 0) { + return deferSwap("no enabled swap route could be quoted"); + } + const best = quotes.reduce((leader, quote) => (quote.quotedOut > leader.quotedOut ? quote : leader)); + const vault = await readSubsidyVaultState(vaultAddress, immutables.usdc); + const projection = projectSwap({ + amountIn, + floorPpm: Number(floorPpm), + maxFeePpm: immutables.maxFeePpm, + oracleDecimals: immutables.oracleDecimals, + oracleRaw, + quotedOut: best.quotedOut, + referenceRaw: reference.rateRaw, + slippageBps: immutables.slippageBps, + targetPpm: Number(targetPpm), + vault + }); + if (projection.defer) { + return deferSwap(`${projection.defer} (route ${best.index} quoted ${best.quotedOut})`); + } + logger.info( + `monerium-b2b: priced swap of ${amountIn} on route ${best.index}: quoted ${best.quotedOut}, ` + + `reference ${reference.price}, fee ${projection.fee}, subsidy ${projection.subsidy}` + ); + return { kind: "ready", projection, reference, routeIndex: best.index }; +} + // ------------------------------------------------------------------ executor /** @@ -630,6 +884,20 @@ export async function runConversionExecutor(accountId: string): Promise { return; } + // Price the planned swap before anything is reserved: reference, route and the + // projected fee/subsidy. A deferral leaves the funds waiting (marker still armed) + // and never creates an execution row. + const amountIn = balance > perSwapCap ? perSwapCap : balance; + const plan = await pricePlannedSwap(forwarder, factory, amountIn); + if (plan.kind === "defer") { + logger.warn(`monerium-b2b: deferring conversion for account ${account.id}: ${plan.reason}`); + if (pokeNeeded) { + await sendPoke(forwarder); + } + return; + } + const swapArgs: readonly [bigint, bigint] = [plan.reference.rateRaw, BigInt(plan.routeIndex)]; + // 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 => { @@ -643,7 +911,12 @@ export async function runConversionExecutor(accountId: string): Promise { { accountId: account.id, destination: account.destination, - eureInRaw: (balance > perSwapCap ? perSwapCap : balance).toString() + eureInRaw: amountIn.toString(), + referenceAt: plan.reference.time, + referenceRateRaw: plan.reference.rateRaw.toString(), + referenceSource: plan.reference.source, + referenceTradeId: plan.reference.tradeId, + routeIndex: plan.routeIndex }, { transaction } ); @@ -667,6 +940,7 @@ export async function runConversionExecutor(accountId: string): Promise { abi: forwarderAbi, account: keeper.account, address: forwarder, + args: swapArgs, functionName: "swapAndForward" }); @@ -710,6 +984,7 @@ export async function runConversionExecutor(accountId: string): Promise { abi: forwarderAbi, account: keeper.account, address: forwarder, + args: swapArgs, chain: null, functionName: "swapAndForward", nonce diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 219a0e621..951772d52 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -4,13 +4,16 @@ import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import { + chainlinkAbi, erc20Abi, factoryAbi, forwarderAbi, getChainId, getForwarderImmutables, getPublicClient, - moneriumChainForChainId + MAINNET_QUOTER_V2, + moneriumChainForChainId, + quoterV2Abi } from "./chain"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; @@ -41,23 +44,12 @@ import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./moneri * None of these monitors hold keys or send transactions; they are detection-only. */ -/** Uniswap V3 QuoterV2 on Ethereum mainnet (the pinned quoting contract, PRD §7.4). */ -export const MAINNET_QUOTER_V2: Address = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"; - /** Stranding marker armed longer than this warns (the keeper converts within minutes normally). */ export const STRANDED_WARN_MS = 12 * 60 * 60 * 1000; /** Full monitoring pass at most this often (the worker cycles every minute). */ const MONITORING_INTERVAL_MS = 30 * 60_000; -const quoterV2Abi = parseAbi([ - "function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)" -]); - -const chainlinkAbi = parseAbi([ - "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)" -]); - // Read-only getters beyond the keeper ABI surface in ./chain.ts. const forwarderMonitoringAbi = parseAbi([ "function destination() view returns (address)", diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts new file mode 100644 index 000000000..ed4bdb8d4 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { + COINBASE_EURC_TICKER_URL, + COINBASE_REFERENCE_SOURCE, + fetchCoinbaseReference, + isWithinReferenceBand, + toReferenceRateRaw +} from "./reference-rate"; + +describe("toReferenceRateRaw", () => { + it("scales a decimal price to the oracle's decimals", () => { + expect(toReferenceRateRaw("1.14", 8)).toBe(114_000_000n); + expect(toReferenceRateRaw("1", 8)).toBe(100_000_000n); + expect(toReferenceRateRaw("0.98765432", 8)).toBe(98_765_432n); + }); + + it("rejects malformed or non-positive prices", () => { + for (const bad of ["", "abc", "-1.1", "1e5", "0", "0.0"]) { + expect(() => toReferenceRateRaw(bad, 8)).toThrow(); + } + }); +}); + +describe("isWithinReferenceBand", () => { + const oracle = 114_000_000n; // 1.14 at 8 decimals + + it("accepts references inside the band, inclusive of its edges", () => { + expect(isWithinReferenceBand(oracle, oracle, 100)).toBe(true); + expect(isWithinReferenceBand((oracle * 10_100n) / 10_000n, oracle, 100)).toBe(true); + expect(isWithinReferenceBand((oracle * 9_900n) / 10_000n, oracle, 100)).toBe(true); + }); + + it("rejects references outside the band and a zero reference", () => { + expect(isWithinReferenceBand((oracle * 10_101n) / 10_000n, oracle, 100)).toBe(false); + expect(isWithinReferenceBand((oracle * 9_899n) / 10_000n, oracle, 100)).toBe(false); + expect(isWithinReferenceBand(0n, oracle, 100)).toBe(false); + }); +}); + +describe("fetchCoinbaseReference", () => { + function fakeFetch(status: number, body: unknown) { + const calls: string[] = []; + const fetchImpl = async (url: string) => { + calls.push(url); + return { json: async () => body, ok: status >= 200 && status < 300, status }; + }; + return { calls, fetchImpl }; + } + + it("parses the ticker into a scaled, timestamped, attributable quote", async () => { + const { calls, fetchImpl } = fakeFetch(200, { price: "1.1432", time: "2026-09-15T10:00:00.123456Z", trade_id: 4711 }); + const quote = await fetchCoinbaseReference(8, fetchImpl); + expect(calls).toEqual([COINBASE_EURC_TICKER_URL]); + expect(quote).toMatchObject({ + price: "1.1432", + rateRaw: 114_320_000n, + source: COINBASE_REFERENCE_SOURCE, + tradeId: "4711" + }); + expect(quote.time.toISOString()).toBe("2026-09-15T10:00:00.123Z"); + }); + + it("fails on a non-2xx response or a body without a usable price", async () => { + await expect(fetchCoinbaseReference(8, fakeFetch(503, {}).fetchImpl)).rejects.toThrow("503"); + await expect(fetchCoinbaseReference(8, fakeFetch(200, { price: 1.14 }).fetchImpl)).rejects.toThrow("no price"); + await expect(fetchCoinbaseReference(8, fakeFetch(200, { price: "0" }).fetchImpl)).rejects.toThrow("positive"); + await expect(fetchCoinbaseReference(8, fakeFetch(200, { price: "1.14", time: "soon" }).fetchImpl)).rejects.toThrow( + "timestamp" + ); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.ts new file mode 100644 index 000000000..a54c0b3e7 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -0,0 +1,70 @@ +import { parseUnits } from "viem"; + +/** + * Partner reference rate for the forwarder fee bands + * (docs/proposal-monerium-forwarder-fee-subsidy.md): the Coinbase Exchange EURC-USD + * ticker, fetched fresh before every swap and recorded on the execution row so the + * partner can audit each swap against the public trade history. The keeper passes the + * rate into swapAndForward; the contract rejects it outside its Chainlink band. + */ + +export const COINBASE_EURC_TICKER_URL = "https://api.exchange.coinbase.com/products/EURC-USD/ticker"; +export const COINBASE_REFERENCE_SOURCE = "coinbase-exchange:EURC-USD"; +const FETCH_TIMEOUT_MS = 5_000; + +export interface ReferenceQuote { + /** The ticker price as returned, e.g. "1.1432". */ + price: string; + /** The price scaled to the forwarder's ORACLE_DECIMALS. */ + rateRaw: bigint; + source: string; + time: Date; + /** Coinbase's trade id for the tick, when present — the audit anchor. */ + tradeId: string | null; +} + +/** Decimal price string -> integer at `decimals`. Rejects malformed or non-positive input. */ +export function toReferenceRateRaw(price: string, decimals: number): bigint { + if (!/^\d+(\.\d+)?$/.test(price)) { + throw new Error(`reference price is not a decimal number: ${price}`); + } + const raw = parseUnits(price, decimals); + if (raw <= 0n) { + throw new Error(`reference price must be positive: ${price}`); + } + return raw; +} + +/** Mirrors VortexForwarder._checkedReference: |reference - oracle| <= oracle x band / 10000. */ +export function isWithinReferenceBand(rateRaw: bigint, oracleRaw: bigint, bandBps: number): boolean { + const tolerance = (oracleRaw * BigInt(bandBps)) / 10_000n; + return rateRaw + tolerance >= oracleRaw && rateRaw <= oracleRaw + tolerance; +} + +export type FetchLike = ( + url: string, + init?: { signal?: AbortSignal } +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +/** Fetches the live ticker. Any failure throws; the caller defers the swap. */ +export async function fetchCoinbaseReference(decimals: number, fetchImpl: FetchLike = fetch): Promise { + const response = await fetchImpl(COINBASE_EURC_TICKER_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Coinbase ticker responded ${response.status}`); + } + const body = (await response.json()) as { price?: unknown; time?: unknown; trade_id?: unknown } | null; + if (!body || typeof body.price !== "string") { + throw new Error("Coinbase ticker response has no price"); + } + const time = typeof body.time === "string" ? new Date(body.time) : new Date(); + if (Number.isNaN(time.getTime())) { + throw new Error(`Coinbase ticker time is not a timestamp: ${String(body.time)}`); + } + return { + price: body.price, + rateRaw: toReferenceRateRaw(body.price, decimals), + source: COINBASE_REFERENCE_SOURCE, + time, + tradeId: typeof body.trade_id === "number" || typeof body.trade_id === "string" ? String(body.trade_id) : null + }; +} diff --git a/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts new file mode 100644 index 000000000..3e5abcd8f --- /dev/null +++ b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts @@ -0,0 +1,41 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Every swap is priced against a partner reference rate and may draw a subsidy from the +// vault (docs/proposal-monerium-forwarder-fee-subsidy.md). The reference and the chosen +// route are persisted before broadcast (crash-recovery calldata identity + audit +// trail); the subsidy is recorded from the SwapExecuted event on confirmation. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "reference_rate_raw", { + allowNull: true, + type: DataTypes.DECIMAL(38, 0) + }); + await queryInterface.addColumn("monerium_conversion_executions", "reference_source", { + allowNull: true, + type: DataTypes.STRING(64) + }); + await queryInterface.addColumn("monerium_conversion_executions", "reference_trade_id", { + allowNull: true, + type: DataTypes.STRING(32) + }); + await queryInterface.addColumn("monerium_conversion_executions", "reference_at", { + allowNull: true, + type: DataTypes.DATE + }); + await queryInterface.addColumn("monerium_conversion_executions", "route_index", { + allowNull: true, + type: DataTypes.INTEGER + }); + await queryInterface.addColumn("monerium_conversion_executions", "subsidy_raw", { + allowNull: true, + type: DataTypes.DECIMAL(38, 0) + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "subsidy_raw"); + await queryInterface.removeColumn("monerium_conversion_executions", "route_index"); + await queryInterface.removeColumn("monerium_conversion_executions", "reference_at"); + await queryInterface.removeColumn("monerium_conversion_executions", "reference_trade_id"); + await queryInterface.removeColumn("monerium_conversion_executions", "reference_source"); + await queryInterface.removeColumn("monerium_conversion_executions", "reference_rate_raw"); +} diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index d905b534d..41b8f8da2 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -17,8 +17,17 @@ export interface MoneriumConversionExecutionAttributes { eureInRaw: string; // 18-decimal base units usdcGrossRaw: string | null; // 6-decimal base units feeRaw: string | null; + /** USDC the subsidy vault paid straight to the destination for this swap (6 decimals). */ + subsidyRaw: string | null; usdcNetRaw: string | null; destination: string; + /** Partner reference the swap was priced against, ORACLE_DECIMALS; persisted before broadcast. */ + referenceRateRaw: string | null; + referenceSource: string | null; + referenceTradeId: string | null; + referenceAt: Date | null; + /** Factory route index the swap executed. */ + routeIndex: number | null; txHash: string | null; /** The swap's transaction nonce, persisted BEFORE broadcast (crash-recovery identity). */ nonce: number | null; @@ -38,7 +47,13 @@ type MoneriumConversionExecutionCreationAttributes = Optional< | "id" | "usdcGrossRaw" | "feeRaw" + | "subsidyRaw" | "usdcNetRaw" + | "referenceRateRaw" + | "referenceSource" + | "referenceTradeId" + | "referenceAt" + | "routeIndex" | "txHash" | "nonce" | "broadcastBlockNumber" @@ -59,8 +74,14 @@ class MoneriumConversionExecution declare eureInRaw: string; declare usdcGrossRaw: string | null; declare feeRaw: string | null; + declare subsidyRaw: string | null; declare usdcNetRaw: string | null; declare destination: string; + declare referenceRateRaw: string | null; + declare referenceSource: string | null; + declare referenceTradeId: string | null; + declare referenceAt: Date | null; + declare routeIndex: number | null; declare txHash: string | null; declare nonce: number | null; declare broadcastBlockNumber: number | null; @@ -122,11 +143,41 @@ MoneriumConversionExecution.init( allowNull: true, type: DataTypes.INTEGER }, + referenceAt: { + allowNull: true, + field: "reference_at", + type: DataTypes.DATE + }, + referenceRateRaw: { + allowNull: true, + field: "reference_rate_raw", + type: DataTypes.DECIMAL(38, 0) + }, + referenceSource: { + allowNull: true, + field: "reference_source", + type: DataTypes.STRING(64) + }, + referenceTradeId: { + allowNull: true, + field: "reference_trade_id", + type: DataTypes.STRING(32) + }, + routeIndex: { + allowNull: true, + field: "route_index", + type: DataTypes.INTEGER + }, status: { allowNull: false, defaultValue: MoneriumConversionExecutionStatus.Pending, type: DataTypes.ENUM(...Object.values(MoneriumConversionExecutionStatus)) }, + subsidyRaw: { + allowNull: true, + field: "subsidy_raw", + type: DataTypes.DECIMAL(38, 0) + }, swapLogIndex: { allowNull: true, field: "swap_log_index", From cd29bf8d210f249f513826cab3dd0e4a14d1b1d3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:44:25 +0200 Subject: [PATCH 10/44] feat(api): expose execution pricing on deposit conversions --- .../controllers/monerium-b2b.controller.ts | 2 ++ .../monerium-b2b/manager-events.test.ts | 22 +++++++++++++++++-- .../services/monerium-b2b/manager-events.ts | 18 ++++++++++++++- ...erium-b2b-account-read.integration.test.ts | 4 ++++ docs/api/openapi/vortex.openapi.d.ts | 9 ++++++++ docs/api/openapi/vortex.openapi.json | 21 +++++++++++++++++- docs/api/pages/07-webhooks.md | 4 ++++ .../shared/src/endpoints/webhook.endpoints.ts | 17 ++++++++++++++ 8 files changed, 93 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index 965505f52..02d3857b6 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -10,6 +10,7 @@ import MoneriumFiatDeposit from "../../models/moneriumFiatDeposit.model"; import { APIError } from "../errors/api-error"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { executionPricing } from "../services/monerium-b2b/manager-events"; import { UNATTRIBUTED_ORDER_PREFIX } from "../services/monerium-b2b/mint-watcher"; import { MONERIUM_ID_HEADER, @@ -164,6 +165,7 @@ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: const execution = executionById.get(allocation.executionId); return { eureInRaw: allocation.eureInRaw, + execution: execution ? executionPricing(execution) : { feeRaw: null, referenceRateRaw: null, subsidyRaw: null }, executionId: allocation.executionId, status: execution?.status ?? "pending", txHash: execution?.txHash ?? null, 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 6c20fa588..ea8b4e88b 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 @@ -162,7 +162,10 @@ describe("monerium b2b manager events", () => { blockNumber: 1000, destination: DESTINATION, eureInRaw: "60000000000000000000", + feeRaw: "81000", + referenceRateRaw: "108140000", status: MoneriumConversionExecutionStatus.Confirmed, + subsidyRaw: "0", txHash: "0xswap1", usdcNetRaw: "64800000" }); @@ -171,7 +174,10 @@ describe("monerium b2b manager events", () => { blockNumber: 1001, destination: DESTINATION, eureInRaw: "40000000000000000000", + feeRaw: "0", + referenceRateRaw: "108120000", status: MoneriumConversionExecutionStatus.Confirmed, + subsidyRaw: "120000", txHash: "0xswap2", usdcNetRaw: "43200000" }); @@ -222,8 +228,20 @@ describe("monerium b2b manager events", () => { expect(deliveries[0].payload).toMatchObject({ payload: { conversions: [ - { eureInRaw: "60000000000000000000", executionId: firstExecution.id, txHash: "0xswap1", usdcNetRaw: "64800000" }, - { eureInRaw: "40000000000000000000", executionId: secondExecution.id, txHash: "0xswap2", usdcNetRaw: "43200000" } + { + eureInRaw: "60000000000000000000", + execution: { feeRaw: "81000", referenceRateRaw: "108140000", subsidyRaw: "0" }, + executionId: firstExecution.id, + txHash: "0xswap1", + usdcNetRaw: "64800000" + }, + { + eureInRaw: "40000000000000000000", + execution: { feeRaw: "0", referenceRateRaw: "108120000", subsidyRaw: "120000" }, + executionId: secondExecution.id, + txHash: "0xswap2", + usdcNetRaw: "43200000" + } ], depositId: deposit.id, usdcNetRaw: "108000000" 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 d50782c8c..83f37220d 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -1,4 +1,10 @@ -import { DepositStatus, type DepositWebhookPayloadBase, WebhookEventType, type WebhookPayload } from "@vortexfi/shared"; +import { + type ConversionExecutionPricing, + DepositStatus, + type DepositWebhookPayloadBase, + WebhookEventType, + type WebhookPayload +} from "@vortexfi/shared"; import { Op } from "sequelize"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; @@ -29,6 +35,15 @@ const defaultDeps: ManagerEventDeps = { } }; +/** Execution-level pricing facts, identical on every deposit portion the execution consumed. */ +export function executionPricing(execution: MoneriumConversionExecution): ConversionExecutionPricing { + return { + feeRaw: execution.feeRaw, + referenceRateRaw: execution.referenceRateRaw, + subsidyRaw: execution.subsidyRaw + }; +} + function depositPayloadBase(deposit: MoneriumFiatDeposit, account: MoneriumAccount): DepositWebhookPayloadBase { return { accountId: account.id, @@ -166,6 +181,7 @@ async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: const execution = executionById.get(allocation.executionId) as MoneriumConversionExecution; return { eureInRaw: allocation.eureInRaw, + execution: executionPricing(execution), executionId: execution.id, txHash: execution.txHash, usdcNetRaw: allocation.usdcNetRaw diff --git a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts index 740adbfa1..779ad8f36 100644 --- a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts +++ b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts @@ -86,6 +86,9 @@ describe("monerium b2b account read surface", () => { }); const execution = await MoneriumConversionExecution.create({ + feeRaw: "8000000", + referenceRateRaw: "114000000", + subsidyRaw: "0", accountId: mapped.accountId, destination: DESTINATION, eureInRaw: "100000000000000000000", @@ -133,6 +136,7 @@ describe("monerium b2b account read surface", () => { conversions: [ { eureInRaw: "100000000000000000000", + execution: { feeRaw: "8000000", referenceRateRaw: "114000000", subsidyRaw: "0" }, executionId: execution.id, status: "confirmed", txHash: "0xswap", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index cd4df4f8f..acdc1c340 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2857,6 +2857,15 @@ export interface components { conversions: { /** @description EURe from this deposit consumed by the execution in 18-decimal base units. */ eureInRaw: string; + /** @description Execution-level pricing, identical on every deposit portion the execution consumed: the reference rate it was settled against, the fee taken above the target band, and the subsidy paid to reach the floor. Null values while the execution is not yet confirmed. */ + execution: { + /** @description Fee taken on the whole execution in 6-decimal base units. */ + feeRaw: string | null; + /** @description Reference EUR/USD rate the execution was priced against, in the oracle's decimals (8). */ + referenceRateRaw: string | null; + /** @description Subsidy paid by the vault straight to the destination for the whole execution in 6-decimal base units. */ + subsidyRaw: string | null; + }; executionId: string; /** * @description Execution status. diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index b49088a02..f9613e691 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2479,6 +2479,25 @@ "description": "EURe from this deposit consumed by the execution in 18-decimal base units.", "type": "string" }, + "execution": { + "description": "Execution-level pricing, identical on every deposit portion the execution consumed: the reference rate it was settled against, the fee taken above the target band, and the subsidy paid to reach the floor. Null values while the execution is not yet confirmed.", + "properties": { + "feeRaw": { + "description": "Fee taken on the whole execution in 6-decimal base units.", + "type": ["string", "null"] + }, + "referenceRateRaw": { + "description": "Reference EUR/USD rate the execution was priced against, in the oracle's decimals (8).", + "type": ["string", "null"] + }, + "subsidyRaw": { + "description": "Subsidy paid by the vault straight to the destination for the whole execution in 6-decimal base units.", + "type": ["string", "null"] + } + }, + "required": ["feeRaw", "referenceRateRaw", "subsidyRaw"], + "type": "object" + }, "executionId": { "type": "string" }, @@ -2496,7 +2515,7 @@ "type": "string" } }, - "required": ["eureInRaw", "executionId", "status", "txHash", "usdcNetRaw"], + "required": ["eureInRaw", "execution", "executionId", "status", "txHash", "usdcNetRaw"], "type": "object" }, "type": "array" diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 0522f7cc2..ed482d17c 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -160,12 +160,14 @@ Fired once per deposit after the full deposit has been converted and every contr "conversions": [ { "eureInRaw": "60000000000000000000", + "execution": { "feeRaw": "81000", "referenceRateRaw": "108140000", "subsidyRaw": "0" }, "executionId": "e77a...", "txHash": "0x...", "usdcNetRaw": "64800000" }, { "eureInRaw": "40000000000000000000", + "execution": { "feeRaw": "0", "referenceRateRaw": "108120000", "subsidyRaw": "120000" }, "executionId": "f88b...", "txHash": "0x...", "usdcNetRaw": "43200000" @@ -178,6 +180,8 @@ Fired once per deposit after the full deposit has been converted and every contr Each `conversions[]` entry contains the EURe portion consumed and the net USDC attributed to this deposit by that execution. The payload-level `usdcNetRaw` is their aggregate. When one execution consumes several deposits, its output is divided proportionally by allocated EURe; floor dust goes to the largest allocation. +The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. + ### Delivery Semantics Deposit events are delivered **durably, at least once**: each event is persisted before sending and retried with growing backoff (1, 5, 15, 60, 180 minutes; abandoned after 6 attempts). Unlike transaction webhooks, a failing endpoint never deactivates the subscription — deliveries resume when your endpoint recovers, and outages lose nothing that has not exhausted its retries. Deduplicate on `eventId`; events are emitted only from subscription time forward (history is never replayed to a new subscription). diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 26f45752a..9727777da 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -100,6 +100,21 @@ export interface DepositReceivedWebhookPayload { payload: DepositWebhookPayloadBase; } +/** + * How a whole execution was priced (docs/proposal-monerium-forwarder-fee-subsidy.md): + * the partner reference it was settled against, the fee Vortex took above the target + * band, and the subsidy the vault paid to reach the floor. Totals for the execution, + * not per deposit; a deposit's own share is its `usdcNetRaw`. + */ +export interface ConversionExecutionPricing { + /** Fee taken on the execution (6-decimal base units). */ + feeRaw: string | null; + /** Reference EUR/USD rate the execution was priced against, in the oracle's decimals (8). */ + referenceRateRaw: string | null; + /** Subsidy paid by the vault straight to the destination (6-decimal base units). */ + subsidyRaw: string | null; +} + export interface DepositConvertedWebhookPayload { /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ eventId: string; @@ -110,6 +125,8 @@ export interface DepositConvertedWebhookPayload { conversions: Array<{ /** EURe from this deposit consumed by this execution (18-decimal base units). */ eureInRaw: string; + /** Execution-level pricing shared by every deposit portion the execution consumed. */ + execution: ConversionExecutionPricing; executionId: string; /** The swap-and-forward transaction. */ txHash: string | null; From e130029659a77dd3af3cac28ee83850432436127 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:47:31 +0200 Subject: [PATCH 11/44] feat(api): quote every forwarder route and monitor the subsidy vault --- .../src/api/services/monerium-b2b/chain.ts | 63 +++++- .../monerium-b2b/conversion-executor.ts | 71 +------ .../services/monerium-b2b/monitoring.test.ts | 19 ++ .../api/services/monerium-b2b/monitoring.ts | 191 ++++++++++++------ 4 files changed, 217 insertions(+), 127 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index e57fd8b38..41102c9e5 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -10,7 +10,8 @@ import { parseAbi, parseAbiItem, Transport, - WalletClient + WalletClient, + zeroAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../config/logger"; @@ -325,3 +326,63 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise forwarderImmutablesCache.set(key, immutables); return immutables; } + +// ------------------------------------------------------------------ routes + vault readers + +/** Enabled swap routes on the factory whitelist, by stable index. */ +export async function readEnabledRoutes(factory: Address): Promise> { + const client = getPublicClient(); + const count = Number(await client.readContract({ abi: factoryAbi, address: factory, functionName: "routeCount" })); + const routes = await Promise.all( + Array.from({ length: count }, (_, index) => + client + .readContract({ abi: factoryAbi, address: factory, args: [BigInt(index)], functionName: "route" }) + .then(([path, enabled]) => ({ enabled, index, path })) + ) + ); + return routes.filter(route => route.enabled).map(({ index, path }) => ({ index, path })); +} + +/** Static QuoterV2 quote for `amountIn` over a packed path. Mainnet only (MAINNET_QUOTER_V2 pin). */ +export async function quoteRouteOutput(path: Hex, amountIn: bigint): Promise { + const { result } = await getPublicClient().simulateContract({ + abi: quoterV2Abi, + address: MAINNET_QUOTER_V2, + args: [path, amountIn], + functionName: "quoteExactInput" + }); + return result[0]; +} + +export interface SubsidyVaultState { + balance: bigint; + dailyBudget: bigint; + maxSubsidyPpm: number; + paused: boolean; + /** Spent in the current UTC day; zero when the vault's day counter has rolled over. */ + spentToday: bigint; +} + +/** Live limits and balance of the factory's subsidy vault; null when none is configured. */ +export async function readSubsidyVaultState(vault: Address, usdc: Address): Promise { + if (vault === zeroAddress) { + return null; + } + const client = getPublicClient(); + const [balance, dailyBudget, maxSubsidyPpm, paused, spentToday, currentDay] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: usdc, args: [vault], functionName: "balanceOf" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "dailyBudget" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "maxSubsidyPpm" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "paused" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "spentToday" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "currentDay" }) + ]); + const today = BigInt(Math.floor(Date.now() / 86_400_000)); + return { + balance, + dailyBudget, + maxSubsidyPpm: Number(maxSubsidyPpm), + paused, + spentToday: currentDay === today ? spentToday : 0n + }; +} 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 17a44a504..cd39b7a08 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1,13 +1,5 @@ import { Op, Transaction } from "sequelize"; -import { - Address, - encodeFunctionData, - Hex, - parseEventLogs, - TransactionReceipt, - TransactionReceiptNotFoundError, - zeroAddress -} from "viem"; +import { Address, encodeFunctionData, Hex, parseEventLogs, TransactionReceipt, TransactionReceiptNotFoundError } from "viem"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; @@ -27,9 +19,10 @@ import { getForwarderImmutables, getKeeperWalletClient, getPublicClient, - MAINNET_QUOTER_V2, - quoterV2Abi, - subsidyVaultAbi, + quoteRouteOutput, + readEnabledRoutes, + readSubsidyVaultState, + SubsidyVaultState, swapExecutedEvent } from "./chain"; import { withForwarderLock } from "./deposit-processor"; @@ -133,15 +126,6 @@ export function conversionAmountsFromSwapEvent(event: { fee: bigint; subsidy: bi const PPM = 1_000_000n; const BPS = 10_000n; -export interface SubsidyVaultState { - balance: bigint; - dailyBudget: bigint; - maxSubsidyPpm: number; - paused: boolean; - /** Spent in the current UTC day; the reader zeroes it when the vault's day has rolled over. */ - spentToday: bigint; -} - export interface SwapProjectionInput { amountIn: bigint; floorPpm: number; @@ -692,35 +676,15 @@ function deferSwap(reason: string): PlannedSwap { return { kind: "defer", reason }; } -async function readEnabledRoutes(factory: Address): Promise> { - const client = getPublicClient(); - const count = Number(await client.readContract({ abi: factoryAbi, address: factory, functionName: "routeCount" })); - const routes = await Promise.all( - Array.from({ length: count }, (_, index) => - client - .readContract({ abi: factoryAbi, address: factory, args: [BigInt(index)], functionName: "route" }) - .then(([path, enabled]) => ({ enabled, index, path })) - ) - ); - return routes.filter(route => route.enabled).map(({ index, path }) => ({ index, path })); -} - /** Quotes every enabled route on the mainnet QuoterV2; a route that cannot be quoted is skipped with a warning. */ async function quoteRoutes( routes: Array<{ index: number; path: Hex }>, amountIn: bigint ): Promise> { - const client = getPublicClient(); const quotes: Array<{ index: number; quotedOut: bigint }> = []; for (const route of routes) { try { - const { result } = await client.simulateContract({ - abi: quoterV2Abi, - address: MAINNET_QUOTER_V2, - args: [route.path, amountIn], - functionName: "quoteExactInput" - }); - quotes.push({ index: route.index, quotedOut: result[0] }); + quotes.push({ index: route.index, quotedOut: await quoteRouteOutput(route.path, amountIn) }); } catch (error) { logger.warn(`monerium-b2b: route ${route.index} could not be quoted: ${errorText(error)}`); } @@ -728,29 +692,6 @@ async function quoteRoutes( return quotes; } -async function readSubsidyVaultState(vault: Address, usdc: Address): Promise { - if (vault === zeroAddress) { - return null; - } - const client = getPublicClient(); - const [balance, dailyBudget, maxSubsidyPpm, paused, spentToday, currentDay] = await Promise.all([ - client.readContract({ abi: erc20Abi, address: usdc, args: [vault], functionName: "balanceOf" }), - client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "dailyBudget" }), - client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "maxSubsidyPpm" }), - client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "paused" }), - client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "spentToday" }), - client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "currentDay" }) - ]); - const today = BigInt(Math.floor(Date.now() / 86_400_000)); - return { - balance, - dailyBudget, - maxSubsidyPpm: Number(maxSubsidyPpm), - paused, - spentToday: currentDay === today ? spentToday : 0n - }; -} - /** * Reference, route and projection for a swap of `amountIn` * (docs/proposal-monerium-forwarder-fee-subsidy.md, phase 2). Outside Ethereum mainnet 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 e47df258c..01ecdb9d1 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { classifyStranding, + classifyVaultRunway, computeQuoteImpactBps, detectConfigDrift, diffAssociation, @@ -73,6 +74,24 @@ describe("classifyStranding", () => { }); }); +describe("classifyVaultRunway", () => { + const healthy = { balance: 1_000n * USDC, dailyBudget: 200n * USDC, paused: false, spentToday: 0n }; + + it("is ok with a funded, unpaused vault and budget left today", () => { + expect(classifyVaultRunway(healthy).severity).toBe("ok"); + }); + + it("errors when paused or empty, since every below-floor swap then defers", () => { + expect(classifyVaultRunway({ ...healthy, paused: true })).toMatchObject({ severity: "error" }); + expect(classifyVaultRunway({ ...healthy, balance: 0n })).toMatchObject({ severity: "error" }); + }); + + it("warns below one day of budget or once today's budget is spent", () => { + expect(classifyVaultRunway({ ...healthy, balance: 150n * USDC })).toMatchObject({ severity: "warn" }); + expect(classifyVaultRunway({ ...healthy, spentToday: 200n * USDC })).toMatchObject({ severity: "warn" }); + }); +}); + describe("diffAssociation", () => { const FORWARDER = "0xD7444AB7270A142227Fe659D63873ABdc8AF9b72"; const IBAN = "EE08 7224 5745 6244 9516"; diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 951772d52..791e62ecb 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -1,5 +1,5 @@ import { Op } from "sequelize"; -import { Address, encodePacked, Hex, parseAbi } from "viem"; +import { Address, Hex, parseAbi } from "viem"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; @@ -11,25 +11,31 @@ import { getChainId, getForwarderImmutables, getPublicClient, - MAINNET_QUOTER_V2, moneriumChainForChainId, - quoterV2Abi + quoteRouteOutput, + readEnabledRoutes, + readSubsidyVaultState, + SubsidyVaultState } from "./chain"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; /** * Monitoring pass for the Monerium B2B onramp (implementation plan D3 / phase 3), run - * from the keeper worker. Four read-only monitors, alerting via the standard logger: + * from the keeper worker. Five read-only monitors, alerting via the standard logger: * - * 1. Executable-depth check (main PRD §7.4, T6 follow-up): QuoterV2 static quote on the - * pinned EURe->EURC->USDC path at perSwapCap and minSwapAmount sizes vs the - * Chainlink EUR/USD rate. Impact above SLIPPAGE_BPS at minSwapAmount size is the - * PAUSE THRESHOLD (error-level -> engage guardian pause per the incident runbook); - * at perSwapCap size it is an early warning. Mainnet-only (QuoterV2 pin). + * 1. Executable-depth check (main PRD §7.4, T6 follow-up): QuoterV2 static quotes on + * every enabled factory route at perSwapCap and minSwapAmount sizes vs the Chainlink + * EUR/USD rate. Impact of the best route above SLIPPAGE_BPS at minSwapAmount size is + * the PAUSE THRESHOLD (error-level -> engage guardian pause per the incident + * runbook); at perSwapCap size it is an early warning. Mainnet-only (QuoterV2 pin). * 2. Stranded-balance monitor: forwarders whose on-chain stranding marker (R03) has * been armed for more than STRANDED_WARN_MS warn; past TRIGGER_DELAY (the * permissionless-trigger delay, registry P4) they error — the keeper should have - * converted long before either. + * converted long before either — and within SWEEP_IMMINENT_MS of SWEEP_DELAY the + * error says so: the dead-man sweep to the fallback is about to become possible. + * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault + * (docs/proposal-monerium-forwarder-fee-subsidy.md); a vault that cannot cover a + * below-floor swap makes the keeper defer, so runway problems surface here first. * 3. Association monitor (S1 detective control, trust model in the b2b-variant doc): * re-reads the linked-address and IBAN state from the Monerium API per active * account and alerts on ANY divergence from the DB record (IBAN moved, new address @@ -47,6 +53,9 @@ import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./moneri /** Stranding marker armed longer than this warns (the keeper converts within minutes normally). */ export const STRANDED_WARN_MS = 12 * 60 * 60 * 1000; +/** Inside this window before SWEEP_DELAY the stranding error names the imminent sweep. */ +export const SWEEP_IMMINENT_MS = 2 * 24 * 60 * 60 * 1000; + /** Full monitoring pass at most this often (the worker cycles every minute). */ const MONITORING_INTERVAL_MS = 30 * 60_000; @@ -56,14 +65,8 @@ const forwarderMonitoringAbi = parseAbi([ "function fallbackAddress() view returns (address)", "function targetPpm() view returns (uint32)", "function floorPpm() view returns (uint32)", - "function EURC() view returns (address)", - "function USDC() view returns (address)", - "function ORACLE() view returns (address)", - "function ORACLE_DECIMALS() view returns (uint8)", - "function SLIPPAGE_BPS() view returns (uint16)", "function TRIGGER_DELAY() view returns (uint256)", - "function POOL_FEE_EURE_EURC() view returns (uint24)", - "function POOL_FEE_EURC_USDC() view returns (uint24)" + "function SWEEP_DELAY() view returns (uint256)" ]); const factoryMonitoringAbi = parseAbi([ @@ -111,6 +114,32 @@ export function classifyStranding(strandedSinceSec: bigint, triggerDelaySec: big return "ok"; } +export type VaultRunwaySeverity = "error" | "ok" | "warn"; + +/** + * Runway of the shared subsidy vault. Paused or empty is an error (every below-floor + * swap defers); less than one day of budget on hand, or today's budget already spent, + * is a warning worth a refill before clients notice. + */ +export function classifyVaultRunway(state: Pick): { + reason: string; + severity: VaultRunwaySeverity; +} { + if (state.paused) { + return { reason: "vault is paused", severity: "error" }; + } + if (state.balance === 0n) { + return { reason: "vault is empty", severity: "error" }; + } + if (state.balance < state.dailyBudget) { + return { reason: "balance is below one day of budget", severity: "warn" }; + } + if (state.spentToday >= state.dailyBudget) { + return { reason: "today's budget is exhausted", severity: "warn" }; + } + return { reason: "ok", severity: "ok" }; +} + export interface AssociationDbRecord { forwarderAddress: string; iban: string | null; @@ -215,8 +244,8 @@ async function monitoredAccounts(statuses: MoneriumAccountStatus[]): Promise { if ((await getChainId()) !== 1) { @@ -227,20 +256,18 @@ export async function runExecutableDepthCheck(): Promise { return; } const client = getPublicClient(); - const forwarder = accounts[0].forwarderAddress as Address; - const { eure, factory } = await getForwarderImmutables(forwarder); - const [eurc, usdc, oracle, oracleDecimals, slippageBps, poolFeeEureEurc, poolFeeEurcUsdc, minSwapAmount, perSwapCap] = - await Promise.all([ - client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "EURC" }), - client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "USDC" }), - client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "ORACLE" }), - client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "ORACLE_DECIMALS" }), - client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "SLIPPAGE_BPS" }), - client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "POOL_FEE_EURE_EURC" }), - client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "POOL_FEE_EURC_USDC" }), - client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), - client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) - ]); + const { factory, oracle, oracleDecimals, slippageBps } = await getForwarderImmutables( + accounts[0].forwarderAddress as Address + ); + const [minSwapAmount, perSwapCap, routes] = await Promise.all([ + client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }), + readEnabledRoutes(factory) + ]); + if (routes.length === 0) { + logger.error("monerium-b2b: depth check aborted — the factory has no enabled swap route"); + return; + } const [, answer, , updatedAt] = await client.readContract({ abi: chainlinkAbi, @@ -252,35 +279,39 @@ export async function runExecutableDepthCheck(): Promise { return; } - const path = encodePacked( - ["address", "uint24", "address", "uint24", "address"], - [eure, poolFeeEureEurc, eurc, poolFeeEurcUsdc, usdc] - ); - const quote = async (amountIn: bigint): Promise => { - const { result } = await client.simulateContract({ - abi: quoterV2Abi, - address: MAINNET_QUOTER_V2, - args: [path, amountIn], - functionName: "quoteExactInput" - }); - return result[0]; - }; - - const [minOut, capOut] = await Promise.all([quote(minSwapAmount), quote(perSwapCap)]); - const minImpactBps = computeQuoteImpactBps(minSwapAmount, minOut, answer, Number(oracleDecimals)); - const capImpactBps = computeQuoteImpactBps(perSwapCap, capOut, answer, Number(oracleDecimals)); + const quoted: Array<{ capImpactBps: number; index: number; minImpactBps: number }> = []; + for (const route of routes) { + try { + const [minOut, capOut] = await Promise.all([ + quoteRouteOutput(route.path, minSwapAmount), + quoteRouteOutput(route.path, perSwapCap) + ]); + quoted.push({ + capImpactBps: computeQuoteImpactBps(perSwapCap, capOut, answer, oracleDecimals), + index: route.index, + minImpactBps: computeQuoteImpactBps(minSwapAmount, minOut, answer, oracleDecimals) + }); + } catch (error) { + logger.warn(`monerium-b2b: depth check could not quote route ${route.index}:`, error); + } + } + if (quoted.length === 0) { + logger.error("monerium-b2b: depth check aborted — no enabled swap route could be quoted"); + return; + } + const best = quoted.reduce((leader, route) => (route.minImpactBps < leader.minImpactBps ? route : leader)); const detail = - `oracle=${answer} (updatedAt=${updatedAt}), minSwapAmount=${minSwapAmount} -> ${minOut} (${minImpactBps} bps), ` + - `perSwapCap=${perSwapCap} -> ${capOut} (${capImpactBps} bps), SLIPPAGE_BPS=${slippageBps}`; + `oracle=${answer} (updatedAt=${updatedAt}), SLIPPAGE_BPS=${slippageBps}, best route ${best.index}; per route: ` + + quoted.map(route => `#${route.index} min=${route.minImpactBps}bps cap=${route.capImpactBps}bps`).join(", "); - if (minImpactBps > slippageBps) { - // PAUSE THRESHOLD (PRD §7.4): even minimum-size swaps would revert on minOut. + if (best.minImpactBps > slippageBps) { + // PAUSE THRESHOLD (PRD §7.4): even minimum-size swaps would land below the floor on every route. logger.error( - "monerium-b2b: PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS; engage guardian pause per " + - `docs/operations-monerium-b2b-runbook.md. ${detail}` + "monerium-b2b: PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS on every route; engage " + + `guardian pause per docs/operations-monerium-b2b-runbook.md. ${detail}` ); - } else if (capImpactBps > slippageBps) { - logger.warn(`monerium-b2b: executable depth below perSwapCap — cap-sized swaps would revert on minOut. ${detail}`); + } else if (best.capImpactBps > slippageBps) { + logger.warn(`monerium-b2b: executable depth below perSwapCap — cap-sized swaps would land below the floor. ${detail}`); } else { logger.info(`monerium-b2b: depth check ok. ${detail}`); } @@ -298,12 +329,17 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi } const client = getPublicClient(); const { factory } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); - const [minSwapFloor, triggerDelay] = await Promise.all([ + const [minSwapFloor, triggerDelay, sweepDelay] = 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" + }), + client.readContract({ + abi: forwarderMonitoringAbi, + address: accounts[0].forwarderAddress as Address, + functionName: "SWEEP_DELAY" }) ]); @@ -322,10 +358,16 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi if (severity === "ok") { continue; } - const hours = Math.floor((now - Number(strandedSince) * 1000) / 3_600_000); + const armedMs = now - Number(strandedSince) * 1000; + const hours = Math.floor(armedMs / 3_600_000); + const sweepInMs = Number(sweepDelay) * 1000 - armedMs; + const sweepNote = + sweepInMs <= SWEEP_IMMINENT_MS + ? `; dead-man sweep to the fallback ${sweepInMs <= 0 ? "is live" : `possible in ${Math.ceil(sweepInMs / 3_600_000)}h`}` + : ""; const message = `monerium-b2b: stranded EURe on forwarder ${forwarder} (account ${account.id}): balance=${balance}, ` + - `marker armed ${hours}h ago${severity === "error" ? " — past TRIGGER_DELAY, permissionless trigger is live" : ""}`; + `marker armed ${hours}h ago${severity === "error" ? " — past TRIGGER_DELAY, permissionless trigger is live" : ""}${sweepNote}`; if (severity === "error") { logger.error(message); } else { @@ -471,6 +513,32 @@ export async function runConfigReconciliation(): Promise { } } +/** Subsidy-vault monitor: runway of the shared vault every below-floor swap depends on. */ +export async function runSubsidyVaultMonitor(): Promise { + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const { factory, usdc } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const vault = await getPublicClient().readContract({ abi: factoryAbi, address: factory, functionName: "subsidyVault" }); + const state = await readSubsidyVaultState(vault, usdc); + if (!state) { + logger.warn("monerium-b2b: no subsidy vault is configured on the factory — every below-floor swap will defer"); + return; + } + const { reason, severity } = classifyVaultRunway(state); + const detail = + `vault=${vault}: balance=${state.balance}, dailyBudget=${state.dailyBudget}, spentToday=${state.spentToday}, ` + + `maxSubsidyPpm=${state.maxSubsidyPpm}, paused=${state.paused}`; + if (severity === "error") { + logger.error(`monerium-b2b: SUBSIDY VAULT — ${reason}; below-floor swaps are deferring. ${detail}`); + } else if (severity === "warn") { + logger.warn(`monerium-b2b: subsidy vault ${reason}; refill before below-floor swaps start deferring. ${detail}`); + } else { + logger.info(`monerium-b2b: subsidy vault ok. ${detail}`); + } +} + // ------------------------------------------------------------------ pass orchestration let lastPassAt = 0; @@ -500,6 +568,7 @@ export async function runMonitoringPass(now: number = Date.now()): Promise if (config.moneriumB2b.rpcUrl) { await guarded("executable-depth check", runExecutableDepthCheck); await guarded("stranded-balance monitor", () => runStrandedBalanceMonitor(now)); + await guarded("subsidy-vault monitor", runSubsidyVaultMonitor); await guarded("config reconciliation", runConfigReconciliation); } if (isWhitelabelConfigured()) { From 8f940a456663805e5a395e47d05c9452def3981b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:48:32 +0200 Subject: [PATCH 12/44] docs(api): sync the monerium b2b security spec with reference-priced swaps --- .../05-integrations/monerium-b2b.md | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 395284b0f..22483505c 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -24,33 +24,35 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 9. **Deposit identity and scope are verified** — authenticated payloads must pass the shared Monerium wire schema. Only EUR issue orders on the configured chain and the mapped account's Monerium profile are accepted; `meta.txHashes` is used only when it contains exactly one hash. `monerium_order_id` is unique and cannot move between accounts; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are positive 18-decimal base-unit strings converted from provider decimals, never floats. An amount-only mint match is accepted only when exactly one same-account candidate exists. A late real order in a minted provider state reconciles its unique exact same-account unattributed mint by amount and transaction hash: a missing provider row adopts the synthetic row, while an existing provider row receives the chain identity and allocations atomically before the synthetic row is removed. Pending or terminal provider states never adopt a synthetic mint. Ambiguity is quarantined and alerted, never guessed. Malformed authenticated deliveries are terminally discarded so they cannot poison the inbox. 10. **Client credentials are env-only and requests are bounded** — all provider calls go through the shared white-label client ([monerium.md](./monerium.md)): credentials come from env (`MONERIUM_WHITELABEL_CLIENT_ID/SECRET`), every call carries an explicit timeout, HTTPS base URLs only, successful responses are validated against the consumed wire schemas, and upstream failures surface with redacted response bodies. The B2B adapter (`monerium-api.ts`) adds no transport of its own. 11. **No KYB submission path exists** — the whitelabel KYB mechanism is contractually unsettled (adr-0005 registry T3), so no identity-data submission code path exists in the B2B module or the shared client. Pilot corporates do not need one: they are onboarded and approved by Monerium under the partner's KYC reliance, and the admin mapping imports that outcome as an approved `kyc_cases` row. -12. **Account mapping is admin-only, atomic, and rooted in a trusted factory** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) verifies that the forwarder's immutable `FACTORY()` equals `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, queries `isForwarder` on that configured factory (never a self-reported address), and reads back destination/fallbackAddress/feeBps before persistence. The managed child, customer entity, approved KYB mirror, and account then commit in one database transaction, so a late uniqueness conflict leaves no orphan identity records. Identical replay returns existing records; any divergence is 409, never an overwrite. A Monerium profile, forwarder, and managed profile can each back at most one account (migrations 069/071). +12. **Account mapping is admin-only, atomic, and rooted in a trusted factory** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) verifies that the forwarder's immutable `FACTORY()` equals `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, queries `isForwarder` on that configured factory (never a self-reported address), and reads back destination/fallbackAddress/targetPpm/floorPpm before persistence (the fee policy is validated as `0 <= targetPpm <= floorPpm <= 10000`, mirroring the contract). The managed child, customer entity, approved KYB mirror, and account then commit in one database transaction, so a late uniqueness conflict leaves no orphan identity records. Identical replay returns existing records; any divergence is 409, never an overwrite. A Monerium profile, forwarder, and managed profile can each back at most one account (migrations 069/071). 13. **Onboarding provider writes are exactly-once and provider reads are scoped** — the automated link (`POST /addresses`) and IBAN request (`POST /ibans`) run through the profile-scoped `financial_operations` ledger (flow `monerium-b2b-onboarding`), so crashes and retries never repeat a claimed provider call; interrupted calls reconcile by re-reading the linked addresses / issued IBANs. Every linked-address and IBAN selection requires the exact mapped profile, configured Monerium chain, and forwarder address; multiple exact IBAN matches are rejected rather than selected arbitrarily. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. 15. **The read surface is effective-user scoped and accepts no selectors** — `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` resolve the account strictly from the acting profile (manager delegation via `X-Managed-Profile-Id` under the standard managed-profile authorization with EU corridor + business policy, or the child's own credential); no caller-supplied account, profile, or IBAN identifier is accepted, a foreign manager gets the uniform managed-profile 403, and R09 `unattr:` synthetic deposit rows are never returned (`monerium-b2b-account-read.integration.test.ts`). -16. **Manager deposit events are final, chain-backed, and manager-only** — `DEPOSIT_RECEIVED` requires the real chain id, transaction hash, log index, and block number, so a provider order alone cannot claim that funds landed. `DEPOSIT_CONVERTED` fires once only after allocations cover the deposit's full EURe amount and every contributing execution is `NOTIFY_CONFIRMATION_DEPTH` blocks deep; its `conversions[]` contains per-execution EURe/USDC portions and payload `usdcNetRaw` is the aggregate. Per-deposit markers prevent replay to late subscribers. Deliveries go only to the controlling manager's webhooks through the durable outbox; `unattr:` rows never emit (`manager-events.test.ts`). +16. **Manager deposit events are final, chain-backed, and manager-only** — `DEPOSIT_RECEIVED` requires the real chain id, transaction hash, log index, and block number, so a provider order alone cannot claim that funds landed. `DEPOSIT_CONVERTED` fires once only after allocations cover the deposit's full EURe amount and every contributing execution is `NOTIFY_CONFIRMATION_DEPTH` blocks deep; its `conversions[]` contains per-execution EURe/USDC portions plus each execution's reference rate, fee and subsidy (`execution` block), and payload `usdcNetRaw` is the aggregate. Per-deposit markers prevent replay to late subscribers. Deliveries go only to the controlling manager's webhooks through the durable outbox; `unattr:` rows never emit (`manager-events.test.ts`). 17. **Account lifecycle transitions are explicit** — `onboarding → active`, `active → {suspended, closed}`, and `suspended → {active, closed}` are the only state changes; `closed` is terminal. Repeating the current status is idempotent. The admin controller returns 409 for every invalid edge, including reopening a closed account or moving an active account back to onboarding (`moneriumB2b.controller.test.ts`). ## Keeper The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox → mint watcher → per-account conversion executor → dormancy gate) holds signing keys and submits transactions; its invariants: -1. **Three-way key separation** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`/`swapAndForward()`), 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 move funds: `swapAndForward` only executes the contract-constrained oracle-checked swap to the client's own `destination`; `setGuardianPaused` is protective-only by contract invariant; the attestor signs the fixed link statement. All three are env-only and never logged. +1. **Three-way key separation** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`/`swapAndForward()`), 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 move funds: `swapAndForward` only executes a factory-whitelisted, oracle-floored swap to the client's own `destination`, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `setGuardianPaused` is protective-only by contract invariant; the attestor signs the fixed link statement. All three 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. 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 no-arg `swapAndForward()`, and receipt emits `SwapExecuted` from the forwarder. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. +3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. 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 `swapAndForward(referenceRate, routeIndex)` rebuilt from the reference and route persisted on the row before broadcast (`expectedSwapCalldata`), and receipt emits `SwapExecuted` from the forwarder. 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, execution slot check/creation, finalization, R04 allocation) 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. -5. **Attribution is N:M, cursor-gated, exact-snapshot, and idempotent (R04)** — a confirmed execution records the block and block-global `SwapExecuted` log index but is not allocated immediately. Reconciliation starts only after the persisted mint cursor has processed that block, then consumes outstanding portions of deposits minted in earlier blocks or earlier log positions in the same block, oldest-first up to `eureInRaw`. This covers a mint that lands between the executor's balance read and swap without assigning a later same-block mint to the execution. A cap-cut deposit receives a partial `monerium_deposit_allocations` row and its remainder participates in the next execution; one execution may likewise allocate across many deposits. Each row records its EURe portion and proportional net USDC; execution net is computed as `usdcOut - fee`, never the event's `forwarded` full-balance sweep, so pre-existing unsolicited USDC is not misreported as this deposit's yield. Floor dust goes to the largest allocation only when indexed deposits cover the whole execution, so missing inflow cannot inflate a customer's share. Mint identity is `(chain_id, tx_hash, log_index)` and the watcher scans 12-deep blocks. Only chain-indexed deposits make an account a conversion candidate; a raw forwarder balance never bypasses the watcher. Non-Monerium inflows become `unattr:` rows and never surface as customer claims. 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. **Attribution is N:M, cursor-gated, exact-snapshot, and idempotent (R04)** — a confirmed execution records the block and block-global `SwapExecuted` log index but is not allocated immediately. Reconciliation starts only after the persisted mint cursor has processed that block, then consumes outstanding portions of deposits minted in earlier blocks or earlier log positions in the same block, oldest-first up to `eureInRaw`. This covers a mint that lands between the executor's balance read and swap without assigning a later same-block mint to the execution. A cap-cut deposit receives a partial `monerium_deposit_allocations` row and its remainder participates in the next execution; one execution may likewise allocate across many deposits. Each row records its EURe portion and proportional net USDC; execution net is computed as `usdcOut - fee + subsidy` (the vault pays the subsidy straight to the destination), never the event's `forwarded` full-balance sweep, so pre-existing unsolicited USDC is not misreported as this deposit's yield. Floor dust goes to the largest allocation only when indexed deposits cover the whole execution, so missing inflow cannot inflate a customer's share. Mint identity is `(chain_id, tx_hash, log_index)` and the watcher scans 12-deep blocks. Only chain-indexed deposits make an account a conversion candidate; a raw forwarder balance never bypasses the watcher. Non-Monerium inflows become `unattr:` rows and never surface as customer claims. 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, 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 or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (docs/proposal-monerium-forwarder-fee-subsidy.md) — before every swap the keeper fetches the Coinbase Exchange EURC-USD ticker (`reference-rate.ts`), records price, time and trade id on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. 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. ## 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 `docs/operations-monerium-b2b-runbook.md`). -2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on the pinned EURe→EURC→USDC path at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline). Impact above `SLIPPAGE_BPS` at `minSwapAmount` size logs the error-level PAUSE THRESHOLD line; at `perSwapCap` size a warning. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. -3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal). +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. Impact above `SLIPPAGE_BPS` at `minSwapAmount` size logs the error-level PAUSE THRESHOLD line; at `perSwapCap` size a warning. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. +3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal), and within two days of `SWEEP_DELAY` (7 days, registry P3) the error names the imminent dead-man sweep to the fallback. 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). 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, destination/fallback and timelocked fee changes are authorized transitions reconciled with a version bump; 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. ## Threat Vectors & Mitigations @@ -61,6 +63,9 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | **Lost or replaced keeper transaction** | A slow/hidden transaction is declared stale and a second swap sends the same funds | Compare-and-set pre-send reservation; no time-based failure after nonce persistence; fail-closed nonce state; bounded complete persisted-block scan plus exact sender/nonce/target/calldata/event identity before adopt/fail | | **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving attribution permanently incomplete | Conversion candidates require chain-indexed deposits; allocation waits until the mint cursor covers the swap's exact block/log boundary | | **Unsolicited USDC inflates deposit reporting** | The contract sweeps a pre-existing USDC balance with a later swap and the backend credits the whole transfer to that deposit | Execution net and allocations use `SwapExecuted.usdcOut - fee`; `forwarded` is deliberately excluded from conversion accounting | +| **Manipulated reference rate** | A compromised keeper (or Coinbase response) supplies a reference that inflates the fee or triggers a subsidy | Contract band check against Chainlink; fee capped at `MAX_FEE_PPM`; subsidy bounded by the vault's per-swap cap and daily budget; the floor on the client's net; permissionless callers cannot supply a reference at all | +| **Subsidy-widened sandwich** | A searcher moves the pool so the fill lands just above floor minus cap and the vault pays the difference | Private orderflow for keeper swaps; modest per-swap cap and daily budget; the vault, not the client, absorbs the loss; no subsidy on the permissionless path | +| **Subsidy vault drain** | A contract or key tries to pull vault USDC | `pay` accepts only factory-registered clones and only to their fixed destination, within cap and budget; withdrawals go to the immutable treasury only; guardian setters bound Vortex's own money, never client funds | | **Untrusted forwarder factory** | Admin-secret holder submits a contract whose self-reported factory blesses it and redirects mints | Configured factory is the trust root for provisioning, execution, and monitoring; local provisioning is atomic | | **Webhook replay / duplicate delivery** | A captured valid delivery is replayed to double-count a deposit | Durable inbox dedup on unique `event_id` (`ON CONFLICT DO NOTHING`); forward-only transitions make a replayed older state a no-op | | **Out-of-order events regress state** | A delayed `pending` event arrives after `minted` | Forward-only transition lattice; regressions logged and dropped | @@ -88,6 +93,8 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] No KYB submission code path exists unless registry item T3 has been resolved and this spec updated - [ ] HTTPS enforcement, timeouts, and wire-schema validation on every provider call are delivered by the shared client ([monerium.md](./monerium.md)); `monerium-api.ts` adds no transport of its own - [ ] Current webhook signature/id protocol and upstream order-state vocabulary re-verified from a production delivery before first mainnet deposit (registry T4) +- [ ] Foundry suite covers the three fee bands, the fee cap, the reference band, the floor on the net after subsidy, vault cap/budget/pause/treasury-only withdrawal, route validation and the permissionless no-subsidy path; `projectSwap` mirrors the same numbers (`conversion-executor.test.ts`) +- [ ] Recovery calldata identity includes the persisted reference and route (`expectedSwapCalldata`); a row without them stays pending - [ ] Keeper, guardian, and attestor private keys are three distinct keys in production; none logged - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) - [ ] Conversion execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the swap; no elapsed-time failure exists after nonce persistence; exact recovery identity, bounded paging, and R04 N:M allocation math are covered by `conversion-executor.test.ts` From c33ea2a2fdc623970fa55ae44b398985b3f3fced Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 18:28:18 +0200 Subject: [PATCH 13/44] docs(repo): record the fee, subsidy, route and sweep decisions in adr-0005 --- docs/adr-0005-monerium-b2b-onramp.md | 115 +++++++++++++++++++----- docs/operations-monerium-b2b-rollout.md | 79 ++++++++++------ 2 files changed, 148 insertions(+), 46 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index b582acc8f..0568531ff 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -1,7 +1,9 @@ # ADR 0005: Monerium B2B Zero-Touch Onramp **Status:** Accepted (selected 2026-07-17; parameters finalized and documents consolidated -2026-08-26). This ADR is the single source of truth for the *decisions and risk +2026-08-26; amended 2026-09-15 with reference-priced fee bands, the subsidy vault, the +route whitelist and the 7 day sweep — see the amendment section). This ADR is the +single source of truth for the *decisions and risk acceptances* of the B2B EUR → USDC onramp. How the system works lives in [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); security invariants and the threat model in @@ -39,15 +41,19 @@ a fiat theft path and unambiguous custody. The whole design follows from closing Supporting decisions, all in force: -- **Conversion policy** (unchanged from the consumer design): pinned EURe→EURC→USDC - Uniswap v3 route, contract-constructed calldata (never caller-supplied — the swap is - permissionless after the trigger delay), Chainlink EUR/USD minimum-output bound with - staleness ceiling, exact approvals, atomic delta checks, fee skim to an immutable +- **Conversion policy** (amended 2026-09-15): the swap runs over one of the factory's + whitelisted Uniswap v3 routes — validated on chain to touch only EURe, EURC and USDC + on the immutable router — chosen by the caller through a route index; the caller also + supplies the partner reference rate, which the contract bounds to a band around + Chainlink EUR/USD (staleness ceiling kept). The fill is settled into fee bands + (amendment) and the Chainlink floor is enforced on the client's net after fee and + subsidy; exact approvals and atomic delta checks stay; the fee goes to an immutable treasury. -- **No upgradeability, ever.** Immutable-and-migratable: evolution (new pools, new - routes, contract fixes) happens by deploying a new implementation + factory and +- **No upgradeability, ever.** Immutable-and-migratable: evolution (new tokens, a new + router, contract fixes) happens by deploying a new implementation + factory and migrating clients clone-by-clone — never by mutating deployed code. The custody - argument depends on it. + argument depends on it. Routes, the fee policy and the vault limits are bounded + *data* the guardian may change within immutable validation, not code. - **Mandatory self-custodied `fallbackAddress`** for every client (Tier C "no fallback" dropped 2026-07-17 — condition of Monerium's acceptance; Tier B "partner-held recovery key" rejected 2026-07-14 — the partner declines custody-like powers). All @@ -55,8 +61,18 @@ Supporting decisions, all in force: dead-man sweep. - **Never send raw EURe to a CEX destination** — EURe recovery targets are the fallback address only. -- **No on-contract redeem validator.** Redemption = withdraw to fallback, then redeem - normally; Monerium's issuer recovery is the break-glass backstop (see T1 below). +- **No on-contract redeem validator, and no payment bouncing** (reaffirmed 2026-09-15). + Redemption = withdraw to fallback, then redeem normally; Monerium's issuer recovery is + the break-glass backstop (see T1 below). Returning a deposit to its sender would be a + redeem order the forwarder must approve via EIP-1271; any such path hands whoever + holds the whitelabel credentials plus the signing key a fiat drain to an arbitrary + IBAN, and the safe variant (a return IBAN pinned per clone) still buys nothing for the + liquidity case it was asked for — swaps stop at the floor and funds wait safely. +- **No Vortex-triggered sweep to the fallback address** (decided 2026-09-15). Only the + client (`sweep`) and the permissionless dead-man sweep move EURe to the fallback; a + guardian shortcut would weaken "Vortex keys cannot move client funds". Instead the + dead-man delay is short (P3, 7 days), and because the sweep ignores pauses that is + also the longest hold Vortex can impose — token-level freezes are Monerium's lever. - **EIP-191 hash only, chainid-bound** (the raw-keccak variant was removed after the G0 sandbox validation; chainid binding closes cross-chain replay — review r1). - **Three distinct Vortex keys** (attestor / keeper / guardian), none able to move or @@ -73,27 +89,75 @@ Supporting decisions, all in force: `DEPOSIT_CONVERTED` after all portions settle, with `conversions[]` and aggregate attributed USDC rather than a misleading event per chunk. +## Amendment 2026-09-15: reference-priced fee bands and the subsidy vault + +The partner agreement fixes the client's rate against a reference: the client receives +the Coinbase EURC-USD reference minus 12.5 bps, and never worse than 15 bps below it. +A flat skim on whatever the DEX returns cannot express that, so the contract now settles +every fill into bands against a reference rate (decided with the partner; contracts were +not yet deployed, so this replaced the flat fee before launch with no migration): + +- **Reference rate.** The keeper fetches the Coinbase Exchange EURC-USD ticker before + each swap, records price, time and trade id on the execution row, and passes the rate + into `swapAndForward`. The contract rejects a reference outside + `MAX_REFERENCE_DEVIATION_BPS` of Chainlink; on the permissionless path the argument is + ignored and Chainlink is the reference. Reading the price from Vortex's own oracle on + Base was rejected: it blends a forex rate on weekdays and lives on another chain. +- **Fee bands** (per clone, ppm below the reference, `targetPpm` ≤ `floorPpm` ≤ + `MAX_FEE_PPM`, increases timelocked as before): a fill above `reference × (1 − + target)` gives the surplus to the treasury as fee, capped at `MAX_FEE_PPM`; a fill + between floor and target is passed through untouched; a fill below `reference × (1 − + floor)` is topped up to the floor from the vault. The 2.5 bps dead band is intended. +- **Subsidy vault.** One `VortexSubsidyVault` shared by every clone, treasury-funded, + pays only when called by a factory-registered clone and only to that clone's fixed + destination, within a guardian-settable per-swap cap and UTC-daily budget, can be + paused, and withdraws only to the treasury. A vault that cannot cover reverts the whole + swap — a swap is never partially subsidized. The vault holds Vortex money only, so its + limits bound Vortex's exposure, never the client's. +- **Floor on the net.** `SLIPPAGE_BPS` (now 40 bps) is enforced on fill − fee + subsidy, + not on the raw fill; the router minimum is zero and the forwarder's post-condition is + the guard, so a subsidy can never paper over a depegged reference. +- **Route whitelist.** The factory holds guardian-managed routes, validated on chain to + EURe/EURC/USDC only, Uniswap's four tiers, at most two hops, the immutable router; + entries are disabled, never removed. The keeper quotes every enabled route and passes + the best index; a poor pick costs Vortex fee or subsidy, never the client, because the + floor applies whichever route runs. On-chain best-of was rejected (quoter gas). +- **Keeper deferral.** The keeper mirrors the settlement off-chain and, when the vault + could not cover, the net would breach the floor, the reference is unavailable or out + of band, or no route quotes, it defers: no execution row, funds wait, marker armed. +- **Accepted limitation.** After the 24 h trigger anyone may execute the swap, priced + against Chainlink and unsubsidized, so a forced swap after a deliberate deferral can + land below the 15 bps floor. Accepted: the trigger exists so no Vortex outage can trap + funds; the rate guarantee applies to keeper-executed swaps and the terms say so. + Pausing instead of deferring was rejected as turning every market dip into an + operator incident. +- **Accepted exposure.** The subsidy widens the sandwich-exploitable band from the + floor to floor plus the per-swap cap, paid by the vault; private orderflow and a + modest cap are the mitigation, and the permissionless path keeps the plain floor. + ## Final parameters (decided 2026-08-26 unless noted) | ID | Parameter | Value | |---|---|---| -| B1 | Service fee | **0 bps pilot / 15 bps GA starting point** (per client, guardian-adjustable) | +| B1 | Fee policy | **target 1250 ppm (12.5 bps), floor 1500 ppm (15 bps) below the reference**, per client, guardian-adjustable (amended 2026-09-15; replaces the flat 0 / 15 bps skim) | | B2 | Penny-test amount | 5 USDC | | B3 | Processing SLA wording | **Same business day**; weekend mints execute within the 52 h oracle window at possibly wider spreads | | B4 | Pilot volume limits | **€50k/client/day, paper/contractual only** (no backend enforcement in the pilot; GA revisit) | | B5 | Partner liability | Tier A defaults: partner warrants destination correctness; rotation loss borne by the client; dormancy re-activation on written partner confirmation | | B6 | Redemption-limitation disclosure | Mandatory in client terms (committed to Monerium); draft in the rollout doc | -| P1 | `SLIPPAGE_BPS` | 100 (1%) | -| P2 | `MAX_FEE_BPS` | 100 (1%), immutable | -| P3 | Dead-man sweep delay | 60 days | +| P1 | `SLIPPAGE_BPS` | **40 bps on the client's net after fee and subsidy** (amended 2026-09-15; was 100 on the raw fill) | +| P2 | `MAX_FEE_PPM` | 10000 ppm (1%), immutable; caps both the fee and the floor policy (amended 2026-09-15; was `MAX_FEE_BPS` 100) | +| P3 | Dead-man sweep delay | **7 days** (amended 2026-09-15; was 60) — also the longest hold Vortex can impose | | P4 | Permissionless trigger delay | 24 h | | P5 | Dormancy window | 60 days | | P6 | `minSwapAmount` | floor €25 (immutable) / operational **€250** | | P7 | `perSwapCap` | operational **€25k** / ceiling €50k (re-measure liquidity at the deploy block before raising) | | P8 | `MAX_ORACLE_AGE` | **52 h** (observed Chainlink EUR/USD weekend gaps up to 48 h; applied to configs 2026-08-26) | | P9 | Notification confirmation depth | 32 blocks (implemented) | -| P10 | Router pin | SwapRouter02, 5 bps fee tiers; re-verify pools at the deploy block | -| P11 | Fee adjustability | Guardian `setFeeBps` within `MAX_FEE_BPS`; increases behind a 24 h announced timelock, decreases immediate (implemented) | +| P10 | Router pin and routes | SwapRouter02 immutable; routes are a guardian-managed, on-chain validated whitelist (EURe/EURC/USDC, four tiers, ≤ 2 hops); initial route EURe→EURC→USDC at the 5 bps tiers, re-verify at the deploy block (amended 2026-09-15) | +| P11 | Fee adjustability | Guardian `setFeePolicy(target, floor)` within `MAX_FEE_PPM`; raising either value is announced and applies after 24 h, lowering is immediate (amended 2026-09-15) | +| P12 | Reference rate | Coinbase Exchange EURC-USD ticker, keeper-fetched per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15) | +| P13 | Subsidy vault limits | One shared vault; **50 bps of the reference value per swap, 200 USDC per UTC day** at launch, guardian-settable; withdraw to treasury only (2026-09-15) | | T2 | Whitelabel MSA terms | Open — G1 negotiation (rollout doc), includes the per-IBAN suspension ask | | T3 | KYB submission mechanism | Open, deliberately unbuilt — pilot corporates are approved by Monerium under partner KYC reliance and imported via the admin mapping; no identity-data submission path may exist until this settles (security-spec invariant 11) | | T4 | Sandbox wire-format verifications | Webhook digest encoding, delivery id field, order-state vocabulary, and the EIP-191 link-hash variant were confirmed against the sandbox during G0; re-verify against production before first mainnet deposit | @@ -135,8 +199,15 @@ example (oversized-deposit allocation). the custody definition, but exchange/transfer-service scoping is a separate G2 question. Never present "no custody" as "no licence needed". - **Stuck-state table** (route death, feed retirement, depeg beyond bound, blacklisted - destination): all fail-safe — swaps revert, funds accumulate as EURe, client exits - keep working; recovery is client-side sweep plus the issuer backstop. Accepted. + destination, reference feed outage, exhausted subsidy budget): all fail-safe — swaps + revert or the keeper defers, funds accumulate as EURe, client exits keep working; + recovery is client-side sweep plus the issuer backstop. Accepted. +- **Bounded keeper pricing power.** A compromised keeper can pick any whitelisted route + and any reference inside the Chainlink band: worst case the fee reaches `MAX_FEE_PPM` + or the vault pays up to its caps. Bounded by the band, the fee cap, the vault limits + and the floor on the net; it can still never redirect funds. Accepted. +- **Subsidy exposure.** Up to the per-swap cap per swap and the daily budget per day, + plus the widened sandwich band (amendment). Accepted; both limits are live-tunable. - **Operational residuals:** reorgs deeper than the watcher's 12-block lag; financial-operation claim-crash windows require manual reconciliation; deposit batching is intra-client only and pro-rata attribution never changes a client's @@ -147,6 +218,8 @@ example (oversized-deposit allocation). Zero-touch onboarding works end to end (validated against the Monerium sandbox: link accepted, IBAN issued, no client interaction). Clients keep unilateral exits that no Vortex failure can block. The cost: every rescue path must be designed in upfront -(no universal owner key), fee/venue changes are governed by timelocks and migrations -rather than admin switches, and Vortex accepts elevated provisioning trust plus a -control-plane risk at Monerium that only contract terms and monitoring can bound. +(no universal owner key), fee-policy increases are timelocked and venue changes are +bounded by on-chain route validation rather than admin switches, the partner's rate +guarantee is enforced by the contract at the cost of a treasury-funded subsidy budget, +and Vortex accepts elevated provisioning trust plus a control-plane risk at Monerium +that only contract terms and monitoring can bound. diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 8ba3cf968..9cafa5154 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -32,13 +32,15 @@ verbal/Telegram statements; consolidate into the MSA or a side letter: **G2 — legal review** (not started): custody opinion on the attestor construction; MiCA exchange/transfer-service scoping (non-custody is not the whole question); disclosure enforceability; DPA with Monerium; sanctions screening for destinations; scope of the -bounded, pre-announced guardian fee power (P11). +bounded, pre-announced guardian fee-policy power (P11), the route whitelist (P10) and +the subsidy vault (P13). **G3 — external contract audit.** Parameters are final (ADR); the internal reviews and the invariant suite are done, but this moves client funds. **G4 — pilot.** SulPayments agreement signed (terms inputs below), reliance -attestations per customer, 3–5 clients at **€50k/client/day** (paper control), fee 0. +attestations per customer, 3–5 clients at **€50k/client/day** (paper control), launch +fee policy 12.5 bps target / 15 bps floor (B1). ## Deploy checklist (mainnet bring-up) @@ -51,19 +53,25 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control 2. **Treasury first (O2):** create the dedicated fee Safe multisig — `FEE_RECIPIENT` is immutable in the implementation. Confirm guardian key custody plan (EOA acceptable for pilot; hardware/multisig at GA). -3. Re-verify the pinned pools and fee tiers at the deploy block (P10) and re-run the - liquidity baseline quote methodology (T6); confirm `perSwapCap` €25k still executes - within the slippage bound. +3. Re-verify the initial route's pools and fee tiers at the deploy block (P10) and re-run + the liquidity baseline quote methodology (T6); confirm `perSwapCap` €25k still + executes within floor plus the per-swap subsidy cap, and decide whether a second + route (direct EURe→USDC or other tiers) is worth whitelisting from day one. 4. Deploy implementation + factory with the final parameters (ADR table: 52 h oracle - age, 100 bps slippage/fee cap, 60 d/24 h/60 d delays, €25 floor/€50k ceiling); set - operational `minSwapAmount` €250 and `perSwapCap` €25k; register the keeper key. + age, 40 bps floor on the net, 1% fee cap, 100 bps reference band, 7 d/24 h delays, + €25 floor/€50k ceiling, initial 5 bps/5 bps route); set operational `minSwapAmount` + €250 and `perSwapCap` €25k; register the keeper key. +4a. Deploy `VortexSubsidyVault` (USDC, the fee Safe as treasury, the factory, 50 bps per + swap, 200 USDC per day — P13), point the factory at it (`setSubsidyVault`), and fund + it from the treasury with the first days of budget. Runbook §2.6 has the commands. 5. Verify factory + implementation source on the block explorer; generate, verify, and publish the manifest. 6. Production whitelabel credentials from Monerium; configure the keeper backend (the mykobo flow variant only): credentials, attestor/keeper/guardian keys (three distinct; keeper funded), read RPC + private orderflow RPC, webhook secret, and - `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`. Keep `MONERIUM_B2B_ENABLED=false` until - every remaining gate is complete. + `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`; the backend needs outbound HTTPS to + `api.exchange.coinbase.com` for the reference rate (P12) — without it every swap + defers. Keep `MONERIUM_B2B_ENABLED=false` until every remaining gate is complete. 7. Register the webhook endpoint at Monerium (`profile.updated`, `iban.updated`, `order.created`, `order.updated`). 8. Before first production onboarding, simulate a SEPA deposit end to end (dashboard → @@ -105,18 +113,33 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control > If no conversion completes for 60 days, forwarding pauses automatically and > resumes only after you (or the partner on your behalf, in writing) re-confirm your > payout address. Deposits made while paused remain in your forwarding account and - > convert after re-confirmation; your fallback-address rights are unaffected. -4. **Fees (B1/P1/P2)** — disclose fee and conversion bound separately: - - Service fee: per-client percentage set at account creation (**pilot 0; GA - starting point 15 bps**), assessed on gross USDC output, contractual ceiling - equal to the on-chain cap (1%). Increases require a 24 h on-chain pre-announcement - (P11); decreases are immediate. - - Conversion bound (not a fee): each conversion delivers at least the Chainlink - EUR/USD reference rate minus 1%, or it does not execute (deposits wait and - retry). Enforced by the contract assuming an honest oracle; not a principal - guarantee under oracle failure or a stablecoin collapse beyond the bound. - - Batching never changes a client's effective rate: co-converted deposits split fee - and output pro-rata by amount. + > convert after re-confirmation; your fallback-address rights are unaffected. Once + > a balance has waited 7 days without conversion, anyone may return it to your + > fallback address instead. +4. **Rate, fee and subsidy (B1/P1/P2/P12/P13)** — disclose the guarantee, the fee and + the hard bound separately: + - Reference rate: the Coinbase Exchange EURC-USD price fetched immediately before + each conversion and recorded with the conversion (the agreement's "Coinbase EURC + oracle" — align the wording; the source is the exchange ticker, weekdays and + weekends alike). + - Guarantee: each keeper-executed conversion delivers the reference rate minus + 12.5 bps whenever the market allows it, and never less than the reference minus + 15 bps. Vortex's fee is whatever the market delivers above the 12.5 bps target, + contractually capped at the on-chain 1%; below the 15 bps floor Vortex tops the + conversion up from its own subsidy budget. The 12.5 bps target and 15 bps floor are + per client; raising either requires a 24 h on-chain pre-announcement (P11), + lowering is immediate. + - Subsidy limits: top-ups are capped per conversion and per day (P13). When the + budget cannot cover a conversion, the conversion waits rather than executing below + the floor. After a conversion has waited 24 hours, anyone may execute it at the + unsubsidized Chainlink-bounded terms below; the guarantee applies to conversions + Vortex's keeper executes. + - Hard bound (not a fee): no conversion ever delivers less than the Chainlink + EUR/USD rate minus 0.4% after fee and subsidy, or it does not execute. Enforced by + the contract assuming an honest oracle; not a principal guarantee under oracle + failure or a stablecoin collapse beyond the bound. + - Batching never changes a client's effective rate: co-converted deposits split fee, + subsidy and output pro-rata by amount. 5. **Processing SLA (B3 — decided: same business day).** Draft: > Deposits at or above the minimum convert the same business day under normal > market conditions. Conversions also execute on weekends; the EUR/USD reference @@ -125,10 +148,13 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control > conversion bound. Deposits below the minimum accumulate until it is reached. Include: the SLA is a service target, not a guarantee; keeper outages beyond 24 h - open a permissionless execution path, so conversion does not depend on Vortex. + open a permissionless execution path, so conversion does not depend on Vortex; a + conversion deliberately held back by the subsidy limits is disclosed as waiting, not + failed. 6. **Vortex powers & self-custody disclosure.** What Vortex can do: deploy the account, - run the conversion, pause it, tune bounded parameters, adjust the fee within the - disclosed cap and timelock. What Vortex cannot do: move, redeem, or redirect funds — + run the conversion, pause it, tune bounded parameters, adjust the fee policy within + the disclosed cap and timelock, choose the swap route among an on-chain validated + set, and fund or limit its own subsidy budget. What Vortex cannot do: move, redeem, or redirect funds — every exit target is client-controlled, and pauses never block the fallback rights or the delayed automatic sweep. Exit guarantees are scoped to the client's continued control of their fallback key (loss of that key plus a broken destination is an @@ -144,5 +170,8 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control | G3 audit | External | After PR merge; params final | | SulPayments agreement (terms above) | Marcel ↔ partner | Drafting inputs ready | | Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering (needs Marcel's sandbox login) | Open — only remaining engineering unknown | -| Fee Safe multisig creation | Ops | Before implementation deploy | +| Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | +| Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation reads the Coinbase Exchange EURC-USD ticker — confirm that is what was meant | +| Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable) | +| Subsidy vault funding and refill cadence | Ops | Before first activation; runbook §2.6 | | GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | From e78e98c45528b89204b378bec5630dece656b009 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 18:30:39 +0200 Subject: [PATCH 14/44] docs(repo): describe reference-priced swaps in the architecture and runbook --- contracts/monerium-forwarder/README.md | 9 +- docs/api/pages/14-managed-profiles.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 149 ++++++++++++++++------- docs/operations-monerium-b2b-runbook.md | 135 ++++++++++++++------ 4 files changed, 214 insertions(+), 81 deletions(-) diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index 0d7517c65..89ec7ce4f 100644 --- a/contracts/monerium-forwarder/README.md +++ b/contracts/monerium-forwarder/README.md @@ -2,11 +2,14 @@ Foundry project for the attestor-linked forwarder (Monerium B2B zero-touch onramp): per-client EIP-1167 clones whose EIP-1271 `isValidSignature` accepts only the fixed -Monerium link message from the Vortex attestor, with an immutable EURe→EURC→USDC -conversion policy and client-controlled recovery. +Monerium link message from the Vortex attestor, a conversion policy that swaps over a +factory-whitelisted Uniswap v3 route and settles the fill against a partner reference +rate (fee above the target, top-up from the shared `VortexSubsidyVault` below the floor, +Chainlink floor on the client's net), and client-controlled recovery. - Spec: [docs/architecture-monerium-b2b-onramp.md](../../docs/architecture-monerium-b2b-onramp.md) §2 -- Parameter values (slippage, delays, caps, fee) are decided in + and its "Fees, reference rate and subsidy" section +- Parameter values (floor, delays, caps, fee policy, reference band, vault limits) are decided in [docs/adr-0005-monerium-b2b-onramp.md](../../docs/adr-0005-monerium-b2b-onramp.md) — that table, not values hardcoded in tests or scripts, is authoritative. diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 2ffa400c5..5ec723606 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -15,7 +15,7 @@ Manager status is granted by Vortex, not self-service. During partner onboarding - **Allowed corridors** — the countries (`BR`, `AR`, `CO`, `MX`, `US`, `EU`) your children may operate in. - **Optional customer-type narrowing** — restrict children to `individual` or `business`; a null policy allows both wherever the corridor's canonical capability matrix does. -Every delegated operation re-checks this policy at request time, so a corridor removed from your manager record immediately blocks new mutations for children in that corridor (in-flight ramps continue). Quoted EUR ramps are not available for managed children — those flows are bound to a verified login email. The `EU` corridor instead covers the dedicated business EUR onramp account surface (`GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` under delegation or a child credential), available to business children whose accounts Vortex provisions during partner onboarding. +Every delegated operation re-checks this policy at request time, so a corridor removed from your manager record immediately blocks new mutations for children in that corridor (in-flight ramps continue). Quoted EUR ramps are not available for managed children — those flows are bound to a verified login email. The `EU` corridor instead covers the dedicated business EUR onramp account surface (`GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` under delegation or a child credential), available to business children whose accounts Vortex provisions during partner onboarding. The account response carries the child's fee policy (`targetPpm` and `floorPpm`, parts per million below the reference rate), and every conversion listed on a deposit carries the execution's reference rate, fee and subsidy, as documented for the [`DEPOSIT_CONVERTED` webhook](https://api-docs.vortexfinance.co/webhooks). ## Create A Managed Child diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index a8a922f8a..0b93f1ca5 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -16,9 +16,11 @@ deploys one `VortexForwarder` contract clone per client, links it to that profil attestor signature, and requests an IBAN **for the linked contract address** — the IBAN's default mint destination *is* the forwarder. From then on the flow is passive on Monerium's side: EUR received on the IBAN mints EURe to the forwarder, and Vortex's -keeper calls `swapAndForward()` on the contract, which swaps EURe → EURC → USDC on -Uniswap v3 (Chainlink-bounded minimum output) and transfers the USDC to the client's -fixed destination wallet, minus the configured fee to the treasury. The flow is +keeper calls `swapAndForward(reference, route)` on the contract, which swaps EURe to +USDC over a whitelisted Uniswap v3 route, settles the fill against the partner reference +rate (surplus above the target is the fee, shortfall below the floor is topped up from +the subsidy vault, Chainlink bounds the net), and transfers the USDC to the client's +fixed destination wallet. The flow is deliberately **not** a ramp: no quote, no `ramp_states` — the account is permanent and repeatedly funded. Inside Vortex the client is a **managed child profile** under the partner manager, which is what carries KYB records, API credentials, the read API, and @@ -47,12 +49,17 @@ flowchart LR subgraph Chain["Ethereum mainnet"] FWD["VortexForwarder clone\n(one per client)"] FACT[Factory + implementation] - UNI[Uniswap v3\nEURe-EURC-USDC] + UNI[Uniswap v3\nwhitelisted routes] LINK[Chainlink EUR/USD] + VAULT["VortexSubsidyVault\n(shared, treasury-funded)"] DEST[Client wallet] TREAS[Treasury FEE_RECIPIENT] end + subgraph Reference["Reference rate"] + CB[Coinbase Exchange\nEURC-USD ticker] + end + subgraph Vortex["Vortex API (keeper backend)"] ADM["Admin API\n/v1/admin/monerium-b2b"] INBOX[("monerium_webhook_events\n(durable inbox)")] @@ -60,7 +67,7 @@ flowchart LR MW[Mint watcher] CE[Conversion executor] ONB[Onboarding automation] - MONI[4 detection monitors] + MONI[5 detection monitors] OUTBOX[("webhook_deliveries\n(durable outbox)")] READ["Read API\n/v1/monerium-b2b/*"] end @@ -69,11 +76,15 @@ flowchart LR MWH -- "order.*, iban.updated (HMAC)" --> INBOX INBOX --> DP MW -- "EURe Transfer logs" --> FWD - CE -- "swapAndForward()" --> FWD + CB -- "price before each swap" --> CE + CE -- "quotes every route" --> UNI + CE -- "swapAndForward(reference, route)" --> FWD FWD --> UNI - FWD -- "minOut check" --> LINK + FWD -- "band + floor on the net" --> LINK FWD -- "USDC - fee" --> DEST FWD -- fee --> TREAS + FWD -- "pay(shortfall)" --> VAULT + VAULT -- subsidy --> DEST ONB -- "link address + request IBAN" --> MAPI MONI -- "association / config reads" --> MAPI OUTBOX -- "DEPOSIT_RECEIVED / DEPOSIT_CONVERTED" --> PAPI @@ -84,7 +95,9 @@ Trust boundaries worth holding onto: **Monerium controls where EURe mints** (the linked default address — which is why the association monitor exists); **the contract controls where funds can go** (fixed `destination`, fee to the immutable treasury, fallback sweep — the keeper can only ever trigger, never redirect); **Vortex controls -timing and accounting**, nothing more. +timing, route choice and the reference within on-chain bounds** (a validated route set, +a Chainlink band, a fee cap, vault caps and a floor on the client's net), which can move +the price inside those bounds but never where funds go; and **accounting**. ## Onboarding sequence (per client) @@ -97,7 +110,7 @@ sequenceDiagram participant C as Ethereum Note over M: Monerium onboards the corporate under partner reliance - profile "approved" - Op->>C: deployForwarder(destination, fallback, feeBps) via factory + Op->>C: deployForwarder(destination, fallback, targetPpm, floorPpm) via factory Op->>Adm: POST /v1/admin/monerium-b2b/accounts Adm->>C: verify clone against configured trusted factory + config read-back Adm->>Adm: atomically commit managed child + KYB mirror + account @@ -114,8 +127,8 @@ Steps in prose: profile arrives `approved`. (Vortex's KYB submission API is a deliberate 501 stub — registry T3.) 2. **Operator deploys the forwarder clone** with the client's `destination`, mandatory - self-custodied `fallbackAddress`, and initial `feeBps`; manifest generated and - verified. + self-custodied `fallbackAddress`, and the initial fee policy (`targetPpm`, + `floorPpm`); manifest generated and verified. 3. **Admin mapping** — one idempotent call provisions the managed child, mirrors the approved KYB into `provider_customers` + `kyc_cases`, verifies the clone against the configured trusted factory on chain, and creates the account row bound via @@ -132,7 +145,9 @@ sequenceDiagram participant B as Client's bank participant M as Monerium participant F as Forwarder (chain) + participant S as Subsidy vault (chain) participant V as Vortex keeper + participant CB as Coinbase participant P as Partner B->>M: SEPA transfer to the IBAN @@ -140,10 +155,14 @@ sequenceDiagram M-->>V: order.created / order.updated webhook -> inbox -> deposit row V->>F: (watcher) sees the Transfer log -> stamps chain identity V->>V: DEPOSIT_RECEIVED -> outbox -> partner webhook - V->>F: swapAndForward() [execution row committed first] - F->>F: swap min(balance, perSwapCap) via Uniswap, Chainlink minOut + V->>CB: EURC-USD ticker (reference, recorded on the execution row) + V->>V: quote every whitelisted route, project fee/subsidy, defer if the vault cannot cover + V->>F: swapAndForward(reference, bestRoute) [execution row committed first] + F->>F: swap min(balance, perSwapCap) on the route; fee above target, floor on the net + F->>S: pay(shortfall) when the fill is below the floor + S->>P: subsidy USDC to client wallet F->>P: USDC - fee to client wallet (fee to treasury) - V->>V: finalize from SwapExecuted event + V->>V: finalize from SwapExecuted event (fee, subsidy, reference, route) Note over V: mint cursor reaches the swap block V->>V: R04 attribution through the exact swap log position Note over V: 32 blocks later @@ -240,8 +259,9 @@ write of the current status is idempotent. A nonce-less execution row is a five- pre-send reservation; expiry uses a compare-and-set so its original owner can no longer broadcast. Once the swap nonce is persisted, time alone never fails the execution. Recovery scans bounded 2,000-block pages from the pre-broadcast block and adopts only -one transaction matching the keeper sender, nonce, forwarder target, exact -`swapAndForward()` calldata, and emitted event; incomplete or ambiguous evidence stays +one transaction matching the keeper sender, nonce, forwarder target, the exact +`swapAndForward(reference, route)` calldata rebuilt from the reference and route +persisted before broadcast, and emitted event; incomplete or ambiguous evidence stays pending for manual reconciliation. The account additionally carries a `dormant_since` marker (guardian-paused after 60 days without a conversion; conversion stops, the protective stranding marker still arms). @@ -277,26 +297,62 @@ execution only when they arrive within about a minute of each other or during do And batching only ever merges deposits of the **same client** — every client has their own forwarder, so cross-client funds never mix. -## Fees - -- **Rate (`feeBps`)**: per-client, set at clone initialization and adjustable by the - guardian via `setFeeBps`, always capped by the implementation-immutable - `MAX_FEE_BPS`. Increases are announced on-chain and apply (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 higher fee; decreases are immediate - (registry P11). Swaps always use the currently applied fee — an announced increase - never touches a swap inside its window. +## Fees, reference rate and subsidy + +The partner agreement fixes the client's rate against a reference: the reference minus +12.5 bps whenever the market allows it, never worse than 15 bps below it. The contract +settles every fill into three bands against that reference (decisions: +[`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md), amendment). + +- **Reference rate.** Before each swap the keeper fetches the Coinbase Exchange EURC-USD + ticker (`reference-rate.ts`), stores price, time and trade id on the execution row, + and passes the rate into `swapAndForward`. The contract rejects a reference outside + `MAX_REFERENCE_DEVIATION_BPS` of Chainlink EUR/USD; a permissionless caller's value is + ignored and Chainlink is the reference. No reference means the keeper defers. +- **Fee policy (`targetPpm`, `floorPpm`)**: per clone, in ppm below the reference, + `target ≤ floor ≤ MAX_FEE_PPM`. A fill above `reference × (1 − target)` gives the + surplus to `FEE_RECIPIENT` as fee, capped at `MAX_FEE_PPM`; a fill between floor and + target is passed through untouched; a fill below `reference × (1 − floor)` is topped + up to the floor. Raising either value is announced on chain and applies + (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. +- **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded + from the treasury. It pays only when called by a factory-registered clone, only to + that clone's fixed destination, within a guardian-settable per-swap cap (ppm of the + swap's reference value) and a UTC-daily budget; it can be paused and withdraws only + to the treasury. A vault that cannot cover the shortfall reverts the whole swap — a + swap is never partially subsidized. The vault holds Vortex money only. +- **Floor on the net**: `SLIPPAGE_BPS` bounds fill − fee + subsidy against Chainlink, + not the raw fill. The router minimum is zero and the forwarder's post-condition is the + guard, so a subsidy can never paper over a depegged reference and the whole call, + subsidy transfer included, reverts when the floor fails. +- **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. +- **Keeper deferral**: before reserving an execution row the keeper mirrors the + settlement off-chain (`projectSwap`). It defers — nothing sent, no row, funds wait, + stranding 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 vault + balance, or the projected net would breach the floor. After the 24 h trigger anyone + may execute the swap anyway, priced against Chainlink and unsubsidized (accepted + limitation, ADR). - **Destination (`FEE_RECIPIENT`)**: an immutable baked into the **implementation** contract at deployment, shared by every clone of that implementation. Changing the treasury address means deploying a new implementation + factory and using it for new clones. There is no per-client fee destination and no setter. -- The database mirrors `fee_bps` on the account row for accounting and drift detection - only; the contract value is authoritative, and the config monitor reconciles - guardian fee changes (warn + version bump) while alarming on anything unauthorized. +- The database mirrors `target_ppm` / `floor_ppm` on the account row for accounting and + drift detection only; the contract values are authoritative, and the config monitor + reconciles guardian policy changes (warn + version bump) while alarming on anything + unauthorized. Each execution row records the reference, the route, the fee and the + subsidy; the client's net is `usdcOut − fee + subsidy` and flows into attribution + unchanged, and the partner sees the same three pricing facts on every conversion. ## Monitoring (detection-only) -Four monitors run from the keeper worker (rate-limited to one pass per ~30 minutes), +Five monitors run from the keeper worker (rate-limited to one pass per ~30 minutes), read-only — no keys, no transactions: 1. **Association monitor (the S1 detective control).** Per active account it re-reads @@ -306,15 +362,19 @@ read-only — no keys, no transactions: or unrecorded. This is the control for the structural risk that Vortex-held whitelabel credentials can change associations at Monerium: those changes cannot be prevented client-side, only detected fast. -2. **Executable-depth monitor.** QuoterV2 quotes on the pinned swap path vs Chainlink; - price impact past the slippage bound is an alert before clients feel it. +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. 3. **Stranded-balance monitor.** Forwarders holding EURe with the stranding marker - armed too long — a keeper-outage signal (past the trigger delay, the permissionless - fallback is live; funds are never at risk, conversion is just late). + armed too long — a keeper-outage or deferral signal (past the trigger delay, the + permissionless fallback is live; within two days of the 7 day sweep the alert says + the dead-man sweep to the fallback is imminent; funds are never at risk). 4. **Config reconciliation.** Re-reads per-clone config and bytecode: client-authorized - changes (destination/fallback) and guardian-authorized changes (feeBps, timelocked) - are reconciled into the DB with a version bump; bytecode or registration drift is a - should-be-impossible incident. + changes (destination/fallback) and guardian-authorized changes (fee policy, + timelocked) are reconciled into the DB with a version bump; bytecode or registration + drift is a should-be-impossible incident. +5. **Subsidy-vault monitor.** Balance, daily budget, spend and pause state of the shared + vault: paused or empty is an error (every below-floor swap defers), less than a day + of budget or an exhausted day is a refill warning. ## Data model — the Monerium B2B tables @@ -338,7 +398,8 @@ erDiagram string forwarder_address UK string destination string fallback_address - int fee_bps + int target_ppm + int floor_ppm enum status } monerium_fiat_deposits { @@ -351,6 +412,9 @@ erDiagram monerium_conversion_executions { decimal eure_in_raw decimal usdc_net_raw + decimal subsidy_raw + decimal reference_rate_raw + int route_index string tx_hash int nonce int broadcast_block_number @@ -367,9 +431,9 @@ erDiagram | Table | Purpose | |---|---| -| `monerium_accounts` (069, 071) | One row per client account: Monerium profile UUID, IBAN, forwarder/destination/fallback addresses, `fee_bps`, lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | +| `monerium_accounts` (069, 071, 078) | One row per client account: Monerium profile UUID, IBAN, forwarder/destination/fallback addresses, fee policy mirror (`target_ppm`, `floor_ppm`), lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | | `monerium_fiat_deposits` (069, 070, 073, 076) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status, on-chain mint identity, and two webhook-emission markers | -| `monerium_conversion_executions` (069, 074, 075, 077) | One row per `swapAndForward()`, created before broadcast: EURe in, USDC gross + fee from the event, conversion net (`usdcOut - fee`, excluding unrelated USDC swept by `forwarded`), tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and `SwapExecuted` log index (allocation boundary), status | +| `monerium_conversion_executions` (069, 074, 075, 077, 079) | One row per `swapAndForward()`, created before broadcast with the reference (rate, source, trade id, time) and route it will send: EURe in, USDC gross + fee + subsidy from the event, conversion net (`usdcOut - fee + subsidy`, excluding unrelated USDC swept by `forwarded`), tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and `SwapExecuted` log index (allocation boundary), status | | `monerium_deposit_allocations` (076) | N:M accounting join: the EURe portion and attributed net USDC for each deposit/execution pair | | `monerium_webhook_events` (069) | Durable persist-before-200 inbox for Monerium deliveries, dedup by event id, 30-day retention after processing | | `monerium_chain_cursors` (070) | Persisted block cursors for the mint watcher | @@ -389,8 +453,11 @@ reconciles the exact same-account unattributed mint into the provider order, inc when that order row already exists, without duplicating chain identity or allocations; provider onboarding calls are exactly-once (`financial_operations`) and their reads are bound to the configured profile and chain; a broadcast whose hash was lost is recovered -from its persisted nonce/block plus an exact transaction-and-event match rather than -re-sent; all per-account writes serialize on one advisory lock; and the client always has two exits +from its persisted nonce/block plus an exact transaction-and-event match (including the +persisted reference and route in the calldata) rather than re-sent; a swap the vault +could not cover, a reference that is unavailable or out of band, or a fill below the +floor is deferred by the keeper, never forced; all per-account writes serialize on one +advisory lock; and the client always has two exits that no operator failure can block — the fallback-address sweep and, past the trigger delay, permissionless swap execution. Full invariants and threat model: [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md). diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 6672afa4a..8dc2b1887 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -13,8 +13,12 @@ Ground rules that shape every procedure here: never move or redirect funds. There is no Vortex-side rescue path by design. - **Pauses never trap client funds.** `fallbackAddress` functions (`sweep`, `setDestination`, `setFallbackAddress`, `setClientPaused`) and the permissionless - dead-man sweep (`sweepStrandedEure`, after 60 days) work while paused. Do not promise - otherwise in comms. + dead-man sweep (`sweepStrandedEure`, after 7 days) work while paused. Do not promise + otherwise in comms — and remember the sweep makes 7 days the longest any Vortex-side + hold can last. +- **The subsidy vault holds Vortex money only.** It pays a client's fixed destination + up to the floor, within its caps, and withdraws only to the treasury; funding, limits + and pause are ordinary operations (§2.6), never a client-funds question. - **Never send raw EURe to a CEX destination.** EURe recovery targets are `fallbackAddress` only. - **Treat allocation migrations as forward-only after use.** Run migrations from one @@ -28,7 +32,8 @@ Deploy → manifest → verify → map → (automated: link + IBAN) → penny te One pass per client. Prerequisites: guardian key funded on the target chain; `MONERIUM_B2B_ENABLED=true` and the complete `MONERIUM_B2B_*` env set on the one `mykobo` keeper backend (including the trusted factory address, read/private RPCs, -webhook secret, and three keys); partner paperwork complete; the client +webhook secret, and three keys); the factory's subsidy vault deployed, pointed at and +funded (§2.6); partner paperwork complete; the client company onboarded and KYB-approved on Monerium's side (partner KYC reliance) with its Monerium profile UUID at hand; the partner configured as a managed-profile manager (`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type @@ -43,8 +48,9 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag - `fallbackAddress` — client's **self-custodied** recovery address. Mandatory, no exceptions (Monerium acceptance condition). Must be distinct from custodial/CEX addresses. -- `feeBps` — per-client; pilot `0`, GA starting point 15 bps (ADR B1). Adjustable - later via the guardian's timelocked setter. +- `targetPpm` / `floorPpm` — the client's fee policy in ppm below the reference rate; + launch policy 1250 / 1500 (12.5 / 15 bps, ADR B1). Adjustable later via the + guardian's timelocked `setFeePolicy` (raising either value waits 24 h). - Signed terms including the redemption-limitation disclosure (rollout doc, Terms §1). ### 1.2 Deploy the forwarder clone @@ -52,8 +58,8 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag ```bash # predict, then deploy (guardian-only); salt = any unused bytes32, convention: client index cast call $FACTORY "predictAddress(bytes32)(address)" $SALT --rpc-url $RPC -cast send $FACTORY "deployForwarder(address,address,uint16,bytes32)" \ - $DESTINATION $FALLBACK $FEE_BPS $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $FACTORY "deployForwarder(address,address,uint32,uint32,bytes32)" \ + $DESTINATION $FALLBACK $TARGET_PPM $FLOOR_PPM $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY ``` The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). @@ -149,9 +155,14 @@ cast send "setGuardianPaused(bool)" true --rpc-url $RPC --pri cast send $FACTORY "setGlobalPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY # Availability lever: reduce the per-swap cap (instant, bounded by immutables) cast send $FACTORY "setPerSwapCap(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY +# Route lever: disable a route whose pool went bad (indices are stable; the keeper re-quotes each cycle) +cast send $FACTORY "setRouteEnabled(uint256,bool)" false --rpc-url $RPC --private-key $GUARDIAN_KEY +# Subsidy lever: stop topping up (below-floor swaps then defer instead of executing) +cast send $VAULT "setPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY ``` -Both pauses block `swapAndForward` only; unpause = same call with `false`. +Both pauses block `swapAndForward` only; unpause = same call with `false`. Pausing the +vault pauses nothing on the forwarders: swaps that need no subsidy keep executing. ### 2.2 Monerium IBAN suspension ask @@ -200,19 +211,52 @@ Monerium-side links/IBANs against the DB for every account, treating the associa monitor's history as the timeline. Blast radius = deposit flow between the unauthorized change and suspension. +### 2.6 Subsidy vault operations + +One `VortexSubsidyVault` per factory, deployed once (USDC, the fee Safe as treasury, the +factory, launch limits 50 bps per swap and 200 USDC per day — ADR P13), then pointed at +by the factory and funded from the treasury. All guardian-key calls are ordinary +operations: the vault never holds client funds. + +```bash +# once: point the factory at the vault +cast send $FACTORY "setSubsidyVault(address)" $VAULT --rpc-url $RPC --private-key $GUARDIAN_KEY +# fund (from the treasury Safe): plain USDC transfer to $VAULT +# tune limits (instant) +cast send $VAULT "setMaxSubsidyPpm(uint32)" 5000 --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $VAULT "setDailyBudget(uint256)" 200000000 --rpc-url $RPC --private-key $GUARDIAN_KEY +# read runway +cast call $VAULT "dailyBudget()(uint256)" --rpc-url $RPC +cast call $VAULT "spentToday()(uint256)" --rpc-url $RPC +cast call $USDC "balanceOf(address)(uint256)" $VAULT --rpc-url $RPC +# return funds (treasury only — there is no other target) +cast send $VAULT "withdraw(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Sizing: at the €25k per-swap cap a worst-case top-up is about 135 USDC, so the 200 USDC +daily budget covers roughly one and a half such swaps per day across all clients before +the keeper starts deferring. Raise the budget or lower `perSwapCap` if deferrals become +routine; both are instant. + ## 3. Alert triage (monitoring log lines → action) Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium-b2b:`. | Log line contains | Meaning | Action | |---|---|---| -| `PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Executable depth below even minimum-size swaps; swaps would revert on minOut | Global pause (§2.1); investigate pool state (LP exit, depeg); consider lowering `perSwapCap`; re-run the liquidity-baseline methodology before unpausing | -| `executable depth below perSwapCap` | Cap-sized swaps would revert; availability, not fund risk | Lower `perSwapCap` or accept keeper retries; watch for escalation | +| `PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS on every route` | Executable depth below even minimum-size swaps on the best route; the keeper is deferring and the permissionless path would revert | Global pause (§2.1); investigate pool state (LP exit, depeg); whitelist a better route or lower `perSwapCap`; re-run the liquidity-baseline methodology before unpausing | +| `executable depth below perSwapCap` | Cap-sized swaps would land below the floor; availability, not fund risk | Lower `perSwapCap`, add a route, or accept deferrals; watch for escalation | +| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable — check egress), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (depeg — do not force), `no enabled swap route could be quoted` (§2.1 route lever) | Funds wait with the marker armed; after 24 h the permissionless path can execute unsubsidized, after 7 days the fallback sweep is live — communicate if the deferral persists | +| `SUBSIDY VAULT —` (error) | Vault paused or empty: every below-floor swap defers | §2.6: fund or unpause; check why it emptied (budget too high for the market?) | +| `subsidy vault ... refill before below-floor swaps start deferring` | Less than a day of budget left, or today's budget spent | §2.6 refill; consider the budget vs. observed spreads | +| `no subsidy vault is configured on the factory` | `setSubsidyVault` never ran; below-floor swaps defer | §2.6 | +| `route ... could not be quoted` | One whitelisted route's pool is unquotable (drained, removed) | Disable it (§2.1) so the keeper stops trying; keep at least one healthy route | | `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, address linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | | `stranded EURe on forwarder` (warn ≥12h) | Keeper is not converting | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts) | -| `stranded EURe ... past TRIGGER_DELAY` | Permissionless trigger now live; SLA long broken | Escalate the keeper outage; anyone may call `swapAndForward()` (same policy applies); communicate the delay | +| `stranded EURe ... past TRIGGER_DELAY` | Permissionless trigger now live; SLA long broken (keeper outage or a persistent deferral) | Escalate; anyone may call `swapAndForward(reference, route)` — that path prices against Chainlink and pays no subsidy; communicate the delay | +| `dead-man sweep to the fallback possible in` / `is live` | The balance has waited close to or past the 7 day sweep delay; anyone can move it to the client's fallback | Decide with the partner whether to convert now (unpause / fund the vault) or let the sweep return the EURe; tell the client either way | | `untrusted factory` / `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on trusted factory` | Should-be-impossible state | Full incident: global pause, verify `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, run the manifest verifier, compare against manifest history | -| `reconciled owner-authorized config change` | Client rotated destination/fallback, or a guardian fee change applied — expected, DB updated | No incident. Unexpected destination change → confirm with the partner; a surprise suggests a compromised fallback key (client should `setClientPaused(true)` and rotate) | +| `reconciled owner-authorized config change` | Client rotated destination/fallback, or a guardian fee-policy change applied — expected, DB updated | No incident. Unexpected destination change → confirm with the partner; a surprise suggests a compromised fallback key (client should `setClientPaused(true)` and rotate) | | `onboarding advance failed` (repeating for one account) | Link/IBAN automation stuck | Check the `financial_operations` row: `failed` retries itself; `unknown` needs manual reconciliation (compare Monerium-side state, then update the row) | | `delivery ... abandoned after N attempts` | Partner webhook endpoint down > backoff horizon | Contact partner; deliveries are not retried after abandonment — partner should poll `GET /v1/monerium-b2b/deposits` to catch up | | `MONERIUM_B2B_PRIVATE_RPC_URL is not set` | Keeper writes in the public mempool | Set the private orderflow RPC (operational finding on mainnet) | @@ -227,7 +271,10 @@ address the client no longer controls. The gate converts that silent loss into a (`setGuardianPaused(true)` with the guardian key; log-only if the key is unset) and `dormant_since` is recorded; the conversion executor skips it (the stranding marker still arms — the dead-man sweep clock is unaffected). EURe arriving during dormancy -accumulates safely; past the sweep delay it flows to `fallbackAddress` automatically. +accumulates safely; past the sweep delay (7 days, registry P3) anyone can move it to +`fallbackAddress` — so a deposit into a dormant account gives the partner about a week +to re-confirm before the money goes to the client's self-custody address instead of +being converted. **Re-confirmation (manual, via partner):** partner re-confirms in writing that the destination is valid and client-controlled (ADR B5). If the destination changed, the @@ -273,8 +320,8 @@ the IBAN's current default address; the old clone stays linked but inert. | Key | Blast radius | Response | |---|---|---| | Attestor | Can link addresses to profiles; never move funds (recovery payouts go only to the client's own bank account) | Rotate key; new forwarders need a new implementation (ATTESTOR is immutable); existing links unaffected | -| Keeper | `poke`/`swapAndForward` only (policy-constrained); worst case gas theft | Rotate; `setKeeper(old,false)` + `setKeeper(new,true)`; refund gas | -| Guardian | Pause/unpause, bounded params, timelocked fee — delay-only griefing | Two-step `transferGuardian`/`acceptGuardian`; audit pause + pending-fee state after | +| Keeper | `poke`/`swapAndForward` only; can pick any whitelisted route and any reference inside the Chainlink band — worst case the fee reaches the 1% cap or the vault pays up to its caps, plus gas theft; never a redirect | Rotate; `setKeeper(old,false)` + `setKeeper(new,true)`; pause the vault while rotating; reconcile executions against Coinbase history; refund gas | +| Guardian | Pause/unpause, bounded params, timelocked fee policy, route whitelist (validated), vault limits and withdrawal to treasury — delay-only griefing plus Vortex-money exposure | Two-step `transferGuardian`/`acceptGuardian`; audit pause, pending-policy, route and vault state after | | Whitelabel API credentials | Control-plane: can re-link/move IBANs (future mints only) — S1 | §2.5 full sequence | | `ADMIN_SECRET` | Map/suspend accounts (mapping is bounded by on-chain clone verification) | Rotate; audit recent admin mutations | | Webhook HMAC secret | Fabricated inbound order events (accounting noise; forward-only lattice + mint watcher bound the damage) | Rotate at both ends; reconcile deposits against chain | @@ -321,9 +368,11 @@ The reference deposit transferred 25 EURe to the forwarder in transaction `0x727a53eb525e5851d8db38ea99c2f39633b6213de5757639d82e6c112e49079a`. The live keeper confirmed conversion transaction `0xb52f38073c41b5e8d2f89deab5c2b8536362acfd97903579113630fc02b58eb4`, -consumed the full 25 EURe, and forwarded `29.012924` USDC with a zero fee. These -addresses and hashes are evidence from that ephemeral run, not deployment pins; use the -receipts and addresses produced by each new run. +consumed the full 25 EURe, and forwarded `29.012924` USDC with a zero fee. That run +predates the reference-priced fee bands; a new run records a reference, a route, and a +fee or subsidy per the bands instead of a flat zero fee. These addresses and hashes are +evidence from that ephemeral run, not deployment pins; use the receipts and addresses +produced by each new run. ### 7.2 Start an archive-backed fork @@ -411,12 +460,12 @@ fixtures: | Constructor field | Value | |---|---:| | `MAX_ORACLE_AGE` | 52 hours | -| `SLIPPAGE_BPS` | 100 | -| `MAX_FEE_BPS` | 100 | -| `SWEEP_DELAY` | 60 days | +| `SLIPPAGE_BPS` | 40 (on the client's net after fee and subsidy) | +| `MAX_FEE_PPM` | 10000 | +| `MAX_REFERENCE_DEVIATION_BPS` | 100 | +| `SWEEP_DELAY` | 7 days | | `TRIGGER_DELAY` | 24 hours | -| `POOL_FEE_EURE_EURC` | 500 | -| `POOL_FEE_EURC_USDC` | 500 | +| Initial route | EURe → EURC → USDC, 500 / 500 (packed path constructor argument) | | `RECOVERY_HASH` | `bytes32(0)` | | `MIN_SWAP_FLOOR` | `25e18` | | `CAP_CEILING` | `50000e18` | @@ -434,9 +483,16 @@ cast send "$FACTORY" "setMinSwapAmount(uint256)" 25000000000000000000 \ --private-key "$GUARDIAN_KEY" --rpc-url http://127.0.0.1:8545 ``` -Deploy a zero-fee client clone as in §1.2. Use a fresh salt and record the predicted -address and receipt. Read back `destination()`, `fallbackAddress()`, `feeBps()`, and -`FACTORY()`, then require `factory.isForwarder(forwarder) == true` before continuing. +Deploy `VortexSubsidyVault` (USDC, account 3 as treasury, the factory, 5000 ppm, 200e6) +and point the factory at it with `setSubsidyVault`. Fund it with USDC from an +impersonated mainnet holder if you want to exercise a below-floor top-up; left empty, +a below-floor fill makes the keeper defer, which is also a valid outcome to observe. + +Deploy a client clone with the launch policy (1250 / 1500) as in §1.2. Use a fresh salt +and record the predicted address and receipt. Read back `destination()`, +`fallbackAddress()`, `targetPpm()`, `floorPpm()`, and `FACTORY()`, then require +`factory.isForwarder(forwarder) == true` before continuing. The keeper fetches the live +Coinbase ticker before each swap, so the backend needs outbound HTTPS during the run. ### 7.4 Create the local account fixture @@ -531,7 +587,8 @@ SELECT monerium_order_id, amount_raw, status, tx_hash, log_index, block_number FROM monerium_fiat_deposits WHERE account_id = ''; -SELECT eure_in_raw, usdc_gross_raw, fee_raw, usdc_net_raw, destination, +SELECT eure_in_raw, usdc_gross_raw, fee_raw, subsidy_raw, usdc_net_raw, destination, + reference_rate_raw, reference_source, reference_trade_id, route_index, tx_hash, nonce, broadcast_block_number, block_number, swap_log_index, status, error FROM monerium_conversion_executions WHERE account_id = ''; @@ -548,8 +605,12 @@ Required results: - One `minted` deposit with an `unattr:` order id and the real transfer hash and log index. - One allocation joining that deposit and execution with the 25 EURe input and the attributed net USDC. -- One `confirmed` execution with the 25 EURe input, zero fee, non-null - nonce/hash/block/swap-log-index, destination matching the clone, and `error IS NULL`. +- One `confirmed` execution with the 25 EURe input, a recorded reference (rate, source, + trade id) and route index 0, a fee or subsidy consistent with the fill's position + against the reference bands (`usdc_net_raw = usdc_gross_raw - fee_raw + + subsidy_raw`), non-null nonce/hash/block/swap-log-index, destination matching the + clone, and `error IS NULL`. If the vault was left empty and the fill sat below the + floor, expect a `deferring conversion` log line and no execution row instead. - The forwarder's EURe balance is zero. - The destination's USDC balance increased by `usdc_net_raw`. - The conversion receipt contains `SwapExecuted` from the clone and a USDC `Transfer` @@ -572,11 +633,12 @@ Required results: - The executor's durable path leaves a confirmed execution with its nonce, transaction hash, block number, swap log index, amounts, and destination recorded; allocation is added only after the mint cursor covers that execution block. -- The real contract accepts the current Chainlink EUR/USD answer and swaps successfully - through the pinned EURe -> EURC -> USDC 5-bps Uniswap V3 path. +- The real contract accepts the current Chainlink EUR/USD answer, the keeper's live + Coinbase reference inside the band, and swaps successfully through whitelisted route 0 + (EURe -> EURC -> USDC on the 5-bps tiers). - Keeper authorization, the 25 EURe minimum, allowance reset, full EURe consumption, - zero-fee accounting, and forwarding to the immutable per-client destination work - together. + fee-band accounting against the recorded reference, and forwarding to the immutable + per-client destination work together. - Cursor-gated snapshot allocation links the observed deposit to the confirmed execution at the exact `SwapExecuted` log boundary and assigns the full USDC output. @@ -598,9 +660,10 @@ Required results: run. - It does not test out-of-bounds factory parameters or prove their rejection; the run deploys only the canonical valid parameter set. -- It does not test fees above zero, fee-increase timelocks, per-swap-cap batching, - sub-minimum accumulation, pause controls, dormancy, permissionless triggering, - stranded-fund sweeping, fallback-key recovery, or client config rotation. +- It does not test fee-policy timelocks, route selection among several routes, a funded + vault's top-up (unless you fund it), the reference band rejection, per-swap-cap + batching, sub-minimum accumulation, pause controls, dormancy, permissionless + triggering, stranded-fund sweeping, fallback-key recovery, or client config rotation. - It does not test stale/invalid oracle answers, insufficient liquidity, excess price impact, slippage reverts, router failure, token transfer failure, or depeg behavior. - It does not test reorg replacement, duplicate-log replay, concurrent executors, From edc687e9d024dddcf77efcad671c37be876e4fe6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 18:31:07 +0200 Subject: [PATCH 15/44] docs(repo): fold the fee and subsidy plan into the maintained docs --- .../monerium-b2b/account-provisioning.ts | 2 +- .../monerium-b2b/conversion-executor.ts | 2 +- .../api/services/monerium-b2b/monitoring.ts | 2 +- .../services/monerium-b2b/reference-rate.ts | 2 +- .../migrations/078-monerium-fee-policy-ppm.ts | 2 +- ...79-add-conversion-reference-and-subsidy.ts | 2 +- .../src/VortexForwarder.sol | 2 +- .../src/VortexSubsidyVault.sol | 2 +- docs/README.md | 1 - docs/operations-monerium-b2b-runbook.md | 3 +- ...proposal-monerium-forwarder-fee-subsidy.md | 114 ------------------ .../05-integrations/monerium-b2b.md | 2 +- .../shared/src/endpoints/webhook.endpoints.ts | 2 +- 13 files changed, 12 insertions(+), 126 deletions(-) delete mode 100644 docs/proposal-monerium-forwarder-fee-subsidy.md diff --git a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts index 2d75d2bae..34aa9df4a 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -51,7 +51,7 @@ function normalizeAddress(value: string, name: string): string { return value.trim().toLowerCase(); } -/** Launch fee policy (docs/proposal-monerium-forwarder-fee-subsidy.md): 12.5 bps target, 15 bps floor. */ +/** Launch fee policy (docs/adr-0005-monerium-b2b-onramp.md, B1): 12.5 bps target, 15 bps floor. */ export const DEFAULT_TARGET_PPM = 1_250; export const DEFAULT_FLOOR_PPM = 1_500; const MAX_FEE_PPM = 10_000; 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 cd39b7a08..bb258c16b 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -694,7 +694,7 @@ async function quoteRoutes( /** * Reference, route and projection for a swap of `amountIn` - * (docs/proposal-monerium-forwarder-fee-subsidy.md, phase 2). Outside Ethereum mainnet + * (docs/architecture-monerium-b2b-onramp.md, fees section). 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. */ diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 791e62ecb..ee113cab5 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -34,7 +34,7 @@ import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./moneri * converted long before either — and within SWEEP_IMMINENT_MS of SWEEP_DELAY the * error says so: the dead-man sweep to the fallback is about to become possible. * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault - * (docs/proposal-monerium-forwarder-fee-subsidy.md); a vault that cannot cover a + * (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. * 3. Association monitor (S1 detective control, trust model in the b2b-variant doc): * re-reads the linked-address and IBAN state from the Monerium API per active diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.ts index a54c0b3e7..fa9ac3368 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -2,7 +2,7 @@ import { parseUnits } from "viem"; /** * Partner reference rate for the forwarder fee bands - * (docs/proposal-monerium-forwarder-fee-subsidy.md): the Coinbase Exchange EURC-USD + * (docs/adr-0005-monerium-b2b-onramp.md, P12): the Coinbase Exchange EURC-USD * ticker, fetched fresh before every swap and recorded on the execution row so the * partner can audit each swap against the public trade history. The keeper passes the * rate into swapAndForward; the contract rejects it outside its Chainlink band. diff --git a/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts b/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts index 14c4fa541..07bd7ab99 100644 --- a/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts +++ b/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts @@ -1,7 +1,7 @@ import { DataTypes, QueryInterface } from "sequelize"; // The forwarder prices swaps against a reference rate with a per-clone target and floor -// in parts per million (docs/proposal-monerium-forwarder-fee-subsidy.md). The flat +// in parts per million (docs/adr-0005-monerium-b2b-onramp.md, B1/P11). The flat // fee_bps mirror is replaced by both policy values; defaults are the agreed launch policy. export async function up(queryInterface: QueryInterface): Promise { await queryInterface.addColumn("monerium_accounts", "target_ppm", { diff --git a/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts index 3e5abcd8f..a79dedd63 100644 --- a/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts +++ b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts @@ -1,7 +1,7 @@ import { DataTypes, QueryInterface } from "sequelize"; // Every swap is priced against a partner reference rate and may draw a subsidy from the -// vault (docs/proposal-monerium-forwarder-fee-subsidy.md). The reference and the chosen +// vault (docs/architecture-monerium-b2b-onramp.md, fees section). The reference and the chosen // route are persisted before broadcast (crash-recovery calldata identity + audit // trail); the subsidy is recorded from the SwapExecuted event on confirmation. export async function up(queryInterface: QueryInterface): Promise { diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index f147d8a80..d30cc3c2b 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -397,7 +397,7 @@ contract VortexForwarder { if (eureBefore - EURE.balanceOf(address(this)) > amountIn) revert Overspend(); } - /// @dev Applies the fee bands (docs/proposal-monerium-forwarder-fee-subsidy.md): + /// @dev Applies the fee bands (docs/architecture-monerium-b2b-onramp.md, "Fees, reference rate and subsidy"): /// - fill above reference x (1 - targetPpm): the surplus is the fee, <= MAX_FEE_PPM; /// - fill between the floor and the target: no fee, no subsidy; /// - fill below reference x (1 - floorPpm): a privileged swap draws the shortfall diff --git a/contracts/monerium-forwarder/src/VortexSubsidyVault.sol b/contracts/monerium-forwarder/src/VortexSubsidyVault.sol index f6eb0aee3..513a1cc1d 100644 --- a/contracts/monerium-forwarder/src/VortexSubsidyVault.sol +++ b/contracts/monerium-forwarder/src/VortexSubsidyVault.sol @@ -5,7 +5,7 @@ import {IERC20, IVortexForwarderFactory} from "./VortexForwarder.sol"; /// @title VortexSubsidyVault /// @notice Treasury-funded USDC pool that tops a forwarder swap up to the client's floor -/// rate (docs/proposal-monerium-forwarder-fee-subsidy.md). Only factory-registered +/// rate (docs/architecture-monerium-b2b-onramp.md, "Fees, reference rate and subsidy"). Only factory-registered /// clones can draw; every draw is bounded by a per-swap cap (ppm of the swap's /// reference value) and a daily budget; withdrawals can only go back to the /// treasury. The vault never holds client funds — it only ever pushes Vortex diff --git a/docs/README.md b/docs/README.md index 9fc651352..b767fa270 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,6 @@ The smaller set of general project documents stays directly in `docs/`: | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | | [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md) | Phase-2 proposal for the consumer (Safe + passkey) Monerium onramp; the B2B variant shipped | -| [`proposal-monerium-forwarder-fee-subsidy.md`](proposal-monerium-forwarder-fee-subsidy.md) | Implementation plan for reference-priced fee bands, the subsidy vault, configurable swap routes and the 7 day sweep in the B2B forwarder | | [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | | [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 8dc2b1887..0c40bd432 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -509,7 +509,8 @@ managed-profile manager: "forwarderAddress": "", "destination": "", "fallbackAddress": "", - "feeBps": 0 + "targetPpm": 1250, + "floorPpm": 1500 } ``` diff --git a/docs/proposal-monerium-forwarder-fee-subsidy.md b/docs/proposal-monerium-forwarder-fee-subsidy.md deleted file mode 100644 index d93676f61..000000000 --- a/docs/proposal-monerium-forwarder-fee-subsidy.md +++ /dev/null @@ -1,114 +0,0 @@ -# Proposal: reference-priced fees and subsidies for the Monerium B2B forwarder - -Status: implementation plan, agreed 2026-09-15. The forwarder contracts are not deployed, -so every change below lands in the current contract before launch; no clone migration is -involved. Parameter decisions fold into `adr-0005-monerium-b2b-onramp.md` once shipped. - -## Context - -The partner agreement fixes the client's rate against a reference: the client receives -the Coinbase EURC-USD reference minus 12.5 bps, and never worse than 15 bps below it. -Today `feeBps` is a flat skim on whatever the DEX returns, and nothing tops a poor fill -up. This proposal moves the fee to a band model enforced on chain, adds a treasury-funded -subsidy vault, makes the swap route configurable within bounds, and records three -decisions taken alongside: no payment bouncing, no guardian-triggered fallback sweep, and -a 7 day dead-man sweep. - -## Decisions - -- **No bouncing.** Returning EURe to a bank account is a Monerium redeem order, which - needs the forwarder to approve the payout message via EIP-1271. The forwarder approves - exactly one message, the link message, so no key can move funds to fiat. Adding a - redeem path would let whoever holds the whitelabel credentials plus the attestor key - drain every clone to an arbitrary IBAN. Liquidity problems are handled by waiting, - lowering `perSwapCap`, and Monerium's own recovery for compliance cases. -- **No guardian sweep to the fallback address.** Only the client (via `sweep`) and the - permissionless dead-man sweep move EURe to the fallback. A guardian shortcut would - weaken "Vortex keys cannot move client funds". -- **Dead-man sweep delay (P3) becomes 7 days.** Because the sweep ignores pauses, 7 days - is also the longest any Vortex-side hold can last; token-level freezes are Monerium's - lever. Dormancy (P5) stays at 60 days; the two windows are independent. -- **Accepted limitation.** After the 24 h permissionless trigger anyone may execute the - swap. That path prices against Chainlink and pays no subsidy, so a forced swap can land - below the 15 bps floor. The rate guarantee applies to keeper-executed swaps; the partner - terms say so. - -## Parameters - -| Parameter | Decision | -|---|---| -| Fee unit | parts per million (ppm) | -| Target, per clone | 1250 ppm; increases behind the 24 h timelock | -| Floor, per clone | 1500 ppm; same timelock; floor >= target | -| Max fee and max floor, immutable | 10000 ppm | -| Reference source | Coinbase Exchange EURC-USD ticker, fetched by the keeper per swap; price, timestamp and trade id stored per execution | -| Reference band vs Chainlink, immutable | 100 bps (to confirm; must survive weekend Chainlink staleness) | -| Oracle floor, immutable | 40 bps on the client's net after fee and subsidy; router minimum set to zero, the post-condition is the guard | -| Max subsidy per swap | guardian-settable on the vault, default 50 bps of the reference value | -| Daily subsidy budget | guardian-settable on the vault, default 200 USDC, UTC day bucket; one vault shared by all clones | -| Vault cannot cover | `pay` reverts, the whole swap reverts, funds wait, alert | -| Vault withdrawals | treasury only, immutable | -| Swap routes | guardian-managed whitelist on the factory, validated on chain, caller-selected index, no timelock | -| Route validation | hop tokens only EURe, EURC, USDC; Uniswap tiers 100/500/3000/10000; at most two hops; immutable router | -| Sweep delay (P3) | 7 days | -| Dormancy window (P5) | 60 days, unchanged | - -Known tuning consequence: at the EUR 25k per-swap cap a worst-case subsidy is about -135 USDC, so the 200 USDC budget covers roughly one and a half such swaps per day across -all clients before the executor starts deferring. Both limits are live-adjustable. - -## Contract design - -Per swap the caller passes a reference rate (Chainlink decimals) and a route index. - -1. Chainlink price is read with the existing staleness and sign checks. A privileged - caller's reference must lie within `MAX_REFERENCE_DEVIATION_BPS` of it; a - permissionless caller's argument is ignored and Chainlink is the reference. -2. The whitelisted route is executed through the immutable router with a zero minimum - output. Atomic delta checks stay. -3. With `referenceOut = amountIn x reference`, `targetOut = referenceOut x (1 - target)` - and `floorOut = referenceOut x (1 - floor)`: - - output above `targetOut`: fee = output - targetOut, capped at `MAX_FEE_PPM`; - - output between `floorOut` and `targetOut`: no fee, no subsidy; - - output below `floorOut`: no fee; a privileged swap pulls `floorOut - output` from the - vault straight to the destination, a permissionless swap pays nothing. -4. Post-condition: output - fee + subsidy >= Chainlink value x (1 - `SLIPPAGE_BPS`), - otherwise revert. This catches a bad reference or an EURC depeg that a subsidy would - otherwise paper over. -5. `SwapExecuted` carries reference, route index, fee, subsidy and forwarded amount. - -One subsidy vault, shared by every clone, holds treasury-funded USDC, pays only when -called by a factory-registered clone, enforces the per-swap cap and the daily budget, can be paused, -and can be withdrawn only to the treasury. The factory holds the vault address and the -route list, both guardian-managed with events. - -The subsidy widens the band a sandwich attacker can exploit from the floor to floor plus -the per-swap cap, paid by the vault. Keeper swaps keep going through the private relay; -the permissionless path has no subsidy and keeps the plain floor. - -## Phases - -1. **Contracts.** Vault; factory route whitelist and vault hook; forwarder fee policy in - ppm with shared timelock, reference and route arguments, band check, fee bands, vault - call, post-subsidy floor, extended event; 7 day sweep delay in fixtures; unit, invariant - and fork suites; manifest scripts. -2. **Backend.** Reference module (Coinbase ticker, stored per execution; no reference - means no swap). Executor quotes every whitelisted route, projects fee and subsidy, - defers while the projected subsidy exceeds the per-swap cap or the remaining budget, - passes reference and index over the private relay, parses the new event. Execution - rows gain reference, route and subsidy; net becomes gross - fee + subsidy. Monitoring: - vault runway, reference divergence, per-route quotes, stranding warning before the - sweep. Provisioning takes ppm inputs. Converted event and deposits endpoint expose - fee, subsidy and reference. Later: a minimum deposit age before budget may be spent. -3. **Docs.** ADR-0005 dated amendment (fee model, vault, reference, routes, P3, the - decisions above); narrow "new routes need a new implementation" to new tokens or a - new router; security spec pinned-path invariant becomes a validated route set with - the floor as the bound, plus the sandwich-band note; architecture fees section, - runbook, rollout terms, API docs. -4. **Rollout.** Sepolia validation with a funded vault and two routes, then mainnet - deployment of implementation, factory and vault, vault funding, client onboarding. - -## Open items - -- Reference band value against Chainlink. -- Pacing rule for subsidies when many clients ramp at once (backend, later). diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 22483505c..91a18e414 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -41,7 +41,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, execution slot check/creation, finalization, R04 allocation) 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. 5. **Attribution is N:M, cursor-gated, exact-snapshot, and idempotent (R04)** — a confirmed execution records the block and block-global `SwapExecuted` log index but is not allocated immediately. Reconciliation starts only after the persisted mint cursor has processed that block, then consumes outstanding portions of deposits minted in earlier blocks or earlier log positions in the same block, oldest-first up to `eureInRaw`. This covers a mint that lands between the executor's balance read and swap without assigning a later same-block mint to the execution. A cap-cut deposit receives a partial `monerium_deposit_allocations` row and its remainder participates in the next execution; one execution may likewise allocate across many deposits. Each row records its EURe portion and proportional net USDC; execution net is computed as `usdcOut - fee + subsidy` (the vault pays the subsidy straight to the destination), never the event's `forwarded` full-balance sweep, so pre-existing unsolicited USDC is not misreported as this deposit's yield. Floor dust goes to the largest allocation only when indexed deposits cover the whole execution, so missing inflow cannot inflate a customer's share. Mint identity is `(chain_id, tx_hash, log_index)` and the watcher scans 12-deep blocks. Only chain-indexed deposits make an account a conversion candidate; a raw forwarder balance never bypasses the watcher. Non-Monerium inflows become `unattr:` rows and never surface as customer claims. 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, 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 or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. -7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (docs/proposal-monerium-forwarder-fee-subsidy.md) — before every swap the keeper fetches the Coinbase Exchange EURC-USD ticker (`reference-rate.ts`), records price, time and trade id on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. 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 fetches the Coinbase Exchange EURC-USD ticker (`reference-rate.ts`), records price, time and trade id on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. 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. ## Monitoring diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 9727777da..179d95af5 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -101,7 +101,7 @@ export interface DepositReceivedWebhookPayload { } /** - * How a whole execution was priced (docs/proposal-monerium-forwarder-fee-subsidy.md): + * How a whole execution was priced (docs/architecture-monerium-b2b-onramp.md, fees section): * the partner reference it was settled against, the fee Vortex took above the target * band, and the subsidy the vault paid to reach the floor. Totals for the execution, * not per deposit; a deposit's own share is its `usdcNetRaw`. From b2301f594582ed4215d3c926534777740df8bb21 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 19:27:59 +0200 Subject: [PATCH 16/44] feat(api): price swaps against a five-minute coinbase vwap instead of the last tick --- .../monerium-b2b/conversion-executor.ts | 2 +- .../monerium-b2b/reference-rate.test.ts | 104 ++++++++++--- .../services/monerium-b2b/reference-rate.ts | 141 +++++++++++++----- ...79-add-conversion-reference-and-subsidy.ts | 15 +- .../moneriumConversionExecution.model.ts | 13 +- 5 files changed, 199 insertions(+), 76 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index bb258c16b..ffdeada1e 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -856,7 +856,7 @@ export async function runConversionExecutor(accountId: string): Promise { referenceAt: plan.reference.time, referenceRateRaw: plan.reference.rateRaw.toString(), referenceSource: plan.reference.source, - referenceTradeId: plan.reference.tradeId, + referenceWindowSeconds: plan.reference.windowSeconds, routeIndex: plan.routeIndex }, { transaction } diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts index ed4bdb8d4..ee5b54021 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts @@ -1,23 +1,73 @@ import { describe, expect, it } from "bun:test"; import { - COINBASE_EURC_TICKER_URL, + Candle, + COINBASE_EURC_CANDLES_URL, COINBASE_REFERENCE_SOURCE, + computeWindowVwap, fetchCoinbaseReference, isWithinReferenceBand, - toReferenceRateRaw + parseCandles, + REFERENCE_FALLBACK_WINDOW_SECONDS, + REFERENCE_WINDOW_SECONDS, + selectReferenceWindow } from "./reference-rate"; -describe("toReferenceRateRaw", () => { - it("scales a decimal price to the oracle's decimals", () => { - expect(toReferenceRateRaw("1.14", 8)).toBe(114_000_000n); - expect(toReferenceRateRaw("1", 8)).toBe(100_000_000n); - expect(toReferenceRateRaw("0.98765432", 8)).toBe(98_765_432n); +const END = 1_800_000_000; // window end, a minute boundary +const DECIMALS = 8; + +/** A flat candle: low = high = close, so its typical price is `price`. */ +function candle(bucketStart: number, price: number, volume: number): Candle { + return [bucketStart, price, price, price, price, volume]; +} + +describe("computeWindowVwap", () => { + it("weights each candle's typical price by its volume", () => { + const candles = [candle(END - 60, 1.14, 10), candle(END - 120, 1.16, 30)]; + // (1.14 x 10 + 1.16 x 30) / 40 = 1.155 + expect(computeWindowVwap(candles, END, REFERENCE_WINDOW_SECONDS, DECIMALS)).toBe(115_500_000n); }); - it("rejects malformed or non-positive prices", () => { - for (const bad of ["", "abc", "-1.1", "1e5", "0", "0.0"]) { - expect(() => toReferenceRateRaw(bad, 8)).toThrow(); - } + it("uses (low + high + close) / 3 as the candle price", () => { + const skewed: Candle = [END - 60, 1.14, 1.15, 1.2, 1.145, 5]; + expect(computeWindowVwap([skewed], END, REFERENCE_WINDOW_SECONDS, DECIMALS)).toBe(114_500_000n); + }); + + it("ignores candles outside the window and returns null without volume", () => { + const candles = [candle(END - 360, 2.0, 100), candle(END, 3.0, 100), candle(END - 60, 1.14, 0)]; + expect(computeWindowVwap(candles, END, REFERENCE_WINDOW_SECONDS, DECIMALS)).toBeNull(); + expect(computeWindowVwap(candles, END, 3_600, DECIMALS)).toBe(200_000_000n); + }); + + it("rejects negative or non-finite candle values", () => { + expect(() => computeWindowVwap([candle(END - 60, -1, 1)], END, 300, DECIMALS)).toThrow(); + expect(() => computeWindowVwap([candle(END - 60, Number.NaN, 1)], END, 300, DECIMALS)).toThrow(); + }); +}); + +describe("selectReferenceWindow", () => { + it("prefers the five-minute window and widens to an hour only when it has no volume", () => { + const busy = [candle(END - 60, 1.14, 10), candle(END - 1_800, 1.5, 100)]; + expect(selectReferenceWindow(busy, END, DECIMALS)).toEqual({ + rateRaw: 114_000_000n, + windowSeconds: REFERENCE_WINDOW_SECONDS + }); + + const quiet = [candle(END - 60, 1.14, 0), candle(END - 1_800, 1.5, 100)]; + expect(selectReferenceWindow(quiet, END, DECIMALS)).toEqual({ + rateRaw: 150_000_000n, + windowSeconds: REFERENCE_FALLBACK_WINDOW_SECONDS + }); + + expect(selectReferenceWindow([candle(END - 60, 1.14, 0)], END, DECIMALS)).toBeNull(); + }); +}); + +describe("parseCandles", () => { + it("accepts Coinbase's array-of-arrays shape and rejects anything else", () => { + expect(parseCandles([[END, 1, 2, 1.5, 1.8, 3]])).toEqual([[END, 1, 2, 1.5, 1.8, 3]]); + expect(() => parseCandles({ candles: [] })).toThrow("not an array"); + expect(() => parseCandles([[END, 1, 2]])).toThrow("malformed"); + expect(() => parseCandles([[END, "1", 2, 1.5, 1.8, 3]])).toThrow("malformed"); }); }); @@ -38,6 +88,8 @@ describe("isWithinReferenceBand", () => { }); describe("fetchCoinbaseReference", () => { + const nowMs = (END - 30) * 1000; // half a minute into the bucket that ends at END + function fakeFetch(status: number, body: unknown) { const calls: string[] = []; const fetchImpl = async (url: string) => { @@ -47,25 +99,29 @@ describe("fetchCoinbaseReference", () => { return { calls, fetchImpl }; } - it("parses the ticker into a scaled, timestamped, attributable quote", async () => { - const { calls, fetchImpl } = fakeFetch(200, { price: "1.1432", time: "2026-09-15T10:00:00.123456Z", trade_id: 4711 }); - const quote = await fetchCoinbaseReference(8, fetchImpl); - expect(calls).toEqual([COINBASE_EURC_TICKER_URL]); - expect(quote).toMatchObject({ + it("requests an hour of one-minute candles and returns the five-minute VWAP with its window", async () => { + const { calls, fetchImpl } = fakeFetch(200, [candle(END - 60, 1.1432, 10), candle(END - 120, 1.1432, 10)]); + const quote = await fetchCoinbaseReference(DECIMALS, fetchImpl, nowMs); + expect(calls).toHaveLength(1); + expect(calls[0].startsWith(`${COINBASE_EURC_CANDLES_URL}?granularity=60&start=`)).toBe(true); + expect(calls[0]).toContain(`start=${new Date((END - REFERENCE_FALLBACK_WINDOW_SECONDS) * 1000).toISOString()}`); + expect(calls[0]).toContain(`end=${new Date(nowMs).toISOString()}`); + expect(quote).toEqual({ price: "1.1432", rateRaw: 114_320_000n, source: COINBASE_REFERENCE_SOURCE, - tradeId: "4711" + time: new Date(nowMs), + windowSeconds: REFERENCE_WINDOW_SECONDS }); - expect(quote.time.toISOString()).toBe("2026-09-15T10:00:00.123Z"); }); - it("fails on a non-2xx response or a body without a usable price", async () => { - await expect(fetchCoinbaseReference(8, fakeFetch(503, {}).fetchImpl)).rejects.toThrow("503"); - await expect(fetchCoinbaseReference(8, fakeFetch(200, { price: 1.14 }).fetchImpl)).rejects.toThrow("no price"); - await expect(fetchCoinbaseReference(8, fakeFetch(200, { price: "0" }).fetchImpl)).rejects.toThrow("positive"); - await expect(fetchCoinbaseReference(8, fakeFetch(200, { price: "1.14", time: "soon" }).fetchImpl)).rejects.toThrow( - "timestamp" + it("fails on a non-2xx response, a malformed body, or an hour without volume", async () => { + await expect(fetchCoinbaseReference(DECIMALS, fakeFetch(503, []).fetchImpl, nowMs)).rejects.toThrow("503"); + await expect(fetchCoinbaseReference(DECIMALS, fakeFetch(200, { price: "1.14" }).fetchImpl, nowMs)).rejects.toThrow( + "not an array" ); + await expect( + fetchCoinbaseReference(DECIMALS, fakeFetch(200, [candle(END - 60, 1.14, 0)]).fetchImpl, nowMs) + ).rejects.toThrow("no EURC-USD volume"); }); }); diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.ts index fa9ac3368..7fe5d8786 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -1,38 +1,39 @@ -import { parseUnits } from "viem"; +import { formatUnits, parseUnits } from "viem"; /** - * Partner reference rate for the forwarder fee bands - * (docs/adr-0005-monerium-b2b-onramp.md, P12): the Coinbase Exchange EURC-USD - * ticker, fetched fresh before every swap and recorded on the execution row so the - * partner can audit each swap against the public trade history. The keeper passes the + * Partner reference rate for the forwarder fee bands (docs/adr-0005-monerium-b2b-onramp.md, P12): + * a volume-weighted average price over the last five minutes of Coinbase Exchange + * EURC-USD one-minute candles, computed fresh before every swap and recorded on the + * execution row (rate, window, time) so the partner can recompute it from Coinbase's + * public candle history. Averaging instead of taking the last tick keeps a single thin + * print — common on weekends and outside business hours — from becoming the reference. + * When the five-minute window carries no volume the window widens to an hour; with no + * volume in an hour there is no reference and the keeper defers. The keeper passes the * rate into swapAndForward; the contract rejects it outside its Chainlink band. */ -export const COINBASE_EURC_TICKER_URL = "https://api.exchange.coinbase.com/products/EURC-USD/ticker"; -export const COINBASE_REFERENCE_SOURCE = "coinbase-exchange:EURC-USD"; +export const COINBASE_EURC_CANDLES_URL = "https://api.exchange.coinbase.com/products/EURC-USD/candles"; +export const COINBASE_REFERENCE_SOURCE = "coinbase-exchange:EURC-USD:vwap"; +export const REFERENCE_WINDOW_SECONDS = 5 * 60; +export const REFERENCE_FALLBACK_WINDOW_SECONDS = 60 * 60; +const CANDLE_GRANULARITY_SECONDS = 60; const FETCH_TIMEOUT_MS = 5_000; +/** Coinbase candle volumes carry up to eight decimals. */ +const VOLUME_DECIMALS = 8; + +/** One Coinbase candle: bucket start (unix seconds), low, high, open, close, volume. */ +export type Candle = readonly [number, number, number, number, number, number]; export interface ReferenceQuote { - /** The ticker price as returned, e.g. "1.1432". */ + /** The reference as a decimal string at the oracle's decimals, e.g. "1.14320000". */ price: string; - /** The price scaled to the forwarder's ORACLE_DECIMALS. */ + /** The reference scaled to the forwarder's ORACLE_DECIMALS. */ rateRaw: bigint; source: string; + /** When the reference was computed; the window ends at the current minute bucket. */ time: Date; - /** Coinbase's trade id for the tick, when present — the audit anchor. */ - tradeId: string | null; -} - -/** Decimal price string -> integer at `decimals`. Rejects malformed or non-positive input. */ -export function toReferenceRateRaw(price: string, decimals: number): bigint { - if (!/^\d+(\.\d+)?$/.test(price)) { - throw new Error(`reference price is not a decimal number: ${price}`); - } - const raw = parseUnits(price, decimals); - if (raw <= 0n) { - throw new Error(`reference price must be positive: ${price}`); - } - return raw; + /** Length of the averaging window that produced the rate (300, or 3600 when widened). */ + windowSeconds: number; } /** Mirrors VortexForwarder._checkedReference: |reference - oracle| <= oracle x band / 10000. */ @@ -41,30 +42,94 @@ export function isWithinReferenceBand(rateRaw: bigint, oracleRaw: bigint, bandBp return rateRaw + tolerance >= oracleRaw && rateRaw <= oracleRaw + tolerance; } +function toRaw(value: number, decimals: number): bigint { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`candle value is not a non-negative number: ${String(value)}`); + } + return parseUnits(value.toFixed(decimals), decimals); +} + +/** + * Volume-weighted average over the candles whose bucket starts inside + * [windowEnd - windowSeconds, windowEnd), weighting each candle's typical price + * (low + high + close) / 3 by its volume. Null when the window holds no volume. + */ +export function computeWindowVwap( + candles: readonly Candle[], + windowEndSec: number, + windowSeconds: number, + decimals: number +): bigint | null { + let weighted = 0n; + let volume = 0n; + for (const [time, low, high, , close, size] of candles) { + if (time < windowEndSec - windowSeconds || time >= windowEndSec) continue; + const typical = (toRaw(low, decimals) + toRaw(high, decimals) + toRaw(close, decimals)) / 3n; + const sizeRaw = toRaw(size, VOLUME_DECIMALS); + weighted += typical * sizeRaw; + volume += sizeRaw; + } + return volume === 0n ? null : weighted / volume; +} + +/** The primary window, or the widened one when the primary carries no volume; null when neither does. */ +export function selectReferenceWindow( + candles: readonly Candle[], + windowEndSec: number, + decimals: number +): { rateRaw: bigint; windowSeconds: number } | null { + for (const windowSeconds of [REFERENCE_WINDOW_SECONDS, REFERENCE_FALLBACK_WINDOW_SECONDS]) { + const rateRaw = computeWindowVwap(candles, windowEndSec, windowSeconds, decimals); + if (rateRaw !== null && rateRaw > 0n) { + return { rateRaw, windowSeconds }; + } + } + return null; +} + +export function parseCandles(body: unknown): Candle[] { + if (!Array.isArray(body)) { + throw new Error("Coinbase candles response is not an array"); + } + return body.map(row => { + if (!Array.isArray(row) || row.length < 6 || !row.slice(0, 6).every(v => typeof v === "number" && Number.isFinite(v))) { + throw new Error("Coinbase candle row is malformed"); + } + return row.slice(0, 6) as unknown as Candle; + }); +} + export type FetchLike = ( url: string, init?: { signal?: AbortSignal } ) => Promise<{ ok: boolean; status: number; json(): Promise }>; -/** Fetches the live ticker. Any failure throws; the caller defers the swap. */ -export async function fetchCoinbaseReference(decimals: number, fetchImpl: FetchLike = fetch): Promise { - const response = await fetchImpl(COINBASE_EURC_TICKER_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); +/** Fetches the last hour of one-minute candles and computes the reference. Any failure throws; the caller defers. */ +export async function fetchCoinbaseReference( + decimals: number, + fetchImpl: FetchLike = fetch, + nowMs: number = Date.now() +): Promise { + // The window ends at the end of the current minute bucket, so the in-progress candle counts. + const windowEndSec = + Math.floor(nowMs / 1000 / CANDLE_GRANULARITY_SECONDS) * CANDLE_GRANULARITY_SECONDS + CANDLE_GRANULARITY_SECONDS; + const startSec = windowEndSec - REFERENCE_FALLBACK_WINDOW_SECONDS; + const url = + `${COINBASE_EURC_CANDLES_URL}?granularity=${CANDLE_GRANULARITY_SECONDS}` + + `&start=${new Date(startSec * 1000).toISOString()}&end=${new Date(nowMs).toISOString()}`; + const response = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!response.ok) { - throw new Error(`Coinbase ticker responded ${response.status}`); - } - const body = (await response.json()) as { price?: unknown; time?: unknown; trade_id?: unknown } | null; - if (!body || typeof body.price !== "string") { - throw new Error("Coinbase ticker response has no price"); + throw new Error(`Coinbase candles responded ${response.status}`); } - const time = typeof body.time === "string" ? new Date(body.time) : new Date(); - if (Number.isNaN(time.getTime())) { - throw new Error(`Coinbase ticker time is not a timestamp: ${String(body.time)}`); + const window = selectReferenceWindow(parseCandles(await response.json()), windowEndSec, decimals); + if (!window) { + throw new Error(`no EURC-USD volume on Coinbase in the last ${REFERENCE_FALLBACK_WINDOW_SECONDS / 60} minutes`); } return { - price: body.price, - rateRaw: toReferenceRateRaw(body.price, decimals), + price: formatUnits(window.rateRaw, decimals), + rateRaw: window.rateRaw, source: COINBASE_REFERENCE_SOURCE, - time, - tradeId: typeof body.trade_id === "number" || typeof body.trade_id === "string" ? String(body.trade_id) : null + time: new Date(nowMs), + windowSeconds: window.windowSeconds }; } diff --git a/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts index a79dedd63..2207d52b8 100644 --- a/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts +++ b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts @@ -1,9 +1,10 @@ import { DataTypes, QueryInterface } from "sequelize"; -// Every swap is priced against a partner reference rate and may draw a subsidy from the -// vault (docs/architecture-monerium-b2b-onramp.md, fees section). The reference and the chosen -// route are persisted before broadcast (crash-recovery calldata identity + audit -// trail); the subsidy is recorded from the SwapExecuted event on confirmation. +// Every swap is priced against a partner reference rate (a Coinbase VWAP; the window +// length is recorded so the rate can be recomputed from public candles) and may draw a +// subsidy from the vault (docs/architecture-monerium-b2b-onramp.md, fees section). The +// reference and the chosen route are persisted before broadcast (crash-recovery calldata +// identity + audit trail); the subsidy is recorded from the SwapExecuted event. export async function up(queryInterface: QueryInterface): Promise { await queryInterface.addColumn("monerium_conversion_executions", "reference_rate_raw", { allowNull: true, @@ -13,9 +14,9 @@ export async function up(queryInterface: QueryInterface): Promise { allowNull: true, type: DataTypes.STRING(64) }); - await queryInterface.addColumn("monerium_conversion_executions", "reference_trade_id", { + await queryInterface.addColumn("monerium_conversion_executions", "reference_window_seconds", { allowNull: true, - type: DataTypes.STRING(32) + type: DataTypes.INTEGER }); await queryInterface.addColumn("monerium_conversion_executions", "reference_at", { allowNull: true, @@ -35,7 +36,7 @@ export async function down(queryInterface: QueryInterface): Promise { await queryInterface.removeColumn("monerium_conversion_executions", "subsidy_raw"); await queryInterface.removeColumn("monerium_conversion_executions", "route_index"); await queryInterface.removeColumn("monerium_conversion_executions", "reference_at"); - await queryInterface.removeColumn("monerium_conversion_executions", "reference_trade_id"); + await queryInterface.removeColumn("monerium_conversion_executions", "reference_window_seconds"); await queryInterface.removeColumn("monerium_conversion_executions", "reference_source"); await queryInterface.removeColumn("monerium_conversion_executions", "reference_rate_raw"); } diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index 41b8f8da2..1ebf65be5 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -24,7 +24,8 @@ export interface MoneriumConversionExecutionAttributes { /** Partner reference the swap was priced against, ORACLE_DECIMALS; persisted before broadcast. */ referenceRateRaw: string | null; referenceSource: string | null; - referenceTradeId: string | null; + /** Averaging window (seconds) the reference was computed over. */ + referenceWindowSeconds: number | null; referenceAt: Date | null; /** Factory route index the swap executed. */ routeIndex: number | null; @@ -51,7 +52,7 @@ type MoneriumConversionExecutionCreationAttributes = Optional< | "usdcNetRaw" | "referenceRateRaw" | "referenceSource" - | "referenceTradeId" + | "referenceWindowSeconds" | "referenceAt" | "routeIndex" | "txHash" @@ -79,7 +80,7 @@ class MoneriumConversionExecution declare destination: string; declare referenceRateRaw: string | null; declare referenceSource: string | null; - declare referenceTradeId: string | null; + declare referenceWindowSeconds: number | null; declare referenceAt: Date | null; declare routeIndex: number | null; declare txHash: string | null; @@ -158,10 +159,10 @@ MoneriumConversionExecution.init( field: "reference_source", type: DataTypes.STRING(64) }, - referenceTradeId: { + referenceWindowSeconds: { allowNull: true, - field: "reference_trade_id", - type: DataTypes.STRING(32) + field: "reference_window_seconds", + type: DataTypes.INTEGER }, routeIndex: { allowNull: true, From fce73c25839dfd22b481e274c134e54cde5153cb Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 19:28:00 +0200 Subject: [PATCH 17/44] docs(repo): describe the vwap reference rate --- docs/adr-0005-monerium-b2b-onramp.md | 11 +++++++---- docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/api/openapi/vortex.openapi.json | 2 +- docs/api/pages/07-webhooks.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 15 +++++++++------ docs/operations-monerium-b2b-rollout.md | 12 +++++++----- docs/operations-monerium-b2b-runbook.md | 9 +++++---- .../security-spec/05-integrations/monerium-b2b.md | 2 +- .../shared/src/endpoints/webhook.endpoints.ts | 2 +- 9 files changed, 33 insertions(+), 24 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 0568531ff..0e8a5ffe8 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -97,9 +97,12 @@ A flat skim on whatever the DEX returns cannot express that, so the contract now every fill into bands against a reference rate (decided with the partner; contracts were not yet deployed, so this replaced the flat fee before launch with no migration): -- **Reference rate.** The keeper fetches the Coinbase Exchange EURC-USD ticker before - each swap, records price, time and trade id on the execution row, and passes the rate - into `swapAndForward`. The contract rejects a reference outside +- **Reference rate.** Before each swap the keeper computes a five-minute + volume-weighted average of Coinbase Exchange EURC-USD one-minute candles (typical + price × volume), widened to an hour when the five minutes carry no volume, so a + single thin print on a weekend or outside business hours never becomes the reference + (suggested in review, 2026-09-15). It records price, window and time on the + execution row and passes the rate into `swapAndForward`. The contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink; on the permissionless path the argument is ignored and Chainlink is the reference. Reading the price from Vortex's own oracle on Base was rejected: it blends a forex rate on weekdays and lives on another chain. @@ -156,7 +159,7 @@ not yet deployed, so this replaced the flat fee before launch with no migration) | P9 | Notification confirmation depth | 32 blocks (implemented) | | P10 | Router pin and routes | SwapRouter02 immutable; routes are a guardian-managed, on-chain validated whitelist (EURe/EURC/USDC, four tiers, ≤ 2 hops); initial route EURe→EURC→USDC at the 5 bps tiers, re-verify at the deploy block (amended 2026-09-15) | | P11 | Fee adjustability | Guardian `setFeePolicy(target, floor)` within `MAX_FEE_PPM`; raising either value is announced and applies after 24 h, lowering is immediate (amended 2026-09-15) | -| P12 | Reference rate | Coinbase Exchange EURC-USD ticker, keeper-fetched per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15) | +| P12 | Reference rate | Five-minute VWAP over Coinbase Exchange EURC-USD one-minute candles (widened to 60 min when the five minutes have no volume), keeper-computed per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15) | | P13 | Subsidy vault limits | One shared vault; **50 bps of the reference value per swap, 200 USDC per UTC day** at launch, guardian-settable; withdraw to treasury only (2026-09-15) | | T2 | Whitelabel MSA terms | Open — G1 negotiation (rollout doc), includes the per-IBAN suspension ask | | T3 | KYB submission mechanism | Open, deliberately unbuilt — pilot corporates are approved by Monerium under partner KYC reliance and imported via the admin mapping; no identity-data submission path may exist until this settles (security-spec invariant 11) | diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index acdc1c340..1cebe256d 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2861,7 +2861,7 @@ export interface components { execution: { /** @description Fee taken on the whole execution in 6-decimal base units. */ feeRaw: string | null; - /** @description Reference EUR/USD rate the execution was priced against, in the oracle's decimals (8). */ + /** @description Reference EUR/USD rate the execution was priced against: a five-minute volume-weighted average of the Coinbase Exchange EURC-USD market computed immediately before the swap, in the oracle's decimals (8). */ referenceRateRaw: string | null; /** @description Subsidy paid by the vault straight to the destination for the whole execution in 6-decimal base units. */ subsidyRaw: string | null; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index f9613e691..e479a5ced 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2487,7 +2487,7 @@ "type": ["string", "null"] }, "referenceRateRaw": { - "description": "Reference EUR/USD rate the execution was priced against, in the oracle's decimals (8).", + "description": "Reference EUR/USD rate the execution was priced against: a five-minute volume-weighted average of the Coinbase Exchange EURC-USD market computed immediately before the swap, in the oracle's decimals (8).", "type": ["string", "null"] }, "subsidyRaw": { diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index ed482d17c..1c9b54147 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -180,7 +180,7 @@ Fired once per deposit after the full deposit has been converted and every contr Each `conversions[]` entry contains the EURe portion consumed and the net USDC attributed to this deposit by that execution. The payload-level `usdcNetRaw` is their aggregate. When one execution consumes several deposits, its output is divided proportionally by allocated EURe; floor dust goes to the largest allocation. -The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. +The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against, a five-minute volume-weighted average of the Coinbase Exchange EURC-USD market computed immediately before the swap (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. ### Delivery Semantics diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 0b93f1ca5..917863e16 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -57,7 +57,7 @@ flowchart LR end subgraph Reference["Reference rate"] - CB[Coinbase Exchange\nEURC-USD ticker] + CB[Coinbase Exchange\nEURC-USD 1-min candles] end subgraph Vortex["Vortex API (keeper backend)"] @@ -155,7 +155,7 @@ sequenceDiagram M-->>V: order.created / order.updated webhook -> inbox -> deposit row V->>F: (watcher) sees the Transfer log -> stamps chain identity V->>V: DEPOSIT_RECEIVED -> outbox -> partner webhook - V->>CB: EURC-USD ticker (reference, recorded on the execution row) + V->>CB: last hour of 1-min candles -> 5-min VWAP (reference, recorded on the execution row) V->>V: quote every whitelisted route, project fee/subsidy, defer if the vault cannot cover V->>F: swapAndForward(reference, bestRoute) [execution row committed first] F->>F: swap min(balance, perSwapCap) on the route; fee above target, floor on the net @@ -304,9 +304,12 @@ The partner agreement fixes the client's rate against a reference: the reference settles every fill into three bands against that reference (decisions: [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md), amendment). -- **Reference rate.** Before each swap the keeper fetches the Coinbase Exchange EURC-USD - ticker (`reference-rate.ts`), stores price, time and trade id on the execution row, - and passes the rate into `swapAndForward`. The contract rejects a reference outside +- **Reference rate.** Before each swap the keeper computes a five-minute volume-weighted + average of Coinbase Exchange EURC-USD one-minute candles (`reference-rate.ts`: typical + price `(low + high + close) / 3` weighted by volume; widened to an hour when the five + minutes carry no volume, so a single thin weekend print never becomes the reference), + stores price, window and time on the execution row, and passes the rate into + `swapAndForward`. The contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink EUR/USD; a permissionless caller's value is ignored and Chainlink is the reference. No reference means the keeper defers. - **Fee policy (`targetPpm`, `floorPpm`)**: per clone, in ppm below the reference, @@ -433,7 +436,7 @@ erDiagram |---|---| | `monerium_accounts` (069, 071, 078) | One row per client account: Monerium profile UUID, IBAN, forwarder/destination/fallback addresses, fee policy mirror (`target_ppm`, `floor_ppm`), lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | | `monerium_fiat_deposits` (069, 070, 073, 076) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status, on-chain mint identity, and two webhook-emission markers | -| `monerium_conversion_executions` (069, 074, 075, 077, 079) | One row per `swapAndForward()`, created before broadcast with the reference (rate, source, trade id, time) and route it will send: EURe in, USDC gross + fee + subsidy from the event, conversion net (`usdcOut - fee + subsidy`, excluding unrelated USDC swept by `forwarded`), tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and `SwapExecuted` log index (allocation boundary), status | +| `monerium_conversion_executions` (069, 074, 075, 077, 079) | One row per `swapAndForward()`, created before broadcast with the reference (rate, source, averaging window, time) and route it will send: EURe in, USDC gross + fee + subsidy from the event, conversion net (`usdcOut - fee + subsidy`, excluding unrelated USDC swept by `forwarded`), tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and `SwapExecuted` log index (allocation boundary), status | | `monerium_deposit_allocations` (076) | N:M accounting join: the EURe portion and attributed net USDC for each deposit/execution pair | | `monerium_webhook_events` (069) | Durable persist-before-200 inbox for Monerium deliveries, dedup by event id, 30-day retention after processing | | `monerium_chain_cursors` (070) | Persisted block cursors for the mint watcher | diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 9cafa5154..6f7b01cf1 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -118,10 +118,12 @@ fee policy 12.5 bps target / 15 bps floor (B1). > fallback address instead. 4. **Rate, fee and subsidy (B1/P1/P2/P12/P13)** — disclose the guarantee, the fee and the hard bound separately: - - Reference rate: the Coinbase Exchange EURC-USD price fetched immediately before - each conversion and recorded with the conversion (the agreement's "Coinbase EURC - oracle" — align the wording; the source is the exchange ticker, weekdays and - weekends alike). + - Reference rate: a five-minute volume-weighted average of the Coinbase Exchange + EURC-USD market, computed immediately before each conversion from public + one-minute candles and recorded with the conversion (widened to a one-hour + average when the five minutes carry no trades). The agreement's "Coinbase EURC + oracle" — align the wording; the source is the exchange market, weekdays and + weekends alike, smoothed so a single thin print cannot set the rate. - Guarantee: each keeper-executed conversion delivers the reference rate minus 12.5 bps whenever the market allows it, and never less than the reference minus 15 bps. Vortex's fee is whatever the market delivers above the 12.5 bps target, @@ -171,7 +173,7 @@ fee policy 12.5 bps target / 15 bps floor (B1). | SulPayments agreement (terms above) | Marcel ↔ partner | Drafting inputs ready | | Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering (needs Marcel's sandbox login) | Open — only remaining engineering unknown | | Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | -| Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation reads the Coinbase Exchange EURC-USD ticker — confirm that is what was meant | +| Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses a five-minute VWAP of Coinbase Exchange EURC-USD candles — confirm that is what was meant | | Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable) | | Subsidy vault funding and refill cadence | Ops | Before first activation; runbook §2.6 | | GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 0c40bd432..2a1145328 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -491,8 +491,9 @@ a below-floor fill makes the keeper defer, which is also a valid outcome to obse Deploy a client clone with the launch policy (1250 / 1500) as in §1.2. Use a fresh salt and record the predicted address and receipt. Read back `destination()`, `fallbackAddress()`, `targetPpm()`, `floorPpm()`, and `FACTORY()`, then require -`factory.isForwarder(forwarder) == true` before continuing. The keeper fetches the live -Coinbase ticker before each swap, so the backend needs outbound HTTPS during the run. +`factory.isForwarder(forwarder) == true` before continuing. The keeper computes its +reference from live Coinbase candles before each swap, so the backend needs outbound +HTTPS during the run. ### 7.4 Create the local account fixture @@ -589,7 +590,7 @@ FROM monerium_fiat_deposits WHERE account_id = ''; SELECT eure_in_raw, usdc_gross_raw, fee_raw, subsidy_raw, usdc_net_raw, destination, - reference_rate_raw, reference_source, reference_trade_id, route_index, + reference_rate_raw, reference_source, reference_window_seconds, route_index, tx_hash, nonce, broadcast_block_number, block_number, swap_log_index, status, error FROM monerium_conversion_executions WHERE account_id = ''; @@ -607,7 +608,7 @@ Required results: - One allocation joining that deposit and execution with the 25 EURe input and the attributed net USDC. - One `confirmed` execution with the 25 EURe input, a recorded reference (rate, source, - trade id) and route index 0, a fee or subsidy consistent with the fill's position + averaging window) and route index 0, a fee or subsidy consistent with the fill's position against the reference bands (`usdc_net_raw = usdc_gross_raw - fee_raw + subsidy_raw`), non-null nonce/hash/block/swap-log-index, destination matching the clone, and `error IS NULL`. If the vault was left empty and the fill sat below the diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 91a18e414..c49fc688d 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -41,7 +41,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, execution slot check/creation, finalization, R04 allocation) 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. 5. **Attribution is N:M, cursor-gated, exact-snapshot, and idempotent (R04)** — a confirmed execution records the block and block-global `SwapExecuted` log index but is not allocated immediately. Reconciliation starts only after the persisted mint cursor has processed that block, then consumes outstanding portions of deposits minted in earlier blocks or earlier log positions in the same block, oldest-first up to `eureInRaw`. This covers a mint that lands between the executor's balance read and swap without assigning a later same-block mint to the execution. A cap-cut deposit receives a partial `monerium_deposit_allocations` row and its remainder participates in the next execution; one execution may likewise allocate across many deposits. Each row records its EURe portion and proportional net USDC; execution net is computed as `usdcOut - fee + subsidy` (the vault pays the subsidy straight to the destination), never the event's `forwarded` full-balance sweep, so pre-existing unsolicited USDC is not misreported as this deposit's yield. Floor dust goes to the largest allocation only when indexed deposits cover the whole execution, so missing inflow cannot inflate a customer's share. Mint identity is `(chain_id, tx_hash, log_index)` and the watcher scans 12-deep blocks. Only chain-indexed deposits make an account a conversion candidate; a raw forwarder balance never bypasses the watcher. Non-Monerium inflows become `unattr:` rows and never surface as customer claims. 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, 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 or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. -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 fetches the Coinbase Exchange EURC-USD ticker (`reference-rate.ts`), records price, time and trade id on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. 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 computes a five-minute volume-weighted average of Coinbase Exchange EURC-USD one-minute candles (`reference-rate.ts`; widened to an hour when the five minutes carry no volume, so one thin print cannot set the reference), records price, window and time on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. 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. ## Monitoring diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 179d95af5..2aadfd502 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -109,7 +109,7 @@ export interface DepositReceivedWebhookPayload { export interface ConversionExecutionPricing { /** Fee taken on the execution (6-decimal base units). */ feeRaw: string | null; - /** Reference EUR/USD rate the execution was priced against, in the oracle's decimals (8). */ + /** Reference EUR/USD rate the execution was priced against (a five-minute Coinbase EURC-USD VWAP), in the oracle's decimals (8). */ referenceRateRaw: string | null; /** Subsidy paid by the vault straight to the destination (6-decimal base units). */ subsidyRaw: string | null; From 6dd3a351709af2df1694c078f60da26650d4a357 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 16 Sep 2026 09:19:31 +0200 Subject: [PATCH 18/44] docs(api): refresh wire-contract snapshot for execution pricing Additive only: ConversionExecutionPricing and the required execution block on DepositConvertedWebhookPayload.conversions[], mirrored in WebhookPayload and WebhookDeliveryAttempt. --- docs/api/wire-contract.snapshot.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index dcc074ee7..d08b9851e 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -382,6 +382,12 @@ BundledPriceResult: { CleanupPhase: "assetHubCleanup" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "ethereumCleanupUsdc" | "hydrationCleanup" | "moonbeamCleanup" | "pendulumCleanup" | "polygonCleanup" | "polygonCleanupAxlUsdc" +ConversionExecutionPricing: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; +} + CreateBestQuoteRequest: { api?: boolean; apiKey?: string; @@ -447,6 +453,11 @@ DepositConvertedWebhookPayload: { } & { conversions: Array<{ eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; executionId: string; txHash: null | string; usdcNetRaw: string; @@ -2435,6 +2446,11 @@ WebhookDeliveryAttempt: { } & { conversions: Array<{ eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; executionId: string; txHash: null | string; usdcNetRaw: string; @@ -2498,6 +2514,11 @@ WebhookPayload: { } & { conversions: Array<{ eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; executionId: string; txHash: null | string; usdcNetRaw: string; From 92083ac84279ccc48006de118e6ef16d807de897 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 16 Sep 2026 09:19:32 +0200 Subject: [PATCH 19/44] fix(repo): verify subsidy delivery in the forwarder settlement _settle returned the vault's claimed subsidy without observing it, and setSubsidyVault is guardian-only, untimelocked and unvalidated: a no-op vault let a below-floor fill pass the net floor on paper while the client received the raw fill. Count the subsidy only after exactly the shortfall landed at destination, the same exact-delta pattern _swap uses. --- .../monerium-forwarder/src/VortexForwarder.sol | 7 ++++++- .../src/VortexForwarderFactory.sol | 3 ++- .../test/VortexForwarder.t.sol | 17 +++++++++++++++++ docs/adr-0005-monerium-b2b-onramp.md | 12 +++++++----- docs/architecture-monerium-b2b-onramp.md | 12 +++++++----- .../05-integrations/monerium-b2b.md | 4 ++-- 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index d30cc3c2b..03da7ea41 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -403,7 +403,8 @@ contract VortexForwarder { /// - fill below reference x (1 - floorPpm): a privileged swap draws the shortfall /// from the vault straight to `destination`; a permissionless swap pays nothing. /// The vault reverts (and so does the swap) when its cap, budget, pause or - /// balance cannot cover the shortfall — a swap is never partially subsidized. + /// balance cannot cover the shortfall, and the forwarder reverts unless exactly + /// the shortfall arrived at `destination` — a swap is never partially subsidized. function _settle(uint256 amountIn, uint256 usdcReceived, uint256 referenceUsed, bool privileged) internal returns (uint256 fee, uint256 subsidy) @@ -423,7 +424,11 @@ contract VortexForwarder { subsidy = floorOut - usdcReceived; address vault = FACTORY.subsidyVault(); if (vault == address(0)) revert SubsidyUnavailable(); + // The vault is guardian-settable without a timelock, so its word is not enough: + // count the subsidy only once exactly that amount has landed at `destination`. + uint256 destinationBefore = USDC.balanceOf(destination); IVortexSubsidyVault(vault).pay(destination, subsidy, referenceOut); + if (USDC.balanceOf(destination) - destinationBefore != subsidy) revert SubsidyUnavailable(); return (0, subsidy); } diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol index e77a3abc8..f0902da8f 100644 --- a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -44,7 +44,8 @@ contract VortexForwarderFactory { /// @notice The VortexSubsidyVault clones draw from; address(0) disables subsidies. /// Guardian-settable without a timelock: the vault only ever pays Vortex - /// money to a clone's fixed destination, so a swap cannot be harmed by it. + /// money, and a clone counts a subsidy only after verifying that exactly the + /// shortfall reached its own destination, so a swap cannot be harmed by it. address public subsidyVault; event ForwarderDeployed( diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index f66feee03..5923f417d 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -93,6 +93,12 @@ contract MockReentrantRouter { } } +/// Vault that accepts pay() and transfers nothing: what a misconfigured or hostile +/// guardian-set vault looks like from the forwarder's side. +contract NoopVault { + function pay(address, uint256, uint256) external {} +} + contract VortexForwarderTest is Test { MockERC20 eure; MockERC20 eurc; @@ -701,6 +707,17 @@ contract VortexForwarderTest is Test { assertEq(usdc.balanceOf(destination), 0); } + function test_swap_subsidyNotDelivered_revertsTheWholeSwap() public { + factory.setSubsidyVault(address(new NoopVault())); + _fund(1_000e18); + router.setNextOut(1_130e6); // below both floors; the 8.29 USDC top-up the vault "pays" never arrives + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyUnavailable.selector); + fwd.swapAndForward(REF, 0); + assertEq(eure.balanceOf(address(fwd)), 1_000e18); + assertEq(usdc.balanceOf(destination), 0); + } + function test_swap_subsidyOverBudget_reverts() public { vault.setDailyBudget(1e6); _fund(1_000e18); diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 0e8a5ffe8..988c0bdf4 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -112,11 +112,13 @@ not yet deployed, so this replaced the flat fee before launch with no migration) between floor and target is passed through untouched; a fill below `reference × (1 − floor)` is topped up to the floor from the vault. The 2.5 bps dead band is intended. - **Subsidy vault.** One `VortexSubsidyVault` shared by every clone, treasury-funded, - pays only when called by a factory-registered clone and only to that clone's fixed - destination, within a guardian-settable per-swap cap and UTC-daily budget, can be - paused, and withdraws only to the treasury. A vault that cannot cover reverts the whole - swap — a swap is never partially subsidized. The vault holds Vortex money only, so its - limits bound Vortex's exposure, never the client's. + pays only when called by a factory-registered clone, to the destination that clone + passes (its own immutable one), within a guardian-settable per-swap cap and UTC-daily + budget, can be paused, and withdraws only to the treasury. A vault that cannot cover + reverts the whole swap, and the clone reverts unless exactly the shortfall arrived at + its destination — a swap is never partially subsidized and a guardian-set vault cannot + harm the client. The vault holds Vortex money only, so its limits bound Vortex's + exposure, never the client's. - **Floor on the net.** `SLIPPAGE_BPS` (now 40 bps) is enforced on fill − fee + subsidy, not on the raw fill; the router minimum is zero and the forwarder's post-condition is the guard, so a subsidy can never paper over a depegged reference. diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 917863e16..551ae2f74 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -321,11 +321,13 @@ settles every fill into three bands against that reference (decisions: SEPA transfer is already in flight cannot be swapped under a silently worse policy; lowering is immediate (registry P11). Swaps always use the currently applied policy. - **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded - from the treasury. It pays only when called by a factory-registered clone, only to - that clone's fixed destination, within a guardian-settable per-swap cap (ppm of the - swap's reference value) and a UTC-daily budget; it can be paused and withdraws only - to the treasury. A vault that cannot cover the shortfall reverts the whole swap — a - swap is never partially subsidized. The vault holds Vortex money only. + from the treasury. It pays only when called by a factory-registered clone, to the + destination the clone passes (its own immutable one), within a guardian-settable + per-swap cap (ppm of the swap's reference value) and a UTC-daily budget; it can be + paused and withdraws only to the treasury. A vault that cannot cover the shortfall + reverts the whole swap, and the clone reverts unless exactly the shortfall arrived at + its destination — a swap is never partially subsidized, and the guardian cannot harm + a swap by pointing the factory at a bad vault. The vault holds Vortex money only. - **Floor on the net**: `SLIPPAGE_BPS` bounds fill − fee + subsidy against Chainlink, not the raw fill. The router minimum is zero and the forwarder's post-condition is the guard, so a subsidy can never paper over a depegged reference and the whole call, diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index c49fc688d..17ef15f0d 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -41,7 +41,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, execution slot check/creation, finalization, R04 allocation) 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. 5. **Attribution is N:M, cursor-gated, exact-snapshot, and idempotent (R04)** — a confirmed execution records the block and block-global `SwapExecuted` log index but is not allocated immediately. Reconciliation starts only after the persisted mint cursor has processed that block, then consumes outstanding portions of deposits minted in earlier blocks or earlier log positions in the same block, oldest-first up to `eureInRaw`. This covers a mint that lands between the executor's balance read and swap without assigning a later same-block mint to the execution. A cap-cut deposit receives a partial `monerium_deposit_allocations` row and its remainder participates in the next execution; one execution may likewise allocate across many deposits. Each row records its EURe portion and proportional net USDC; execution net is computed as `usdcOut - fee + subsidy` (the vault pays the subsidy straight to the destination), never the event's `forwarded` full-balance sweep, so pre-existing unsolicited USDC is not misreported as this deposit's yield. Floor dust goes to the largest allocation only when indexed deposits cover the whole execution, so missing inflow cannot inflate a customer's share. Mint identity is `(chain_id, tx_hash, log_index)` and the watcher scans 12-deep blocks. Only chain-indexed deposits make an account a conversion candidate; a raw forwarder balance never bypasses the watcher. Non-Monerium inflows become `unattr:` rows and never surface as customer claims. 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, 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 or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. -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 computes a five-minute volume-weighted average of Coinbase Exchange EURC-USD one-minute candles (`reference-rate.ts`; widened to an hour when the five minutes carry no volume, so one thin print cannot set the reference), records price, window and time on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. 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 computes a five-minute volume-weighted average of Coinbase Exchange EURC-USD one-minute candles (`reference-rate.ts`; widened to an hour when the five minutes carry no volume, so one thin print cannot set the reference), records price, window and time on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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 at its destination, 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. ## Monitoring @@ -65,7 +65,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | **Unsolicited USDC inflates deposit reporting** | The contract sweeps a pre-existing USDC balance with a later swap and the backend credits the whole transfer to that deposit | Execution net and allocations use `SwapExecuted.usdcOut - fee`; `forwarded` is deliberately excluded from conversion accounting | | **Manipulated reference rate** | A compromised keeper (or Coinbase response) supplies a reference that inflates the fee or triggers a subsidy | Contract band check against Chainlink; fee capped at `MAX_FEE_PPM`; subsidy bounded by the vault's per-swap cap and daily budget; the floor on the client's net; permissionless callers cannot supply a reference at all | | **Subsidy-widened sandwich** | A searcher moves the pool so the fill lands just above floor minus cap and the vault pays the difference | Private orderflow for keeper swaps; modest per-swap cap and daily budget; the vault, not the client, absorbs the loss; no subsidy on the permissionless path | -| **Subsidy vault drain** | A contract or key tries to pull vault USDC | `pay` accepts only factory-registered clones and only to their fixed destination, within cap and budget; withdrawals go to the immutable treasury only; guardian setters bound Vortex's own money, never client funds | +| **Subsidy vault drain** | A contract or key tries to pull vault USDC | `pay` accepts only factory-registered clones, within cap and budget; the clone passes its own immutable destination and verifies the exact delivery; withdrawals go to the immutable treasury only; guardian setters bound Vortex's own money, never client funds | | **Untrusted forwarder factory** | Admin-secret holder submits a contract whose self-reported factory blesses it and redirects mints | Configured factory is the trust root for provisioning, execution, and monitoring; local provisioning is atomic | | **Webhook replay / duplicate delivery** | A captured valid delivery is replayed to double-count a deposit | Durable inbox dedup on unique `event_id` (`ON CONFLICT DO NOTHING`); forward-only transitions make a replayed older state a no-op | | **Out-of-order events regress state** | A delayed `pending` event arrives after `minted` | Forward-only transition lattice; regressions logged and dropped | From da3d97c78a76a438d1bfb54d9d04dce6dbd4eba6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 16 Sep 2026 09:19:33 +0200 Subject: [PATCH 20/44] test(repo): pin the fee-branch oracle floor with a skewed reference The only net-floor tests landed in the subsidy branch; a reference 100 bps under Chainlink with a fill above its target exercises the fee branch and must still revert / defer on the oracle floor. --- .../monerium-b2b/conversion-executor.test.ts | 10 ++++++++++ .../monerium-forwarder/test/VortexForwarder.t.sol | 13 +++++++++++++ 2 files changed, 23 insertions(+) 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 9e37d38f1..28928eb0a 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 @@ -215,6 +215,16 @@ describe("projectSwap", () => { describe("expectedSwapCalldata", () => { it("rebuilds the exact calldata from the persisted reference and route, or nothing", () => { + + it("defers when a fee-band net sits below the oracle floor (depegged reference, fee side)", () => { + // Mirrors test_swap_depeggedReference_feeBranchStillEnforcesOracleFloor: reference 100 bps + // under Chainlink, fill above its target -> fee 0.81 USDC, net 1_127_189_250 < 1_135_440_000. + const projection = projectSwap({ ...base, quotedOut: 1_128n * USDC, referenceRaw: (114_000_000n * 9_900n) / 10_000n }); + expect(projection.fee).toBe(810_750n); + expect(projection.subsidy).toBe(0n); + expect(projection.net).toBe(1_127_189_250n); + expect(projection.defer).toContain("oracle floor"); + }); expect(expectedSwapCalldata({ referenceRateRaw: null, routeIndex: 0 })).toBeNull(); expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: null })).toBeNull(); expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: 1 })).toBe( diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 5923f417d..08fe451e3 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -753,6 +753,19 @@ contract VortexForwarderTest is Test { assertEq(usdc.balanceOf(address(vault)), 1_000e6, "subsidy transfer must be undone"); } + function test_swap_depeggedReference_feeBranchStillEnforcesOracleFloor() public { + _fund(1_000e18); + uint256 lowReference = (REF * 9_900) / 10_000; // 100 bps below Chainlink: the band's edge + // Above that reference's target (1_127_189_250): fee branch, fee 0.81 USDC, and the + // net 1_127_189_250 still sits below Chainlink - 40 bps (1_135_440_000). + router.setNextOut(1_128e6); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InsufficientOutput.selector); + fwd.swapAndForward(lowReference, 0); + assertEq(usdc.balanceOf(feeRecipient), 0, "fee transfer must be undone"); + assertEq(eure.balanceOf(address(fwd)), 1_000e18); + } + function test_swap_referenceOutsideTheBandReverts() public { _fund(1_000e18); router.setNextOut(1_150e6); From 4f3ba9d0b4242d74c8c870fcaa8e0d36e55c09ce Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 16 Sep 2026 09:19:34 +0200 Subject: [PATCH 21/44] docs(repo): state the effective reference margin below chainlink The floor on the net binds at SLIPPAGE_BPS - floorPpm (~25 bps) below Chainlink, well inside the 100 bps band; the weekend-gap open item framed it purely as a band question. --- .../monerium-b2b/conversion-executor.test.ts | 17 +++++++++++++++++ docs/adr-0005-monerium-b2b-onramp.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 5 ++++- docs/operations-monerium-b2b-rollout.md | 2 +- 4 files changed, 23 insertions(+), 3 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 28928eb0a..c730c9ebb 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 @@ -225,6 +225,23 @@ describe("expectedSwapCalldata", () => { expect(projection.net).toBe(1_127_189_250n); expect(projection.defer).toContain("oracle floor"); }); + + it("tolerates a reference only SLIPPAGE_BPS - floorPpm (~25 bps) below Chainlink before the floor binds", () => { + // A fill exactly at the client's floor: no fee, no subsidy, net = reference x (1 - floorPpm). + const floorFill = (referenceRaw: bigint) => (((base.amountIn * referenceRaw) / 10n ** 20n) * 998_500n) / 1_000_000n; + const tooLow = (114_000_000n * 9_973n) / 10_000n; // 27 bps below + const fine = (114_000_000n * 9_976n) / 10_000n; // 24 bps below + expect(projectSwap({ ...base, quotedOut: floorFill(tooLow), referenceRaw: tooLow }).defer).toContain("oracle floor"); + expect(projectSwap({ ...base, quotedOut: floorFill(fine), referenceRaw: fine })).toMatchObject({ + defer: null, + fee: 0n, + subsidy: 0n + }); + }); +}); + +describe("expectedSwapCalldata", () => { + it("rebuilds the exact calldata from the persisted reference and route, or nothing", () => { expect(expectedSwapCalldata({ referenceRateRaw: null, routeIndex: 0 })).toBeNull(); expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: null })).toBeNull(); expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: 1 })).toBe( diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 988c0bdf4..4633425b0 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -161,7 +161,7 @@ not yet deployed, so this replaced the flat fee before launch with no migration) | P9 | Notification confirmation depth | 32 blocks (implemented) | | P10 | Router pin and routes | SwapRouter02 immutable; routes are a guardian-managed, on-chain validated whitelist (EURe/EURC/USDC, four tiers, ≤ 2 hops); initial route EURe→EURC→USDC at the 5 bps tiers, re-verify at the deploy block (amended 2026-09-15) | | P11 | Fee adjustability | Guardian `setFeePolicy(target, floor)` within `MAX_FEE_PPM`; raising either value is announced and applies after 24 h, lowering is immediate (amended 2026-09-15) | -| P12 | Reference rate | Five-minute VWAP over Coinbase Exchange EURC-USD one-minute candles (widened to 60 min when the five minutes have no volume), keeper-computed per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15) | +| P12 | Reference rate | Five-minute VWAP over Coinbase Exchange EURC-USD one-minute candles (widened to 60 min when the five minutes have no volume), keeper-computed per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15). The floor on the net binds first: with `floorPpm` 15 bps and `SLIPPAGE_BPS` 40 bps, a reference more than `SLIPPAGE_BPS − floorPpm` ≈ 25 bps below Chainlink makes every normal fill (fee band or subsidized) revert on chain and defer off chain, so ~25 bps is the working downside margin against a stale round; the 100 bps band is the outlier ceiling for a keeper-supplied value, not the operating tolerance (2026-09-16) | | P13 | Subsidy vault limits | One shared vault; **50 bps of the reference value per swap, 200 USDC per UTC day** at launch, guardian-settable; withdraw to treasury only (2026-09-15) | | T2 | Whitelabel MSA terms | Open — G1 negotiation (rollout doc), includes the per-IBAN suspension ask | | T3 | KYB submission mechanism | Open, deliberately unbuilt — pilot corporates are approved by Monerium under partner KYC reliance and imported via the admin mapping; no identity-data submission path may exist until this settles (security-spec invariant 11) | diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 551ae2f74..36c408f53 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -331,7 +331,10 @@ settles every fill into three bands against that reference (decisions: - **Floor on the net**: `SLIPPAGE_BPS` bounds fill − fee + subsidy against Chainlink, not the raw fill. The router minimum is zero and the forwarder's post-condition is the guard, so a subsidy can never paper over a depegged reference and the whole call, - subsidy transfer included, reverts when the floor fails. + subsidy transfer included, reverts when the floor fails. Because the client's floor is + 15 bps under the reference and the oracle floor 40 bps under Chainlink, a reference + more than ~25 bps below Chainlink fails the floor for every normal fill: that margin, + not the 100 bps band, is the operating tolerance against a stale Chainlink round. - **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 diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 6f7b01cf1..e212fb5bb 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -174,6 +174,6 @@ fee policy 12.5 bps target / 15 bps floor (B1). | Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering (needs Marcel's sandbox login) | Open — only remaining engineering unknown | | Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | | Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses a five-minute VWAP of Coinbase Exchange EURC-USD candles — confirm that is what was meant | -| Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable) | +| Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 25 bps, not the band: a market more than ~25 bps under a stale Chainlink round defers every swap until the round updates — decide whether that is acceptable or whether `SLIPPAGE_BPS`/`floorPpm` move before the deploy | | Subsidy vault funding and refill cadence | Ops | Before first activation; runbook §2.6 | | GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | From 70067974aa38778d221116f7638dd1efbb7e749b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 16 Sep 2026 09:19:34 +0200 Subject: [PATCH 22/44] fix(api): describe the depth check as a subsidy signal The monitor compares the raw quote to SLIPPAGE_BPS, but settlement now enforces the floor on the client's net: a raw impact above it is subsidized up to the vault cap and the keeper still executes. Keep the raw depth-vs-oracle signal, name what it means (every swap draws a subsidy, the permissionless path reverts) instead of calling for a pause, and fix the stale _minOut reference. --- .../services/monerium-b2b/monitoring.test.ts | 24 +++++++++ .../api/services/monerium-b2b/monitoring.ts | 53 +++++++++++++++---- docs/operations-monerium-b2b-runbook.md | 6 +-- .../05-integrations/monerium-b2b.md | 2 +- 4 files changed, 71 insertions(+), 14 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts index 01ecdb9d1..abd9eb4c5 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { + classifyExecutableDepth, classifyStranding, classifyVaultRunway, computeQuoteImpactBps, @@ -51,6 +52,29 @@ describe("computeQuoteImpactBps", () => { }); }); +describe("classifyExecutableDepth", () => { + const SLIPPAGE_BPS = 40; + + it("is ok when the best route clears SLIPPAGE_BPS at both sizes", () => { + expect(classifyExecutableDepth(11, 30, SLIPPAGE_BPS).severity).toBe("ok"); + }); + + it("warns when only cap-sized fills would need a subsidy", () => { + const verdict = classifyExecutableDepth(11, 55, SLIPPAGE_BPS); + expect(verdict.severity).toBe("warn"); + expect(verdict.reason).toContain("perSwapCap"); + }); + + it("errors on a subsidizable min-size impact but names the subsidy, not a pause", () => { + // 50 bps raw impact: the vault (50 bps cap) still covers it and the keeper executes. + const verdict = classifyExecutableDepth(50, 80, SLIPPAGE_BPS); + expect(verdict.severity).toBe("error"); + expect(verdict.reason).toContain("subsidy"); + expect(verdict.reason).toContain("permissionless path would revert"); + expect(verdict.reason).not.toMatch(/pause/i); + }); +}); + describe("classifyStranding", () => { const TRIGGER_DELAY = 86_400n; // 24h, registry P4 placeholder const now = 1_800_000_000_000; // fixed epoch ms diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index ee113cab5..39fbfe312 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -25,9 +25,10 @@ import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./moneri * * 1. Executable-depth check (main PRD §7.4, T6 follow-up): QuoterV2 static quotes on * every enabled factory route at perSwapCap and minSwapAmount sizes vs the Chainlink - * EUR/USD rate. Impact of the best route above SLIPPAGE_BPS at minSwapAmount size is - * the PAUSE THRESHOLD (error-level -> engage guardian pause per the incident - * runbook); at perSwapCap size it is an early warning. Mainnet-only (QuoterV2 pin). + * 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). * 2. Stranded-balance monitor: forwarders whose on-chain stranding marker (R03) has * been armed for more than STRANDED_WARN_MS warn; past TRIGGER_DELAY (the * permissionless-trigger delay, registry P4) they error — the keeper should have @@ -78,7 +79,7 @@ const factoryMonitoringAbi = parseAbi([ /** * Price impact of an executable quote vs the Chainlink EUR/USD rate, in bps (floored; - * negative when the quote beats the oracle). Same scaling as VortexForwarder._minOut + * negative when the quote beats the oracle). Same scaling as VortexForwarder._floorOut * without the slippage haircut: EURe 18 dp in, USDC 6 dp out. */ export function computeQuoteImpactBps( @@ -94,6 +95,39 @@ export function computeQuoteImpactBps( return Number(((expectedOut - quotedOutRaw) * 10_000n) / expectedOut); } +export type DepthSeverity = "error" | "ok" | "warn"; + +/** + * Verdict of the executable-depth check from the raw quote impact vs Chainlink at the + * two swap sizes. Settlement enforces SLIPPAGE_BPS on the client's NET, so a raw impact + * above it does not by itself revert a keeper swap: the vault covers the shortfall + * below the floor band up to its per-swap cap and the keeper defers beyond that + * (`projectSwap`). It does mean every keeper swap of that size draws a subsidy and the + * unsubsidized permissionless path would revert — a market condition to investigate, + * not a pause trigger on its own. + */ +export function classifyExecutableDepth( + minImpactBps: number, + capImpactBps: number, + slippageBps: number +): { reason: string; severity: DepthSeverity } { + if (minImpactBps > slippageBps) { + return { + reason: + "raw quote impact at minSwapAmount exceeds SLIPPAGE_BPS on every route: every keeper swap needs a vault " + + "subsidy (deferring once the shortfall exceeds the per-swap cap) and the permissionless path would revert", + severity: "error" + }; + } + if (capImpactBps > slippageBps) { + return { + reason: "raw quote impact at perSwapCap exceeds SLIPPAGE_BPS on the best route: cap-sized swaps need a vault subsidy", + severity: "warn" + }; + } + return { reason: "ok", severity: "ok" }; +} + export type StrandingSeverity = "error" | "ok" | "warn"; /** @@ -304,14 +338,13 @@ export async function runExecutableDepthCheck(): Promise { `oracle=${answer} (updatedAt=${updatedAt}), SLIPPAGE_BPS=${slippageBps}, best route ${best.index}; per route: ` + quoted.map(route => `#${route.index} min=${route.minImpactBps}bps cap=${route.capImpactBps}bps`).join(", "); - if (best.minImpactBps > slippageBps) { - // PAUSE THRESHOLD (PRD §7.4): even minimum-size swaps would land below the floor on every route. + const verdict = classifyExecutableDepth(best.minImpactBps, best.capImpactBps, slippageBps); + if (verdict.severity === "error") { logger.error( - "monerium-b2b: PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS on every route; engage " + - `guardian pause per docs/operations-monerium-b2b-runbook.md. ${detail}` + `monerium-b2b: DEPTH BELOW FLOOR — ${verdict.reason}; triage per docs/operations-monerium-b2b-runbook.md §3. ${detail}` ); - } else if (best.capImpactBps > slippageBps) { - logger.warn(`monerium-b2b: executable depth below perSwapCap — cap-sized swaps would land below the floor. ${detail}`); + } else if (verdict.severity === "warn") { + logger.warn(`monerium-b2b: ${verdict.reason}. ${detail}`); } else { logger.info(`monerium-b2b: depth check ok. ${detail}`); } diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 2a1145328..67ce29903 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -244,9 +244,9 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- | Log line contains | Meaning | Action | |---|---|---| -| `PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS on every route` | Executable depth below even minimum-size swaps on the best route; the keeper is deferring and the permissionless path would revert | Global pause (§2.1); investigate pool state (LP exit, depeg); whitelist a better route or lower `perSwapCap`; re-run the liquidity-baseline methodology before unpausing | -| `executable depth below perSwapCap` | Cap-sized swaps would land below the floor; availability, not fund risk | Lower `perSwapCap`, add a route, or accept deferrals; watch for escalation | -| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable — check egress), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (depeg — do not force), `no enabled swap route could be quoted` (§2.1 route lever) | Funds wait with the marker armed; after 24 h the permissionless path can execute unsubsidized, after 7 days the fallback sweep is live — communicate if the deferral persists | +| `DEPTH BELOW FLOOR — raw quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Even minimum-size fills land below Chainlink − 40 bps on every route before settlement. The floor is enforced on the client's net, so the keeper still executes while the vault covers the shortfall (up to the per-swap cap; beyond it the keeper defers and logs `deferring conversion`), but every swap of that size now costs a subsidy and the unsubsidized permissionless path would revert | Investigate pool state (LP exit, depeg) and watch the vault spend (§2.6); whitelist a better route or lower `perSwapCap`; global pause (§2.1) if it is a depeg or the vault is being drained; re-run the liquidity-baseline methodology before trusting the route again | +| `raw quote impact at perSwapCap exceeds SLIPPAGE_BPS` | Cap-sized swaps would need a vault subsidy; availability and vault spend, not fund risk | Lower `perSwapCap`, add a route, or accept the subsidies; watch for escalation | +| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable — check egress), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (depeg — do not force), `no enabled swap route could be quoted` or `the factory has no enabled swap route` (§2.1 route lever) | Funds wait with the marker armed; after 24 h the permissionless path can execute unsubsidized, after 7 days the fallback sweep is live — communicate if the deferral persists | | `SUBSIDY VAULT —` (error) | Vault paused or empty: every below-floor swap defers | §2.6: fund or unpause; check why it emptied (budget too high for the market?) | | `subsidy vault ... refill before below-floor swaps start deferring` | Less than a day of budget left, or today's budget spent | §2.6 refill; consider the budget vs. observed spreads | | `no subsidy vault is configured on the factory` | `setSubsidyVault` never ran; below-floor swaps defer | §2.6 | diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 17ef15f0d..07676f4bf 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -48,7 +48,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 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 `docs/operations-monerium-b2b-runbook.md`). -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. Impact above `SLIPPAGE_BPS` at `minSwapAmount` size logs the error-level PAUSE THRESHOLD line; at `perSwapCap` size a warning. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. +2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on every enabled factory route at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline); the best route decides. Settlement enforces `SLIPPAGE_BPS` on the client's net, so a raw impact above it means every keeper swap of that size draws a vault subsidy (deferring past the per-swap cap) and the permissionless path would revert; at `minSwapAmount` size that logs the error-level DEPTH BELOW FLOOR line, at `perSwapCap` size a warning (`classifyExecutableDepth`). The keeper's own `projectSwap` deferral remains the authoritative gate. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. 3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal), and within two days of `SWEEP_DELAY` (7 days, registry P3) the error names the imminent dead-man sweep to the fallback. 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). 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, destination/fallback and timelocked fee changes are authorized transitions reconciled with a version bump; proxy bytecode or registration drift is an incident. The standalone manifest verifier remains consistency evidence, not the trust root. From 7264b04d16f62d5bf6da30b5e965c9d3b3def2f2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 16 Sep 2026 09:19:35 +0200 Subject: [PATCH 23/44] fix(repo): diff manifest route arrays by index flatten treated arrays as leaves, so String([...]) collapsed every route to "[object Object]" and a same-length path or enabled change produced no diff. --- .../script/verify-manifest.test.ts | 24 ++++++++++++++++++- .../script/verify-manifest.ts | 12 +++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/contracts/monerium-forwarder/script/verify-manifest.test.ts b/contracts/monerium-forwarder/script/verify-manifest.test.ts index 5b079d31a..a5b7081f5 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.test.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { severityFor } from "./verify-manifest"; +import { Diff, diffSection, severityFor } from "./verify-manifest"; describe("manifest diff severity", () => { it("treats guardian fee policy, vault and route changes as notices", () => { @@ -15,3 +15,25 @@ describe("manifest diff severity", () => { expect(severityFor("forwarders.0x123.runtimeBytecodeHash")).toBe("FAIL"); }); }); + +describe("manifest section diff", () => { + const routes = (enabled: boolean) => ({ routes: [{ enabled: true, path: "0xaa" }, { enabled, path: "0xbb" }] }); + + it("walks route arrays by index so a same-length toggle or path change is visible", () => { + const diffs: Diff[] = []; + diffSection("factory.operational", routes(true), routes(false), diffs); + expect(diffs).toEqual([ + { actual: "false", expected: "true", path: "factory.operational.routes.1.enabled", severity: "NOTICE" } + ]); + }); + + it("reports an added route as a missing manifest entry", () => { + const diffs: Diff[] = []; + diffSection("factory.operational", { routes: [] }, { routes: [{ enabled: true, path: "0xaa" }] }, diffs); + expect(diffs.map(diff => diff.path).sort()).toEqual([ + "factory.operational.routes.0.enabled", + "factory.operational.routes.0.path" + ]); + expect(diffs.every(diff => diff.expected === "" && diff.severity === "NOTICE")).toBe(true); + }); +}); diff --git a/contracts/monerium-forwarder/script/verify-manifest.ts b/contracts/monerium-forwarder/script/verify-manifest.ts index b08b84993..10490126e 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.ts @@ -46,7 +46,7 @@ import { export type Severity = "FAIL" | "EXPECTED-TRANSITION" | "NOTICE"; -interface Diff { +export interface Diff { actual: string; expected: string; path: string; @@ -54,7 +54,13 @@ interface Diff { } function flatten(value: unknown, prefix: string, out: Map): void { - if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (Array.isArray(value)) { + // Arrays (the route whitelist) are walked by index: String([...]) would collapse + // every entry to "[object Object]" and hide a same-length content change. + value.forEach((child, index) => flatten(child, `${prefix}.${index}`, out)); + return; + } + if (value !== null && typeof value === "object") { for (const [key, child] of Object.entries(value)) { flatten(child, prefix ? `${prefix}.${key}` : key, out); } @@ -70,7 +76,7 @@ export function severityFor(path: string): Severity { return "FAIL"; } -function diffSection(path: string, expected: unknown, actual: unknown, diffs: Diff[]): void { +export function diffSection(path: string, expected: unknown, actual: unknown, diffs: Diff[]): void { const expectedFlat = new Map(); const actualFlat = new Map(); flatten(expected, path, expectedFlat); From 271753e82054ed7dd5f73410b9dce8bdead7b286 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 16 Sep 2026 09:19:35 +0200 Subject: [PATCH 24/44] test(api): cover pricePlannedSwap and finalizeExecution --- .../monerium-b2b/conversion-executor.test.ts | 240 +++++++++++++++++- .../monerium-b2b/conversion-executor.ts | 6 +- 2 files changed, 233 insertions(+), 13 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 c730c9ebb..451146bcc 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 @@ -1,6 +1,6 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import { FindOptions, Transaction } from "sequelize"; -import { encodeFunctionData } from "viem"; +import { Address, encodeAbiParameters, encodeEventTopics, encodeFunctionData, Hex, TransactionReceipt } from "viem"; import sequelize from "../../../config/database"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { @@ -13,13 +13,17 @@ import { classifyHashlessPending, conversionAmountsFromSwapEvent, expectedSwapCalldata, + finalizeExecution, isExpectedSwapTransaction, + pricePlannedSwap, projectSwap, recoveryBlockRanges, runConversionExecutor, selectDepositsForExecution } from "./conversion-executor"; -import { forwarderAbi } from "./chain"; +import * as chain from "./chain"; +import * as referenceRate from "./reference-rate"; +import { ReferenceQuote } from "./reference-rate"; // R04 attribution (docs/architecture-monerium-b2b-onramp.md §3): pro-rata by // amount_raw against eureInRaw, floor division, remainder to the largest deposit. @@ -211,10 +215,6 @@ describe("projectSwap", () => { expect(projection.subsidy).toBeGreaterThan(0n); expect(projection.defer).toContain("oracle floor"); }); -}); - -describe("expectedSwapCalldata", () => { - it("rebuilds the exact calldata from the persisted reference and route, or nothing", () => { it("defers when a fee-band net sits below the oracle floor (depegged reference, fee side)", () => { // Mirrors test_swap_depeggedReference_feeBranchStillEnforcesOracleFloor: reference 100 bps @@ -245,7 +245,7 @@ describe("expectedSwapCalldata", () => { expect(expectedSwapCalldata({ referenceRateRaw: null, routeIndex: 0 })).toBeNull(); expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: null })).toBeNull(); expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: 1 })).toBe( - encodeFunctionData({ abi: forwarderAbi, args: [114_000_000n, 1n], functionName: "swapAndForward" }) + encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 1n], functionName: "swapAndForward" }) ); }); }); @@ -307,7 +307,7 @@ describe("classifyHashlessPending", () => { describe("isExpectedSwapTransaction", () => { const keeper = "0x1111111111111111111111111111111111111111"; const forwarder = "0x2222222222222222222222222222222222222222"; - const input = encodeFunctionData({ abi: forwarderAbi, args: [114_000_000n, 0n], functionName: "swapAndForward" }); + const input = encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 0n], functionName: "swapAndForward" }); const expected = { from: keeper, input, nonce: 7, to: forwarder }; it("requires the exact keeper, nonce, forwarder, and priced calldata", () => { @@ -317,7 +317,7 @@ describe("isExpectedSwapTransaction", () => { expect(isExpectedSwapTransaction({ ...expected, to: keeper }, keeper, forwarder, 7, input)).toBe(false); expect(isExpectedSwapTransaction({ ...expected, input: "0x" }, keeper, forwarder, 7, input)).toBe(false); const otherReference = encodeFunctionData({ - abi: forwarderAbi, + abi: chain.forwarderAbi, args: [114_100_000n, 0n], functionName: "swapAndForward" }); @@ -443,3 +443,223 @@ describe("runConversionExecutor recovery ordering", () => { } }); }); + +describe("pricePlannedSwap", () => { + afterEach(() => mock.restore()); + + const FORWARDER = "0x1111111111111111111111111111111111111111" as Address; + const FACTORY = "0x2222222222222222222222222222222222222222" as Address; + const VAULT = "0x3333333333333333333333333333333333333333" as Address; + const immutables: chain.ForwarderImmutables = { + eure: "0x4444444444444444444444444444444444444444", + factory: FACTORY, + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, + oracle: "0x5555555555555555555555555555555555555555", + oracleDecimals: 8, + slippageBps: 40, + usdc: "0x6666666666666666666666666666666666666666" + }; + const reference: ReferenceQuote = { + price: "1.14000000", + rateRaw: 114_000_000n, + source: "test", + time: new Date(0), + windowSeconds: 300 + }; + const vault: chain.SubsidyVaultState = { + balance: 1_000n * USDC, + dailyBudget: 200n * USDC, + maxSubsidyPpm: 5_000, + paused: false, + spentToday: 0n + }; + const routes = [ + { index: 0, path: "0xaa" as Hex }, + { index: 1, path: "0xbb" as Hex } + ]; + + function arrange( + overrides: { + chainId?: number; + oracleAnswer?: bigint; + quotes?: Record; + reference?: ReferenceQuote | Error; + routes?: typeof routes; + } = {} + ) { + const reads: Record = { + floorPpm: 1_500, + latestRoundData: [1n, overrides.oracleAnswer ?? 114_000_000n, 0n, 0n, 1n], + subsidyVault: VAULT, + targetPpm: 1_250 + }; + spyOn(chain, "getPublicClient").mockReturnValue({ + readContract: async ({ functionName }: { functionName: string }) => reads[functionName] + } as unknown as ReturnType); + spyOn(chain, "getForwarderImmutables").mockResolvedValue(immutables); + spyOn(chain, "getChainId").mockResolvedValue(overrides.chainId ?? 1); + 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 quote = quotes[path]; + if (quote instanceof Error) throw quote; + return quote; + }); + const fetched = overrides.reference ?? reference; + const fetchSpy = spyOn(referenceRate, "fetchCoinbaseReference"); + if (fetched instanceof Error) { + fetchSpy.mockRejectedValue(fetched); + } else { + fetchSpy.mockResolvedValue(fetched); + } + } + + const price = () => pricePlannedSwap(FORWARDER, FACTORY, 1_000n * EUR); + + it("defers on a non-positive Chainlink answer", async () => { + arrange({ oracleAnswer: 0n }); + expect(await price()).toEqual({ kind: "defer", reason: "Chainlink EUR/USD answered 0" }); + }); + + it("defers when the reference cannot be fetched", async () => { + arrange({ reference: new Error("coinbase down") }); + expect(await price()).toMatchObject({ kind: "defer", reason: expect.stringContaining("reference rate unavailable") }); + }); + + it("defers on a reference outside the Chainlink band", async () => { + arrange({ reference: { ...reference, price: "1.12000000", rateRaw: 112_000_000n } }); // 175 bps below + expect(await price()).toMatchObject({ kind: "defer", reason: expect.stringContaining("outside the 100 bps band") }); + }); + + it("defers when the factory has no enabled route", async () => { + arrange({ routes: [] }); + expect(await price()).toEqual({ kind: "defer", reason: "the factory has no enabled swap route" }); + }); + + it("uses the first enabled route unprojected off mainnet", async () => { + arrange({ chainId: 11_155_111 }); + expect(await price()).toEqual({ kind: "ready", projection: null, reference, routeIndex: 0 }); + }); + + it("defers when no route can be quoted", async () => { + arrange({ quotes: { "0xaa": new Error("no pool"), "0xbb": new Error("no pool") } }); + expect(await price()).toEqual({ kind: "defer", reason: "no enabled swap route could be quoted" }); + }); + + it("picks the route with the highest quote and projects its settlement", async () => { + arrange(); + expect(await price()).toEqual({ + kind: "ready", + projection: { defer: null, fee: 425_000n, net: 1_138_575_000n, subsidy: 0n }, + reference, + routeIndex: 1 + }); + }); + + it("defers with the route and quote when the projection defers", async () => { + arrange({ quotes: { "0xaa": 1_130n * USDC, "0xbb": new Error("no pool") } }); // needs 8.29 USDC, cap is 5.7 + expect(await price()).toMatchObject({ + kind: "defer", + reason: expect.stringMatching(/per-swap cap.*\(route 0 quoted 1130000000\)/) + }); + }); +}); + +describe("finalizeExecution", () => { + const FORWARDER = "0x1111111111111111111111111111111111111111" as Address; + const KEEPER = "0x7777777777777777777777777777777777777777" as Address; + const TX = `0x${"ab".repeat(32)}` as Hex; + + function swapLog( + address: Address, + args: Record<"eureIn" | "fee" | "forwarded" | "referenceRate" | "routeIndex" | "subsidy" | "usdcOut", bigint> + ) { + const inputs = chain.swapExecutedEvent.inputs; + return { + address, + blockNumber: 100n, + data: encodeAbiParameters( + inputs.filter(input => !("indexed" in input)), + [args.routeIndex, args.eureIn, args.usdcOut, args.referenceRate, args.fee, args.subsidy, args.forwarded] + ), + logIndex: 7, + topics: encodeEventTopics({ abi: [chain.swapExecutedEvent], args: { caller: KEEPER }, eventName: "SwapExecuted" }), + transactionHash: TX + }; + } + + function receipt(status: "reverted" | "success", logs: ReturnType[] = []): TransactionReceipt { + return { blockNumber: 100n, logs, status, transactionHash: TX } as unknown as TransactionReceipt; + } + + function pendingExecution() { + const updates: Record[] = []; + const execution = { + async update(values: Record) { + updates.push(values); + } + } as unknown as MoneriumConversionExecution; + return { execution, updates }; + } + + it("fails the execution on a reverted receipt", async () => { + const { execution, updates } = pendingExecution(); + await finalizeExecution(execution, receipt("reverted"), FORWARDER, {} as Transaction); + expect(updates).toEqual([ + { blockNumber: 100, error: "swapAndForward reverted", status: MoneriumConversionExecutionStatus.Failed } + ]); + }); + + it("fails a successful receipt that carries no SwapExecuted from the forwarder itself", async () => { + const { execution, updates } = pendingExecution(); + const foreign = swapLog("0x9999999999999999999999999999999999999999", { + eureIn: 1_000n * EUR, + fee: 0n, + forwarded: 1_138n * USDC, + referenceRate: 114_000_000n, + routeIndex: 0n, + subsidy: 0n, + usdcOut: 1_138n * USDC + }); + await finalizeExecution(execution, receipt("success", [foreign]), FORWARDER, {} as Transaction); + expect(updates).toEqual([ + { + blockNumber: 100, + error: "receipt succeeded but no SwapExecuted event was emitted by the forwarder", + status: MoneriumConversionExecutionStatus.Failed + } + ]); + }); + + it("confirms from the forwarder's SwapExecuted and records the event's pricing as authoritative", async () => { + const { execution, updates } = pendingExecution(); + const log = swapLog(FORWARDER, { + eureIn: 1_000n * EUR, + fee: 425_000n, + forwarded: 1_140n * USDC, // includes unsolicited USDC: must not leak into the amounts + referenceRate: 114_000_000n, + routeIndex: 1n, + subsidy: 0n, + usdcOut: 1_139n * USDC + }); + await finalizeExecution(execution, receipt("success", [log]), FORWARDER, {} as Transaction); + expect(updates).toEqual([ + { + blockNumber: 100, + error: null, + eureInRaw: (1_000n * EUR).toString(), + feeRaw: "425000", + referenceRateRaw: "114000000", + routeIndex: 1, + status: MoneriumConversionExecutionStatus.Confirmed, + subsidyRaw: "0", + swapLogIndex: 7, + txHash: TX, + usdcGrossRaw: "1139000000", + usdcNetRaw: "1138575000" + } + ]); + }); +}); 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 ffdeada1e..7f7591a2f 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -373,7 +373,7 @@ export async function reconcileConfirmedExecutionAllocations( } /** Applies a mined receipt to a pending execution: confirmed + event amounts, or failed on revert. */ -async function finalizeExecution( +export async function finalizeExecution( execution: MoneriumConversionExecution, receipt: TransactionReceipt, forwarderAddress: string, @@ -668,7 +668,7 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans // ------------------------------------------------------------------ pricing -type PlannedSwap = +export type PlannedSwap = | { kind: "defer"; reason: string } | { kind: "ready"; projection: SwapProjection | null; reference: ReferenceQuote; routeIndex: number }; @@ -698,7 +698,7 @@ async function quoteRoutes( * there is no quoter pin: the first enabled route is used unprojected and the * contract's own checks remain the only gate. */ -async function pricePlannedSwap(forwarder: Address, factory: Address, amountIn: bigint): Promise { +export async function pricePlannedSwap(forwarder: Address, factory: Address, amountIn: bigint): Promise { const client = getPublicClient(); const immutables = await getForwarderImmutables(forwarder); const [targetPpm, floorPpm, roundData, vaultAddress] = await Promise.all([ From 96b34ed492aba1b5abd788a52b336b49abab592d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 16 Sep 2026 09:19:36 +0200 Subject: [PATCH 25/44] docs(api): disclose the reference window fallback The partner docs said five-minute VWAP unconditionally; the keeper widens to sixty minutes when the five carry no volume. --- docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/api/openapi/vortex.openapi.json | 2 +- docs/api/pages/07-webhooks.md | 2 +- packages/shared/src/endpoints/webhook.endpoints.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 1cebe256d..469f1a2bf 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2861,7 +2861,7 @@ export interface components { execution: { /** @description Fee taken on the whole execution in 6-decimal base units. */ feeRaw: string | null; - /** @description Reference EUR/USD rate the execution was priced against: a five-minute volume-weighted average of the Coinbase Exchange EURC-USD market computed immediately before the swap, in the oracle's decimals (8). */ + /** @description Reference EUR/USD rate the execution was priced against: a volume-weighted average of the Coinbase Exchange EURC-USD market over the five minutes before the swap (widened to sixty minutes when those five carry no trades), in the oracle's decimals (8). */ referenceRateRaw: string | null; /** @description Subsidy paid by the vault straight to the destination for the whole execution in 6-decimal base units. */ subsidyRaw: string | null; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index e479a5ced..f4ad6a7c4 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2487,7 +2487,7 @@ "type": ["string", "null"] }, "referenceRateRaw": { - "description": "Reference EUR/USD rate the execution was priced against: a five-minute volume-weighted average of the Coinbase Exchange EURC-USD market computed immediately before the swap, in the oracle's decimals (8).", + "description": "Reference EUR/USD rate the execution was priced against: a volume-weighted average of the Coinbase Exchange EURC-USD market over the five minutes before the swap (widened to sixty minutes when those five carry no trades), in the oracle's decimals (8).", "type": ["string", "null"] }, "subsidyRaw": { diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 1c9b54147..ba89d68fb 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -180,7 +180,7 @@ Fired once per deposit after the full deposit has been converted and every contr Each `conversions[]` entry contains the EURe portion consumed and the net USDC attributed to this deposit by that execution. The payload-level `usdcNetRaw` is their aggregate. When one execution consumes several deposits, its output is divided proportionally by allocated EURe; floor dust goes to the largest allocation. -The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against, a five-minute volume-weighted average of the Coinbase Exchange EURC-USD market computed immediately before the swap (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. +The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against, a volume-weighted average of the Coinbase Exchange EURC-USD market over the five minutes before the swap, widened to sixty minutes when those five carry no trades (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. ### Delivery Semantics diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 2aadfd502..e31bad3ab 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -109,7 +109,7 @@ export interface DepositReceivedWebhookPayload { export interface ConversionExecutionPricing { /** Fee taken on the execution (6-decimal base units). */ feeRaw: string | null; - /** Reference EUR/USD rate the execution was priced against (a five-minute Coinbase EURC-USD VWAP), in the oracle's decimals (8). */ + /** Reference EUR/USD rate the execution was priced against (a Coinbase EURC-USD VWAP over the five minutes before the swap, or sixty when those carry no volume), in the oracle's decimals (8). */ referenceRateRaw: string | null; /** Subsidy paid by the vault straight to the destination (6-decimal base units). */ subsidyRaw: string | null; From f8413e5b93530dad06393abcd985c32c1e1ea9b1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 16 Sep 2026 09:19:37 +0200 Subject: [PATCH 26/44] docs(repo): tighten monerium b2b ops wording --- .../src/api/services/monerium-b2b/account-provisioning.ts | 1 + docs/operations-monerium-b2b-rollout.md | 6 +++++- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts index 34aa9df4a..d10ab9123 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -54,6 +54,7 @@ function normalizeAddress(value: string, name: string): string { /** Launch fee policy (docs/adr-0005-monerium-b2b-onramp.md, B1): 12.5 bps target, 15 bps floor. */ export const DEFAULT_TARGET_PPM = 1_250; export const DEFAULT_FLOOR_PPM = 1_500; +// Mirrors the implementation's immutable MAX_FEE_PPM (ADR-0005 table); the contract re-validates at deploy. const MAX_FEE_PPM = 10_000; /** Mirrors the contract's _validateFeePolicy: both in [0, MAX_FEE_PPM], target never above floor. */ diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index e212fb5bb..016b94a0a 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -49,7 +49,11 @@ fee policy 12.5 bps target / 15 bps floor (B1). concurrently. Migrations 076/077 install allocation accounting and its exact same-block boundary. Treat 076 as forward-only after activation: its `down` migration refuses to discard any existing allocation rows, so restore from backup instead of - forcing a rollback once conversions have been attributed. + forcing a rollback once conversions have been attributed. Migrations 078/079 add the + ppm fee policy and the pricing columns: before applying them, confirm no `Pending` + `monerium_conversion_executions` row has a NULL `reference_rate_raw` or `route_index` + (it would stay in flight forever and block its account) and no deposit-converted + outbox delivery is still pending (it would replay without the `execution` block). 2. **Treasury first (O2):** create the dedicated fee Safe multisig — `FEE_RECIPIENT` is immutable in the implementation. Confirm guardian key custody plan (EOA acceptable for pilot; hardware/multisig at GA). diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 07676f4bf..29fac90e5 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -63,7 +63,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | **Lost or replaced keeper transaction** | A slow/hidden transaction is declared stale and a second swap sends the same funds | Compare-and-set pre-send reservation; no time-based failure after nonce persistence; fail-closed nonce state; bounded complete persisted-block scan plus exact sender/nonce/target/calldata/event identity before adopt/fail | | **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving attribution permanently incomplete | Conversion candidates require chain-indexed deposits; allocation waits until the mint cursor covers the swap's exact block/log boundary | | **Unsolicited USDC inflates deposit reporting** | The contract sweeps a pre-existing USDC balance with a later swap and the backend credits the whole transfer to that deposit | Execution net and allocations use `SwapExecuted.usdcOut - fee`; `forwarded` is deliberately excluded from conversion accounting | -| **Manipulated reference rate** | A compromised keeper (or Coinbase response) supplies a reference that inflates the fee or triggers a subsidy | Contract band check against Chainlink; fee capped at `MAX_FEE_PPM`; subsidy bounded by the vault's per-swap cap and daily budget; the floor on the client's net; permissionless callers cannot supply a reference at all | +| **Manipulated reference rate** | A compromised keeper (or Coinbase response) supplies a reference that inflates the fee or triggers a subsidy | Contract band check against Chainlink; fee capped at `MAX_FEE_PPM`; subsidy bounded by the vault's per-swap cap and daily budget; the floor on the client's net; a permissionless caller's reference argument is ignored in favour of Chainlink | | **Subsidy-widened sandwich** | A searcher moves the pool so the fill lands just above floor minus cap and the vault pays the difference | Private orderflow for keeper swaps; modest per-swap cap and daily budget; the vault, not the client, absorbs the loss; no subsidy on the permissionless path | | **Subsidy vault drain** | A contract or key tries to pull vault USDC | `pay` accepts only factory-registered clones, within cap and budget; the clone passes its own immutable destination and verifies the exact delivery; withdrawals go to the immutable treasury only; guardian setters bound Vortex's own money, never client funds | | **Untrusted forwarder factory** | Admin-secret holder submits a contract whose self-reported factory blesses it and redirects mints | Configured factory is the trust root for provisioning, execution, and monitoring; local provisioning is atomic | From a4e93bc9ea495fe8d6935ef5075d9bd38290d888 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 21:38:32 +0200 Subject: [PATCH 27/44] docs(repo): propose whole-deposit settlement and refund recovery for the B2B onramp --- docs/README.md | 1 + ...al-monerium-b2b-settlement-and-recovery.md | 401 ++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 docs/proposal-monerium-b2b-settlement-and-recovery.md diff --git a/docs/README.md b/docs/README.md index c9fdfc620..bb192e6cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | | [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md) | Phase-2 proposal for the consumer (Safe + passkey) Monerium onramp; the B2B variant shipped | +| [`proposal-monerium-b2b-settlement-and-recovery.md`](proposal-monerium-b2b-settlement-and-recovery.md) | Draft plan (2026-09-17) to rework PR #1375: whole-deposit USDC forwarding and automatic exact-amount refund recovery via a Vortex-held recovery wallet | | [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | | [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | diff --git a/docs/proposal-monerium-b2b-settlement-and-recovery.md b/docs/proposal-monerium-b2b-settlement-and-recovery.md new file mode 100644 index 000000000..34752fb98 --- /dev/null +++ b/docs/proposal-monerium-b2b-settlement-and-recovery.md @@ -0,0 +1,401 @@ +# Proposal: whole-deposit settlement and automatic refund recovery (Monerium B2B onramp) + +**Status:** draft plan, 2026-09-17. Nothing here is implemented. Targets the design on +PR #1375 (`feat/monerium-forwarder-fee-subsidy`); the contracts are not deployed, so +every change below is a pre-launch design change, not a migration. Accepted parts move +into a second amendment of [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md). + +## 1. What product asked for + +1. **One pay-in, one pay-out.** The keeper keeps swapping in `perSwapCap` chunks, but the + chunks are an implementation detail: USDC accumulates on chain and the client's + destination receives **one transfer for the whole bank payment** once every chunk is + converted. Partner (SulPayments) bookkeeping then maps one SEPA credit to one USDC + transfer. +2. **Automatic refund when the promise is missed.** If a bank payment is not fully + converted inside the promised window, Vortex returns the **exact EUR amount** to the + payer's bank account: EURe and any chunk-swapped USDC move to a Vortex recovery + wallet, USDC is swapped back to EURe, a separate subsidy wallet covers the slippage + residue, and a Monerium redeem order pays the source IBAN. +3. **Custody for recovery is agreed commercially.** The forwarder's fallback address + becomes a Vortex-controlled wallet. This reverses three 2026-09-15 decisions in + ADR-0005: "no payment bouncing", "no Vortex-triggered sweep to the fallback", and the + client-chosen self-custodied fallback (Tier A). + +**Assumptions used below** (each is an open decision in §8): + +- The window is **2 hours** (the brief says "1 hour" once and "2 hours" twice). +- The clock starts at the **EURe mint block timestamp** (on-chain, verifiable; the + provider `processedAt` is within minutes of it). +- "Exact amount" = the `amount` string of the Monerium issue order, to the cent. + +## 2. What this changes in the trust model (say it once, plainly) + +Today's invariant is *"Vortex keys can trigger, never move or redirect"*. After this +change it becomes: + +> Vortex keys can move a client's funds **only** to a fixed Vortex recovery wallet, +> **only** after `RECOVERY_DELAY` has elapsed since the batch opened, and the contract can +> still never send anywhere else (destination, treasury fee, recovery wallet, router). + +Consequences that are not engineering: G1 item 1 (Monerium accepted the attestor pattern +*"conditional on fallback capability"*) must be re-approved for a Vortex-held fallback; +G2 must re-scope custody (the ADR's "non-custody" argument is gone for the recovery +path); rollout terms §2 and §6 ("every exit target is client-controlled") are rewritten; +the client loses the self-custody exit that no Vortex failure could block. What survives: +the permissionless swap-and-forward after `TRIGGER_DELAY` (a Vortex outage still cannot +trap funds on chain). + +## 3. Part A — whole-deposit forwarding: approaches + +### A0. The rung we cannot stop at (keeper-only) + +Add `amountIn` to `swapAndForward` and let the keeper swap **one deposit per execution**. +This alone removes deposit merging (two deposits within a minute no longer share a swap) +and is a two-line contract change. It does **not** solve chunking: the mainnet EURe pools +carry roughly €25k within ~14 bps (T6 baseline), so a €100k ticket as one swap breaches +the 40 bps floor and defers forever. Chunking stays, so accumulation is needed. A0 is +nevertheless the first step of both approaches below. + +### A. Accumulate on the clone (recommended) + +The clone keeps the USDC it swaps and forwards it in one explicit transfer. + +```solidity +function poke() external; // arms batchOpenedAt +function swap(uint256 referenceRate, uint256 routeIndex, uint256 amountIn) external; +function forward(uint256 amount) external; // keeper/guardian → destination +function forwardAll() external; // anyone, after TRIGGER_DELAY → destination +function recover(uint256 eureAmount, uint256 usdcAmount) external; // keeper/guardian, after RECOVERY_DELAY → RECOVERY_WALLET +``` + +- `swap` = today's `swapAndForward` minus the final transfer: fee bands, oracle floor on + the net, route whitelist unchanged. The **subsidy is paid to the clone**, not to the + destination (`pay(address(this), …)`, delta-checked on the clone's own balance). + `amountIn` is explicit (`minSwapAmount ≤ amountIn ≤ min(balance, perSwapCap)`), so the + keeper decides which deposit a chunk belongs to. Permissionless callers after + `TRIGGER_DELAY` keep today's semantics (Chainlink reference, no subsidy, amount clamped). +- `forward(amount)` transfers exactly `amount` USDC to `destination`. The keeper calls + it with `Σ chunk nets (+ subsidies)` of one deposit once every chunk is confirmed. + `forwardAll()` is the liveness fallback (Vortex dead for 24 h ⇒ anyone can push the + whole balance to the destination; batches may merge on that path — documented). +- Marker: `strandedSince` becomes `batchOpenedAt` — armed by `poke()`/`swap()` when + EURe ≥ `MIN_SWAP_FLOOR` or USDC > 0 and the marker is 0; **never re-armed by a partial + swap** (today's re-arm on a cap remainder would restart the 2 h clock); cleared only + when EURe < floor **and** USDC == 0 after `forward`/`recover`. +- Removed: `fallbackAddress`, `onlyFallback` (`setDestination`, `setFallbackAddress`, + `setClientPaused`, `sweep`), `sweepStrandedEure`, `SWEEP_DELAY`, `clientPaused`. + `guardianPaused` stays protective: it blocks `swap`/`forward`, never `recover` (an + incident is exactly when pause-then-recover is wanted). +- Events: `SwapExecuted` loses `forwarded`; new `Forwarded(caller, amount)` and + `Recovered(caller, eure, usdc)`. + +Pros: no new contract, per-client isolation unchanged (a bug in one clone never touches +another client's USDC), USDC never leaves the client's own contract until it goes to the +destination, the smallest audit delta (~40 lines net after deletions), one fewer ERC20 +transfer per chunk than B. Cons: the clone becomes stateful across deposits — when two +deposits are in flight their USDC is one fungible balance on chain and the 1:1 ledger +lives in the database plus the `Forwarded` amounts (a mislabelled keeper cannot steal, +only misattribute between the same client's deposits); R09 unsolicited USDC now waits +for the next `forward`/`forwardAll` instead of riding along with a swap. + +*Variant A′ (optional hardening):* keep a `mapping(bytes32 batchId => uint256)` in the +clone and make `swap`/`forward` batch-keyed, so the chain itself proves each forwarded +amount equals that batch's chunk sum. Adds ~25 lines and a keeper-chosen key; buys an +on-chain audit trail but not protection (the keeper picks the key either way). Skip +unless the partner asks for on-chain per-payment proofs. + +### B. Shared settlement escrow + +The clone swaps as today but sends the USDC (net + subsidy) to one shared +`VortexSettlement` contract, credited under `(clone, batchId)`; the keeper releases a +batch to `clone.destination()` when it is complete. + +```solidity +// VortexForwarder +function swapAndForward(uint256 referenceRate, uint256 routeIndex, uint256 amountIn, bytes32 batchId) external; +function recoverEure(uint256 amount) external; // still needed for the EURe leg +// VortexSettlement (shared, immutable in the implementation) +function credit(bytes32 batchId, uint256 amount) external; // clones only (factory.isForwarder) +function release(address clone, bytes32 batchId) external; // keeper; anyone after TRIGGER_DELAY +function recover(address clone, bytes32 batchId) external; // keeper, after RECOVERY_DELAY → RECOVERY_WALLET +``` + +Pros: explicit per-payment ledger and events on chain (`Credited`/`Released` per +batch), batches never mix even at the contract level, the clone's swap path is nearly +untouched. Cons: a **new contract in audit scope (~150 lines)** and a concentration risk +(one contract holds every client's in-flight USDC — a bug there hits all clients, where +A's blast radius is one clone); an extra transfer per chunk; the clone **still** needs a +recover function for the EURe remainder, so B does not avoid the clone changes, it adds +to them; two places to gate on delays and pause; the destination is read from the clone +at release time (fine, but one more cross-contract assumption for the auditor). + +### Comparison + +| | A (clone accumulates) | B (shared escrow) | +|---|---|---| +| New contracts | none | one | +| Audit delta | ~40 lines net in the clone | clone changes **plus** the escrow | +| Blast radius of a bug | one client | all clients' in-flight USDC | +| On-chain per-payment proof | no (DB + `Forwarded` amounts); A′ adds it | yes | +| Gas per chunk | unchanged | +1 ERC20 transfer | +| Custody narrative | USDC stays on the client's contract | USDC pooled in a Vortex contract | +| Backend accounting | `deposit_id` on executions, one `forward` tx | same, plus batch keys | + +**Recommendation: A.** It is the smallest change that meets the requirement, keeps the +per-client isolation the whole design is built on, and the partner's bookkeeping needs +one USDC transfer with the exact amount, which A delivers. Reconsider A′ only if the +partner wants chain-native per-payment proofs. + +### Backend for Part A (either approach) + +- **Executor becomes 1 deposit : N executions.** Pick the oldest minted, chain-indexed + deposit that is not fully converted and not in recovery; `amountIn = min(remaining, + perSwapCap)`; `swap(...)`. Execution rows get `deposit_id` and a `kind` + (`swap | forward | recover | reverse_swap | topup`) so the existing crash-safe send + pipeline (nonce persisted before broadcast, calldata-exact recovery scan) serves every + keeper transaction instead of being duplicated per kind. +- **Delete the N:M attribution.** With the deposit chosen before the swap, the R04 + cursor-gated snapshot allocation, `monerium_deposit_allocations`, `selectDepositsForExecution` and `allocateUsdcProRata` have no job left (~250 lines plus tests). The + mint watcher stays: a chain-indexed mint is still what makes a deposit convertible. +- **Forward step.** When every `swap` execution of a deposit is confirmed, create a + `forward` execution for `Σ(usdcOut − fee + subsidy)` and send it. `DEPOSIT_CONVERTED` + fires after the forward is 32 blocks deep and carries `forwardTxHash` and + `usdcForwardedRaw`; `conversions[]` stays for transparency. +- **Deposit status** gains `converting` (first chunk sent), `forwarded` (terminal), + `recovering`, `refunded` (terminal), `recovery_failed` (manual), still forward-only. +- **Below-minimum deposits** (< `minSwapAmount`, €250) can no longer merge with the next + deposit. Decision §8 D5; the lean default is to refund them through the recovery path + (no loss, one SEPA fee). +- Monitors: drop the sweep-imminent note; the stranded monitor reads `batchOpenedAt` + and also warns on USDC that sits unforwarded past N minutes. + +## 4. Part B — recovery: design and feasibility + +### 4.1 Roles and wallets + +| Wallet | Holds | Key | Purpose | +|---|---|---|---| +| Recovery wallet(s) `RECOVERY_WALLET` | EURe + USDC only during a recovery | Vortex, keeper-class KMS | receives `recover()`, signs the reverse swap and the Monerium redeem | +| Float wallet | EURe float | Vortex | pays the slippage residue so the redeem is exact; its outflow **is** the loss ledger | +| Treasury / `FEE_RECIPIENT` | fees, surplus | Safe | receives reverse-swap surplus and sweeps | + +**One recovery wallet under a Vortex/SatoshiPay company profile (D3, decided +2026-09-17).** A Monerium redeem burns EURe from a *linked* address of a profile. Product +prefers to return the money from a Vortex/SatoshiPay corporate account linked in +Monerium, so the recovery wallet is one EOA linked to that profile, `RECOVERY_WALLET` is +one immutable in the implementation, and no per-client linking or HD derivation is +needed. Implications to carry, none of them technical blockers: + +- **Payer of record.** Every refund is a SEPA credit from SatoshiPay's Monerium account + to a third party, not a return from the client's own profile. Monerium supports + outgoing third-party payments (partners page: "IBANs support both incoming and + outgoing third-party payments"; the `Counterpart` schema exists to identify the + recipient, not to restrict it), but the pattern "one corporate profile paying many + unrelated corporates" needs Monerium compliance sign-off alongside G1 item 1, and G2 + must scope it (SatoshiPay executing payments on behalf of clients). +- **Segregation.** Recovered client EURe sits on SatoshiPay's profile until redeemed. + Use a dedicated profile, or at least a dedicated linked address that holds nothing + but in-flight recoveries, so balances never commingle with SatoshiPay's own funds; the + float wallet is a second dedicated address. +- **Onboarding.** That company profile must be KYB-approved in the whitelabel app (the + whitelabel credentials can only place orders for profiles of that app). +- **Per-order rules apply to the company profile.** `supportingDocumentId` above + €15,000 (M3), any outgoing limits (M5), and the client's bank statement shows + Monerium/SatoshiPay as the sender, so the memo must carry the original payment + reference for the client's reconciliation. +- **Audit trail.** Monerium sees no link between the refund and the client's profile; + the recovery row plus the memo are the only join. + +### 4.2 On-chain primitive + +`recover(eureAmount, usdcAmount)` on the clone (Part A): keeper/guardian only, requires +`batchOpenedAt != 0 && now − batchOpenedAt ≥ RECOVERY_DELAY` (immutable, 2 h), sends to +`RECOVERY_WALLET`, emits `Recovered`. Explicit amounts, because another deposit's EURe +or USDC may be sitting on the clone. The on-chain delay is a coarse lower bound (it +counts from the first arrival of the batch, not per deposit); the keeper enforces the +per-deposit deadline exactly. If the keeper poked late (outage), the chain blocks +recovery for up to 2 h after the poke — acceptable. + +### 4.3 Orchestration (backend state machine, one row per recovered deposit) + +``` +deadline hit ──► recovering.moving recover(eure, usdc) on the clone [execution kind=recover] + ──► recovering.swapping reverse swap USDC→EURe from the recovery wallet [kind=reverse_swap] + ──► recovering.topping float sends (deposit − EURe held) to the recovery wallet [kind=topup] + ──► recovering.redeeming POST /orders kind=redeem, exact amount, source IBAN [financial_operations, exactly-once] + ──► refunded order.updated processed (webhook inbox already exists) + ──► recovery_failed any step exhausted its retries → runbook, alert +``` + +- **Trigger.** Per deposit: `mint block time + RECOVERY_DEADLINE` and the deposit is not + `forwarded`. Also operator-triggered via an admin endpoint for compliance/incident + cases. The account is flagged `recovering` so the executor stops chunking it; recovery + waits for any pending execution to settle before sending `recover` (both run under the + existing per-forwarder advisory lock). +- **Reverse swap.** `exactInput` of all recovered USDC over the reversed whitelisted + route (USDC → EURC → EURe), `minOut` from the keeper's reference with the same 40 bps + tolerance, through the private orderflow RPC. One code path: a shortfall is topped up + by the float, a surplus stays on the recovery wallet and is swept to the treasury. +- **Redeem.** The shared client already has `createRedemptionOrder` and + `buildMoneriumSepaRedemptionMessage` ("Send EUR {amount} to {iban} at {minute}", must + be within five minutes, signed by the recovery key). `amount` = the issue order's + amount string; `counterpart.identifier.iban` = the payer IBAN of the issue order; + `counterpart.details` = payer name/country from the same order; `memo` references the + original payment. The inbox/deposit processor is extended to accept `kind: "redeem"` + events for recovery-wallet addresses. +- **Ledger** (recovery row): EURe recovered, USDC recovered, EURe from reverse swap, + float top-up (= the subsidy figure product wants), surplus, fees already collected on + the deposit's chunks (offset, D9), redeem order id, timestamps per phase. +- **Rollout:** `MONERIUM_B2B_AUTO_RECOVERY=off | alert | auto`. `alert` computes and + logs the recovery plan for every breached deadline and an operator runs it via the + admin endpoint; `auto` executes it. Start in `alert`. + +### 4.4 Leaner alternative for the USDC leg (D4) + +**R2: no reverse swap in the critical path.** The float pays `deposit − EURe recovered` +in full, the redeem goes out immediately, and the recovered USDC is swept to the +subsidy vault (which needs USDC anyway) or sold back by a treasury job at leisure. +Recovery shrinks to `recover` → `topup` → redeem (three steps, no DEX interaction under +time pressure, no MEV exposure, no reverse-route liquidity dependency). Cost: the float +must be sized for the largest in-flight ticket, and the loss ledger becomes an internal +FX trade (float out in EURe, treasury in USDC) rather than a pure residue figure. Product +explicitly wants the residue-only subsidy ledger, so **R1 (reverse swap) is the plan and +R2 is the fallback** if the reverse route proves unreliable in the fork exercise. + +### 4.5 Feasibility: what is confirmed and what must be asked + +Confirmed in code/docs: + +- Monerium redeem orders to a SEPA IBAN exist, are signed with the message format the + shared client already builds, accept EOA signatures, and the API client is in place + (`packages/shared/src/services/monerium`). Orders ≥ €15,000 require + `supportingDocumentId`. SEPA Instant is used when the payer's bank supports it, else + next business day. +- The issue-order webhook already lands in the durable inbox. Monerium's OpenAPI spec + (`docs.monerium.com/redocusaurus/api.yaml`, `CounterpartResponse`) defines the + counterpart of **issue orders** as `identifier.iban` (or a generic `BankAccount`) plus + `details.name` (sender name, required) and an optional `details.address`; there is no + country and no first/last split. The refund order therefore uses `identifier.iban` + from the issue order, `details.companyName = name` and `details.country` from the IBAN + country prefix (corporate clients; individual payers need a name split). +- The keeper's crash-safe send pipeline and the exactly-once `financial_operations` + ledger are reusable for every recovery step. + +Must be verified with Monerium / in the sandbox before committing the contract shape: + +| # | Question | Decides | +|---|---|---| +| M1 | Does Monerium accept the Vortex-held fallback and one SatoshiPay profile refunding many client corporates (re-approval of G1 item 1)? (Address↔profile uniqueness is moot: one wallet under the company profile.) | G1, G2 | +| M2 | ~~Payer IBAN and name on issue orders~~ **Answered by the spec** (`CounterpartResponse`, "Issue orders": `identifier.iban`, `details.name`, optional `details.address`). Remaining: capture one real sandbox SEPA order to confirm the webhook carries the same object. | refund target derivation | +| M3 | Is `supportingDocumentId` required for a return-to-originator ≥ €15k, or can it be waived / auto-satisfied (e.g. the original payment confirmation)? | whether large-ticket refunds can be automated | +| M4 | Redeem to an IBAN that is not the profile holder's own (third-party payer) under a corporate profile; memo/reference conventions Monerium wants on a return; any native return facility (none documented). | D10, compliance | +| M5 | Outgoing limits, fees, cut-offs on redemptions. | promise wording | +| M6 | (Only if we ever drop the recovery wallet) EIP-1271 redeem from the clone itself. | R0 alternative | + +Verdict: **feasible**, with M3 as the one item that can block full automation. +If M3 is a hard requirement, refunds ≥ €15k stay `alert` mode with an operator upload, +which is still a big improvement over today (funds wait indefinitely). + +### 4.6 Failure modes + +| Failure | Behaviour | +|---|---| +| `recover` reverts (delay not elapsed, paused? no — recover ignores pause) | retry next cycle; alert after N | +| reverse route thin / quote below tolerance | retry with backoff up to 30 min, then fall back to R2 for this recovery (float pays all) | +| float empty | phase stalls at `topping`, error alert (new float-runway monitor); nothing is lost | +| Monerium rejects the order (compliance, document) | `recovery_failed`, runbook; funds sit on the recovery wallet | +| crash mid-step | every chain step is an execution row with nonce-before-broadcast; the redeem is a claimed `financial_operations` row | +| deposit swapped 100 % but `forward` not confirmed at the deadline | forward completes; the deadline applies to the last swap (D7) | +| second deposit lands during the first one's recovery | explicit amounts in `recover`; the second deposit keeps converting on its own timeline | + +## 5. Consequences product must see before saying yes + +1. **A deferral becomes a refund.** Today a weekend Chainlink gap makes the keeper + *defer* and the client waits. With a 2 h promise every deferral longer than 2 h is a + bank bounce. The drift replay (Coinbase EURC-USDC five-minute VWAP vs Chainlink EUR/USD, + 2025-09 → 2026-09) gives, at `SLIPPAGE_BPS = 40`, ~80 h/year of floor-cause deferral across 10 of + 52 weekends, episodes up to 29 h; at 60 bps it is ~0.2 h/year. **Decide + `SLIPPAGE_BPS` (60 recommended) before the immutable deploy**, or the refund path + fires on ordinary weekends. Independent of that, the 2025-10-10 depeg weekend (~48 h + out of band) would have refunded everything — correct behaviour, but say so in terms. +2. **The reference venue bug must be fixed first.** `reference-rate.ts` reads Coinbase + `EURC-USD`, which is delisted; today that means every swap defers, which under this + proposal means every deposit is refunded. Switch to `EURC-USDC` (pending decision). +3. **Fees on a refunded deposit.** Chunk fees already went to `FEE_RECIPIENT`; the float + still refunds the full amount. Net them in the ledger (D9); no on-chain claw-back. +4. **Gas and float.** One extra transaction per deposit (`forward`, ~70k gas) and four + per recovery. Float sizing = max concurrent tickets × slippage residue under R1 + (small), or × full ticket under R2. +5. **Trust and terms.** §2 above; the partner agreement's "Vortex cannot move funds" + language and the Monerium G1 approval both change. + +## 6. Change inventory + +**Contracts** (`contracts/monerium-forwarder`, Approach A): `VortexForwarder` as in §3.A; +`VortexForwarderFactory.deployForwarder` drops `fallbackAddress` (adds `recoveryWallet` if +per-clone); `ImmutableConfig` gains `recoveryWallet` (if shared) and `recoveryDelay`, +loses `sweepDelay`; `VortexSubsidyVault.pay` unchanged (the clone passes itself as `to`); +manifest v4 (`manifest-core.ts`, `verify-manifest.ts`). Tests: rewrite the sweep/fallback +tests into forward/recover/gating/pause tests; invariants "USDC leaves only to +destination or recovery wallet", "EURe leaves only to router or recovery wallet", +"recover impossible before `RECOVERY_DELAY`", "partial swap never resets `batchOpenedAt`"; +fork exercise (runbook §7) extended with a forward and a recovery. + +**Backend** (`apps/api`): migrations — `monerium_accounts` drop `fallback_address`, add +`recovery_wallet` (+ derivation index), `monerium_conversion_executions` add `kind`, +`deposit_id`, drop `monerium_deposit_allocations`, new `monerium_recoveries`, deposit +status enum extension; executor (1:N, `forward` step, `kind`-aware calldata expectations); +delete allocation code; recovery orchestrator + recovery/float signers (`chain.ts`); +deposit processor accepts redeem events; manager events (`DEPOSIT_CONVERTED` gains the +forward tx, new `DEPOSIT_RETURNED`); admin endpoints (trigger/list recoveries); monitors +(float runway, recovery-stuck, association monitor covers the recovery address); +config/env (`MONERIUM_B2B_RECOVERY_*`, `MONERIUM_B2B_FLOAT_PRIVATE_KEY`, +`MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES`, `MONERIUM_B2B_AUTO_RECOVERY`); provisioning +reads back `recoveryWallet` instead of `fallbackAddress`. + +**Shared/API contract**: `WebhookEventType.DEPOSIT_RETURNED`, payload types, `DepositStatus` +values; OpenAPI json/d.ts, `wire-contract.snapshot.md`, `docs/api/pages/07-webhooks.md` +and `14-managed-profiles.md`. + +**Docs**: ADR-0005 amendment 2 (decisions flipped, custody accepted, D-list outcomes, +registry rows P3→`RECOVERY_DELAY`, B5, new rows for float/recovery keys); +`architecture-monerium-b2b-onramp.md` (new sequence + lifecycle diagrams, fees section: +subsidy to the clone); `security-spec/05-integrations/monerium-b2b.md` (invariants 12, +keeper 1/3/4/5/6, monitoring 3/5, threat vectors: Vortex custody path, recovery-key +compromise); rollout (G1 re-approval + M1–M5, terms 2/6 rewrite, ledger); +runbook (§2 recovery operations, float operations, §3 triage rows, §5 destination +rotation now = new clone, §6 recovery/float keys, §7 fork exercise). + +**Kept from PR #1375 unchanged:** fee bands, reference VWAP, route whitelist, subsidy +vault and its limits, keeper deferral logic, crash recovery, monitors 1/2/4/6, the +managed-profile wiring, the durable inbox/outbox. + +## 7. Phasing and verification + +| Phase | Scope | Verify | +|---|---|---| +| 0 — prerequisites on PR #1375 | reference venue → `EURC-USDC`; decide `SLIPPAGE_BPS`; answers to M1–M3 (sandbox SEPA simulation covers M2) | forge + api suites green; sandbox order payload captured | +| 1 — whole-deposit settlement | Approach A contracts incl. the `recover()` primitive; executor 1:N + `forward`; delete N:M attribution; `DEPOSIT_CONVERTED` with forward tx; deposit statuses; manual recovery runbook + admin trigger (operator executes the four steps by hand) | forge unit/invariant/fork; api executor + manager-events tests; fork exercise §7 with a €60k deposit → 3 chunks → 1 forward | +| 2 — automated recovery | orchestrator in `alert` mode, then `auto`; recovery/float signers; redeem-event processing; float + recovery monitors | api state-machine tests with mocked chain; sandbox end-to-end refund (M2/M3 permitting); fork exercise recovery leg | +| 3 — partner surface | `DEPOSIT_RETURNED`, read API fields, OpenAPI/wire snapshot, docs pages | `bun docs:api:check`, `wire-contract:check`, integration test | + +Estimated shape: phase 1 is net-negative in backend lines (attribution deleted) and +~+150/−120 in Solidity; phase 2 is the bulk of new code (~800–1,000 lines incl. tests). + +## 8. Decisions needed + +| # | Decision | Recommendation | +|---|---|---| +| D1 | Window: 1 h or 2 h (immutable `RECOVERY_DELAY`, plus `RECOVERY_DEADLINE` config) | 2 h | +| D2 | Clock start: mint block time vs provider `processedAt` | mint block time | +| D3 | Recovery wallet: per client under the client's profile vs one wallet under a Vortex/SatoshiPay company profile | **decided: company profile** (§4.1) | +| D4 | USDC leg: R1 reverse swap (residue-only subsidy ledger) vs R2 float absorbs | R1, R2 as automatic fallback when the reverse route fails | +| D5 | Deposits below `minSwapAmount`: refund, merge with the next deposit, or lower the minimum | refund | +| D6 | Destination rotation without a client fallback key: new clone (runbook §5) vs guardian `setDestination` behind the 24 h timelock | new clone; add the setter only when a client asks | +| D7 | Deadline semantics when all chunks are swapped but not forwarded | forward completes; deadline gates the last swap | +| D8 | Refund on market-caused deferral (weekend drift, depeg) vs pause the clock while out of band | refund, with `SLIPPAGE_BPS = 60`; the promise must say so | +| D9 | Chunk fees on a refunded deposit | keep in treasury, net in the ledger | +| D10 | Third-party payer: refund to source IBAN always | yes (SEPA return semantics) | +| D11 | Approach A vs B (vs A′) | A | +| D12 | Rollout: ship phase 1 with manual recovery, automate in phase 2 | yes | From 0cb326b044440d17d65ee33fc7fec852fe9bf78f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 21:40:04 +0200 Subject: [PATCH 28/44] fix(api): read the reference from Coinbase EURC-USDC and monitor the venue Coinbase Exchange delisted EURC-USD on 2024-08-29; its candles endpoint still answers with two-year-old data, so every keeper swap deferred on "no volume in the last 60 minutes". EURC-USDC is the live market (USDC = USD is already assumption A4). A delisting is invisible to the keeper, so the monitoring pass now probes the product status every half hour. --- .../api/services/monerium-b2b/monitoring.ts | 16 +++++++ .../monerium-b2b/reference-rate.test.ts | 26 +++++++++- .../services/monerium-b2b/reference-rate.ts | 48 +++++++++++++++++-- docs/adr-0005-monerium-b2b-onramp.md | 6 +-- docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/api/openapi/vortex.openapi.json | 2 +- docs/api/pages/07-webhooks.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 4 +- docs/operations-monerium-b2b-rollout.md | 4 +- .../05-integrations/monerium-b2b.md | 2 +- .../shared/src/endpoints/webhook.endpoints.ts | 2 +- 11 files changed, 97 insertions(+), 17 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 39fbfe312..f90b38ade 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -18,6 +18,7 @@ import { SubsidyVaultState } from "./chain"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; +import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; /** * Monitoring pass for the Monerium B2B onramp (implementation plan D3 / phase 3), run @@ -47,6 +48,9 @@ import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./moneri * construction (`onlyFallback` in the contract) — they are reconciled into the DB * and logged, not alarmed, as are guardian fee-policy changes (P11); bytecode or * registration drift is an incident. + * 6. Reference-venue monitor: the Coinbase product the reference VWAP reads. A delisted + * or halted product keeps answering the candles endpoint with stale data, so every + * keeper swap would defer silently; its status is probed instead of assumed. * * None of these monitors hold keys or send transactions; they are detection-only. */ @@ -572,6 +576,17 @@ export async function runSubsidyVaultMonitor(): Promise { } } +/** Reference-venue monitor: a product that is not online makes every keeper swap defer. */ +export async function runReferenceVenueMonitor(): Promise { + const product = await fetchCoinbaseProductStatus(); + const reason = classifyReferenceVenue(product); + if (reason) { + logger.error(`monerium-b2b: REFERENCE VENUE — ${reason}; every keeper swap defers until the reference source is changed`); + } else { + logger.info(`monerium-b2b: reference venue ok (${COINBASE_REFERENCE_PRODUCT} ${product.status})`); + } +} + // ------------------------------------------------------------------ pass orchestration let lastPassAt = 0; @@ -598,6 +613,7 @@ export async function runMonitoringPass(now: number = Date.now()): Promise return; } lastPassAt = now; + await guarded("reference-venue monitor", runReferenceVenueMonitor); if (config.moneriumB2b.rpcUrl) { await guarded("executable-depth check", runExecutableDepthCheck); await guarded("stranded-balance monitor", () => runStrandedBalanceMonitor(now)); diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts index ee5b54021..f76bc54db 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts @@ -122,6 +122,30 @@ describe("fetchCoinbaseReference", () => { ); await expect( fetchCoinbaseReference(DECIMALS, fakeFetch(200, [candle(END - 60, 1.14, 0)]).fetchImpl, nowMs) - ).rejects.toThrow("no EURC-USD volume"); + ).rejects.toThrow("no EURC-USDC volume"); + }); +}); + +import { classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; + +describe("reference venue status", () => { + it("accepts only an online product with trading enabled", () => { + expect(classifyReferenceVenue({ status: "online", tradingDisabled: false })).toBeNull(); + expect(classifyReferenceVenue({ status: "delisted", tradingDisabled: true })).toContain("is delisted"); + expect(classifyReferenceVenue({ status: "online", tradingDisabled: true })).toContain("trading disabled"); + }); + + it("reads the product status from Coinbase and rejects malformed answers", async () => { + const fetchImpl = async (url: string) => { + expect(url).toBe("https://api.exchange.coinbase.com/products/EURC-USDC"); + return { json: async () => ({ id: "EURC-USDC", status: "online", trading_disabled: false }), ok: true, status: 200 }; + }; + expect(await fetchCoinbaseProductStatus(fetchImpl)).toEqual({ status: "online", tradingDisabled: false }); + await expect( + fetchCoinbaseProductStatus(async () => ({ json: async () => ({ status: "online" }), ok: true, status: 200 })) + ).rejects.toThrow("malformed"); + await expect(fetchCoinbaseProductStatus(async () => ({ json: async () => null, ok: false, status: 503 }))).rejects.toThrow( + "503" + ); }); }); diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.ts index 7fe5d8786..0313d4cdf 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -3,7 +3,7 @@ import { formatUnits, parseUnits } from "viem"; /** * Partner reference rate for the forwarder fee bands (docs/adr-0005-monerium-b2b-onramp.md, P12): * a volume-weighted average price over the last five minutes of Coinbase Exchange - * EURC-USD one-minute candles, computed fresh before every swap and recorded on the + * EURC-USDC one-minute candles, computed fresh before every swap and recorded on the * execution row (rate, window, time) so the partner can recompute it from Coinbase's * public candle history. Averaging instead of taking the last tick keeps a single thin * print — common on weekends and outside business hours — from becoming the reference. @@ -12,8 +12,15 @@ import { formatUnits, parseUnits } from "viem"; * rate into swapAndForward; the contract rejects it outside its Chainlink band. */ -export const COINBASE_EURC_CANDLES_URL = "https://api.exchange.coinbase.com/products/EURC-USD/candles"; -export const COINBASE_REFERENCE_SOURCE = "coinbase-exchange:EURC-USD:vwap"; +/** + * The Coinbase Exchange product the reference is read from. EURC-USD and EURC-EUR were + * delisted on 2024-08-29 and still answer the candles endpoint with two-year-old data, + * so the product's status is monitored (`fetchCoinbaseProductStatus`), not assumed. + */ +export const COINBASE_REFERENCE_PRODUCT = "EURC-USDC"; +export const COINBASE_EURC_PRODUCT_URL = `https://api.exchange.coinbase.com/products/${COINBASE_REFERENCE_PRODUCT}`; +export const COINBASE_EURC_CANDLES_URL = `${COINBASE_EURC_PRODUCT_URL}/candles`; +export const COINBASE_REFERENCE_SOURCE = `coinbase-exchange:${COINBASE_REFERENCE_PRODUCT}:vwap`; export const REFERENCE_WINDOW_SECONDS = 5 * 60; export const REFERENCE_FALLBACK_WINDOW_SECONDS = 60 * 60; const CANDLE_GRANULARITY_SECONDS = 60; @@ -123,7 +130,9 @@ export async function fetchCoinbaseReference( } const window = selectReferenceWindow(parseCandles(await response.json()), windowEndSec, decimals); if (!window) { - throw new Error(`no EURC-USD volume on Coinbase in the last ${REFERENCE_FALLBACK_WINDOW_SECONDS / 60} minutes`); + throw new Error( + `no ${COINBASE_REFERENCE_PRODUCT} volume on Coinbase in the last ${REFERENCE_FALLBACK_WINDOW_SECONDS / 60} minutes` + ); } return { price: formatUnits(window.rateRaw, decimals), @@ -133,3 +142,34 @@ export async function fetchCoinbaseReference( windowSeconds: window.windowSeconds }; } + +// ------------------------------------------------------------------ venue status + +export interface CoinbaseProductStatus { + status: string; + tradingDisabled: boolean; +} + +/** Why the reference product cannot serve as the venue right now, or null when it can. */ +export function classifyReferenceVenue(product: CoinbaseProductStatus): string | null { + if (product.status !== "online") { + return `Coinbase product ${COINBASE_REFERENCE_PRODUCT} is ${product.status}`; + } + if (product.tradingDisabled) { + return `Coinbase product ${COINBASE_REFERENCE_PRODUCT} has trading disabled`; + } + return null; +} + +/** Live status of the reference product. Any failure throws; the monitor reports it. */ +export async function fetchCoinbaseProductStatus(fetchImpl: FetchLike = fetch): Promise { + const response = await fetchImpl(COINBASE_EURC_PRODUCT_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Coinbase product responded ${response.status}`); + } + const body = (await response.json()) as { status?: unknown; trading_disabled?: unknown } | null; + if (typeof body?.status !== "string" || typeof body.trading_disabled !== "boolean") { + throw new Error("Coinbase product response is malformed"); + } + return { status: body.status, tradingDisabled: body.trading_disabled }; +} diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 4633425b0..97c74e44b 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -92,13 +92,13 @@ Supporting decisions, all in force: ## Amendment 2026-09-15: reference-priced fee bands and the subsidy vault The partner agreement fixes the client's rate against a reference: the client receives -the Coinbase EURC-USD reference minus 12.5 bps, and never worse than 15 bps below it. +the Coinbase EURC-USDC reference minus 12.5 bps, and never worse than 15 bps below it. A flat skim on whatever the DEX returns cannot express that, so the contract now settles every fill into bands against a reference rate (decided with the partner; contracts were not yet deployed, so this replaced the flat fee before launch with no migration): - **Reference rate.** Before each swap the keeper computes a five-minute - volume-weighted average of Coinbase Exchange EURC-USD one-minute candles (typical + volume-weighted average of Coinbase Exchange EURC-USDC one-minute candles (typical price × volume), widened to an hour when the five minutes carry no volume, so a single thin print on a weekend or outside business hours never becomes the reference (suggested in review, 2026-09-15). It records price, window and time on the @@ -161,7 +161,7 @@ not yet deployed, so this replaced the flat fee before launch with no migration) | P9 | Notification confirmation depth | 32 blocks (implemented) | | P10 | Router pin and routes | SwapRouter02 immutable; routes are a guardian-managed, on-chain validated whitelist (EURe/EURC/USDC, four tiers, ≤ 2 hops); initial route EURe→EURC→USDC at the 5 bps tiers, re-verify at the deploy block (amended 2026-09-15) | | P11 | Fee adjustability | Guardian `setFeePolicy(target, floor)` within `MAX_FEE_PPM`; raising either value is announced and applies after 24 h, lowering is immediate (amended 2026-09-15) | -| P12 | Reference rate | Five-minute VWAP over Coinbase Exchange EURC-USD one-minute candles (widened to 60 min when the five minutes have no volume), keeper-computed per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15). The floor on the net binds first: with `floorPpm` 15 bps and `SLIPPAGE_BPS` 40 bps, a reference more than `SLIPPAGE_BPS − floorPpm` ≈ 25 bps below Chainlink makes every normal fill (fee band or subsidized) revert on chain and defer off chain, so ~25 bps is the working downside margin against a stale round; the 100 bps band is the outlier ceiling for a keeper-supplied value, not the operating tolerance (2026-09-16) | +| P12 | Reference rate | Five-minute VWAP over Coinbase Exchange EURC-USDC one-minute candles (widened to 60 min when the five minutes have no volume), keeper-computed per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15). The floor on the net binds first: with `floorPpm` 15 bps and `SLIPPAGE_BPS` 40 bps, a reference more than `SLIPPAGE_BPS − floorPpm` ≈ 25 bps below Chainlink makes every normal fill (fee band or subsidized) revert on chain and defer off chain, so ~25 bps is the working downside margin against a stale round; the 100 bps band is the outlier ceiling for a keeper-supplied value, not the operating tolerance (2026-09-16) | | P13 | Subsidy vault limits | One shared vault; **50 bps of the reference value per swap, 200 USDC per UTC day** at launch, guardian-settable; withdraw to treasury only (2026-09-15) | | T2 | Whitelabel MSA terms | Open — G1 negotiation (rollout doc), includes the per-IBAN suspension ask | | T3 | KYB submission mechanism | Open, deliberately unbuilt — pilot corporates are approved by Monerium under partner KYC reliance and imported via the admin mapping; no identity-data submission path may exist until this settles (security-spec invariant 11) | diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index fa3a0343c..577628afb 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2891,7 +2891,7 @@ export interface components { execution: { /** @description Fee taken on the whole execution in 6-decimal base units. */ feeRaw: string | null; - /** @description Reference EUR/USD rate the execution was priced against: a volume-weighted average of the Coinbase Exchange EURC-USD market over the five minutes before the swap (widened to sixty minutes when those five carry no trades), in the oracle's decimals (8). */ + /** @description Reference EUR/USD rate the execution was priced against: a volume-weighted average of the Coinbase Exchange EURC-USDC market over the five minutes before the swap (widened to sixty minutes when those five carry no trades), in the oracle's decimals (8). */ referenceRateRaw: string | null; /** @description Subsidy paid by the vault straight to the destination for the whole execution in 6-decimal base units. */ subsidyRaw: string | null; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 6a48d26fe..354577384 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2587,7 +2587,7 @@ "type": ["string", "null"] }, "referenceRateRaw": { - "description": "Reference EUR/USD rate the execution was priced against: a volume-weighted average of the Coinbase Exchange EURC-USD market over the five minutes before the swap (widened to sixty minutes when those five carry no trades), in the oracle's decimals (8).", + "description": "Reference EUR/USD rate the execution was priced against: a volume-weighted average of the Coinbase Exchange EURC-USDC market over the five minutes before the swap (widened to sixty minutes when those five carry no trades), in the oracle's decimals (8).", "type": ["string", "null"] }, "subsidyRaw": { diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index ba89d68fb..28db4d317 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -180,7 +180,7 @@ Fired once per deposit after the full deposit has been converted and every contr Each `conversions[]` entry contains the EURe portion consumed and the net USDC attributed to this deposit by that execution. The payload-level `usdcNetRaw` is their aggregate. When one execution consumes several deposits, its output is divided proportionally by allocated EURe; floor dust goes to the largest allocation. -The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against, a volume-weighted average of the Coinbase Exchange EURC-USD market over the five minutes before the swap, widened to sixty minutes when those five carry no trades (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. +The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against, a volume-weighted average of the Coinbase Exchange EURC-USDC market over the five minutes before the swap, widened to sixty minutes when those five carry no trades (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. ### Delivery Semantics diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 36c408f53..25cedc670 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -57,7 +57,7 @@ flowchart LR end subgraph Reference["Reference rate"] - CB[Coinbase Exchange\nEURC-USD 1-min candles] + CB[Coinbase Exchange\nEURC-USDC 1-min candles] end subgraph Vortex["Vortex API (keeper backend)"] @@ -305,7 +305,7 @@ settles every fill into three bands against that reference (decisions: [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md), amendment). - **Reference rate.** Before each swap the keeper computes a five-minute volume-weighted - average of Coinbase Exchange EURC-USD one-minute candles (`reference-rate.ts`: typical + average of Coinbase Exchange EURC-USDC one-minute candles (`reference-rate.ts`: typical price `(low + high + close) / 3` weighted by volume; widened to an hour when the five minutes carry no volume, so a single thin weekend print never becomes the reference), stores price, window and time on the execution row, and passes the rate into diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 016b94a0a..a58860033 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -123,7 +123,7 @@ fee policy 12.5 bps target / 15 bps floor (B1). 4. **Rate, fee and subsidy (B1/P1/P2/P12/P13)** — disclose the guarantee, the fee and the hard bound separately: - Reference rate: a five-minute volume-weighted average of the Coinbase Exchange - EURC-USD market, computed immediately before each conversion from public + EURC-USDC market, computed immediately before each conversion from public one-minute candles and recorded with the conversion (widened to a one-hour average when the five minutes carry no trades). The agreement's "Coinbase EURC oracle" — align the wording; the source is the exchange market, weekdays and @@ -177,7 +177,7 @@ fee policy 12.5 bps target / 15 bps floor (B1). | SulPayments agreement (terms above) | Marcel ↔ partner | Drafting inputs ready | | Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering (needs Marcel's sandbox login) | Open — only remaining engineering unknown | | Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | -| Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses a five-minute VWAP of Coinbase Exchange EURC-USD candles — confirm that is what was meant | +| Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses a five-minute VWAP of Coinbase Exchange EURC-USDC candles — confirm that is what was meant | | Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 25 bps, not the band: a market more than ~25 bps under a stale Chainlink round defers every swap until the round updates — decide whether that is acceptable or whether `SLIPPAGE_BPS`/`floorPpm` move before the deploy | | Subsidy vault funding and refill cadence | Ops | Before first activation; runbook §2.6 | | GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 29fac90e5..14499d903 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -41,7 +41,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, execution slot check/creation, finalization, R04 allocation) 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. 5. **Attribution is N:M, cursor-gated, exact-snapshot, and idempotent (R04)** — a confirmed execution records the block and block-global `SwapExecuted` log index but is not allocated immediately. Reconciliation starts only after the persisted mint cursor has processed that block, then consumes outstanding portions of deposits minted in earlier blocks or earlier log positions in the same block, oldest-first up to `eureInRaw`. This covers a mint that lands between the executor's balance read and swap without assigning a later same-block mint to the execution. A cap-cut deposit receives a partial `monerium_deposit_allocations` row and its remainder participates in the next execution; one execution may likewise allocate across many deposits. Each row records its EURe portion and proportional net USDC; execution net is computed as `usdcOut - fee + subsidy` (the vault pays the subsidy straight to the destination), never the event's `forwarded` full-balance sweep, so pre-existing unsolicited USDC is not misreported as this deposit's yield. Floor dust goes to the largest allocation only when indexed deposits cover the whole execution, so missing inflow cannot inflate a customer's share. Mint identity is `(chain_id, tx_hash, log_index)` and the watcher scans 12-deep blocks. Only chain-indexed deposits make an account a conversion candidate; a raw forwarder balance never bypasses the watcher. Non-Monerium inflows become `unattr:` rows and never surface as customer claims. 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, 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 or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. -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 computes a five-minute volume-weighted average of Coinbase Exchange EURC-USD one-minute candles (`reference-rate.ts`; widened to an hour when the five minutes carry no volume, so one thin print cannot set the reference), records price, window and time on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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 at its destination, 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 computes a five-minute volume-weighted average of Coinbase Exchange EURC-USDC one-minute candles (`reference-rate.ts`; widened to an hour when the five minutes carry no volume, so one thin print cannot set the reference), records price, window and time on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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 at its destination, 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. ## Monitoring diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index e31bad3ab..cadbd8974 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -109,7 +109,7 @@ export interface DepositReceivedWebhookPayload { export interface ConversionExecutionPricing { /** Fee taken on the execution (6-decimal base units). */ feeRaw: string | null; - /** Reference EUR/USD rate the execution was priced against (a Coinbase EURC-USD VWAP over the five minutes before the swap, or sixty when those carry no volume), in the oracle's decimals (8). */ + /** Reference EUR/USD rate the execution was priced against (a Coinbase EURC-USDC VWAP over the five minutes before the swap, or sixty when those carry no volume), in the oracle's decimals (8). */ referenceRateRaw: string | null; /** Subsidy paid by the vault straight to the destination (6-decimal base units). */ subsidyRaw: string | null; From 145d23a865539f70d005b5b3aa85a256be604fa2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 21:52:57 +0200 Subject: [PATCH 29/44] feat(repo): accumulate swaps on the forwarder and add a delay-gated recovery Product wants one USDC transfer per bank payment and an automatic refund when a payment cannot be converted inside the promised window, with Vortex holding the funds for that refund. The clone now keeps every chunk's USDC (subsidy included) until the keeper forwards the whole payment, and can move a stuck payment to the immutable Vortex recovery wallet once its batch has been open for RECOVERY_DELAY. The client-held fallback role, its sweep and the dead-man sweep go away; the permissionless swap/forwardAll path after TRIGGER_DELAY stays as the liveness guarantee. SLIPPAGE_BPS fixtures move to 60 so weekend Chainlink drift no longer turns into refunds. Manifest v4 follows the new immutables. --- contracts/monerium-forwarder/README.md | 9 +- .../script/manifest-core.ts | 44 +- .../script/verify-manifest.test.ts | 4 +- .../script/verify-manifest.ts | 22 +- .../src/VortexForwarder.sol | 266 +++++---- .../src/VortexForwarderFactory.sol | 23 +- .../test/VortexForwarder.fork.t.sol | 42 +- .../test/VortexForwarder.invariants.t.sol | 137 +++-- .../test/VortexForwarder.t.sol | 539 ++++++++++-------- 9 files changed, 608 insertions(+), 478 deletions(-) diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index 89ec7ce4f..53b1bfc77 100644 --- a/contracts/monerium-forwarder/README.md +++ b/contracts/monerium-forwarder/README.md @@ -5,7 +5,9 @@ per-client EIP-1167 clones whose EIP-1271 `isValidSignature` accepts only the fi Monerium link message from the Vortex attestor, a conversion policy that swaps over a factory-whitelisted Uniswap v3 route and settles the fill against a partner reference rate (fee above the target, top-up from the shared `VortexSubsidyVault` below the floor, -Chainlink floor on the client's net), and client-controlled recovery. +Chainlink floor on the client's net), whole-payment forwarding (chunks accumulate as USDC on the +clone and leave in one `forward`), and a keeper-only, delay-gated `recover` to the immutable Vortex +recovery wallet for bank refunds. - Spec: [docs/architecture-monerium-b2b-onramp.md](../../docs/architecture-monerium-b2b-onramp.md) §2 and its "Fees, reference rate and subsidy" section @@ -44,6 +46,5 @@ node. Published manifests live in `manifests/`. produced by Vortex from the same chain state it attests to, so a verifier pass proves only that the deployment has not silently changed since publication — not that it was honest. Independent verification of contract behavior requires the verified source on a -block explorer. Client-authorized config changes (destination/fallback rotation by the -client's own fallbackAddress) are reported as expected transitions, not failures -(re-review R07). +block explorer. The per-clone destination has no setter, so any change to it is reported as +a failure like every other immutable; guardian-tunable parameters are notices. diff --git a/contracts/monerium-forwarder/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts index 8e12ba91b..a66d6f99f 100644 --- a/contracts/monerium-forwarder/script/manifest-core.ts +++ b/contracts/monerium-forwarder/script/manifest-core.ts @@ -11,7 +11,7 @@ import { Address, getAddress, Hex, keccak256, PublicClient, parseAbi, parseAbiIt * source on a block explorer. */ -export const MANIFEST_VERSION = 3; +export const MANIFEST_VERSION = 4; export const MANIFEST_PURPOSE = "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). " + @@ -37,12 +37,11 @@ export const factoryAbi = parseAbi([ ]); export const forwarderDeployedEvent = parseAbiItem( - "event ForwarderDeployed(address indexed forwarder, address indexed destination, address fallbackAddress, uint32 targetPpm, uint32 floorPpm, bytes32 salt)" + "event ForwarderDeployed(address indexed forwarder, address indexed destination, uint32 targetPpm, uint32 floorPpm, bytes32 salt)" ); export const forwarderConfigAbi = parseAbi([ "function destination() view returns (address)", - "function fallbackAddress() view returns (address)", "function targetPpm() view returns (uint32)", "function floorPpm() view returns (uint32)" ]); @@ -57,11 +56,12 @@ export const implementationAbi = parseAbi([ "function FACTORY() view returns (address)", "function ATTESTOR() view returns (address)", "function FEE_RECIPIENT() view returns (address)", + "function RECOVERY_WALLET() view returns (address)", "function MAX_ORACLE_AGE() view returns (uint256)", "function SLIPPAGE_BPS() view returns (uint16)", "function MAX_FEE_PPM() view returns (uint32)", "function MAX_REFERENCE_DEVIATION_BPS() view returns (uint16)", - "function SWEEP_DELAY() view returns (uint256)", + "function RECOVERY_DELAY() view returns (uint256)", "function TRIGGER_DELAY() view returns (uint256)", "function LINK_HASH_191() view returns (bytes32)", "function RECOVERY_HASH() view returns (bytes32)", @@ -84,25 +84,17 @@ export interface ImplementationImmutables { MAX_REFERENCE_DEVIATION_BPS: number; ORACLE: string; ORACLE_DECIMALS: number; + RECOVERY_DELAY: string; RECOVERY_HASH: Hex; + RECOVERY_WALLET: string; ROUTER: string; SLIPPAGE_BPS: number; - SWEEP_DELAY: string; TRIGGER_DELAY: string; USDC: string; } export interface ForwarderManifestEntry { address: string; - /** - * Mutable ONLY by the client's fallbackAddress (contract `onlyFallback`). A drift here - * is an owner-authorized state transition, not an incident (re-review R07): the - * verifier reports it as EXPECTED-TRANSITION and the manifest should be regenerated. - */ - clientMutable: { - destination: string; - fallbackAddress: string; - }; deploy: { blockNumber: string; salt: Hex; @@ -113,8 +105,13 @@ export interface ForwarderManifestEntry { floorPpm: number; targetPpm: number; }; - /** Factory registration is fixed for the lifetime of the clone. Mismatch = incident. */ + /** + * Fixed for the lifetime of the clone: the destination has no setter (a client wallet + * change means a new clone, runbook §5) and factory registration never changes. + * Mismatch = incident. + */ immutables: { + destination: string; isForwarder: boolean; }; /** keccak256 of the clone's runtime code; must equal the EIP-1167 code for `implementation.address`. */ @@ -335,7 +332,8 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres slippageBps, maxFeePpm, maxReferenceDeviationBps, - sweepDelay, + recoveryWallet, + recoveryDelay, triggerDelay, linkHash191, recoveryHash, @@ -354,7 +352,8 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres read(client, implementationAbi, implementation, "SLIPPAGE_BPS"), read(client, implementationAbi, implementation, "MAX_FEE_PPM"), read(client, implementationAbi, implementation, "MAX_REFERENCE_DEVIATION_BPS"), - read(client, implementationAbi, implementation, "SWEEP_DELAY"), + read
(client, implementationAbi, implementation, "RECOVERY_WALLET"), + read(client, implementationAbi, implementation, "RECOVERY_DELAY"), read(client, implementationAbi, implementation, "TRIGGER_DELAY"), read(client, implementationAbi, implementation, "LINK_HASH_191"), read(client, implementationAbi, implementation, "RECOVERY_HASH"), @@ -395,10 +394,11 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres MAX_REFERENCE_DEVIATION_BPS: Number(maxReferenceDeviationBps), ORACLE: getAddress(oracle), ORACLE_DECIMALS: Number(oracleDecimals), + RECOVERY_DELAY: recoveryDelay.toString(), RECOVERY_HASH: recoveryHash, + RECOVERY_WALLET: getAddress(recoveryWallet), ROUTER: getAddress(router), SLIPPAGE_BPS: Number(slippageBps), - SWEEP_DELAY: sweepDelay.toString(), TRIGGER_DELAY: triggerDelay.toString(), USDC: getAddress(usdc) }, @@ -416,9 +416,8 @@ export async function readForwarderEntry( ): Promise { const factory = getAddress(factoryAddress); const forwarder = getAddress(forwarderAddress); - const [destination, fallbackAddress, targetPpm, floorPpm, isForwarder, forwarderCodeHash] = await Promise.all([ + const [destination, targetPpm, floorPpm, isForwarder, forwarderCodeHash] = await Promise.all([ read
(client, forwarderConfigAbi, forwarder, "destination"), - read
(client, forwarderConfigAbi, forwarder, "fallbackAddress"), read(client, forwarderConfigAbi, forwarder, "targetPpm"), read(client, forwarderConfigAbi, forwarder, "floorPpm"), read(client, factoryAbi, factory, "isForwarder", [forwarder]), @@ -426,10 +425,6 @@ export async function readForwarderEntry( ]); return { address: forwarder, - clientMutable: { - destination: getAddress(destination), - fallbackAddress: getAddress(fallbackAddress) - }, deploy: { blockNumber: deploy.blockNumber.toString(), salt: deploy.salt, @@ -440,6 +435,7 @@ export async function readForwarderEntry( targetPpm: Number(targetPpm) }, immutables: { + destination: getAddress(destination), isForwarder }, runtimeBytecodeHash: forwarderCodeHash diff --git a/contracts/monerium-forwarder/script/verify-manifest.test.ts b/contracts/monerium-forwarder/script/verify-manifest.test.ts index a5b7081f5..cfde5a441 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.test.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.test.ts @@ -9,8 +9,8 @@ describe("manifest diff severity", () => { expect(severityFor("factory.operational.routes.0.enabled")).toBe("NOTICE"); }); - it("keeps client changes expected and immutable changes fatal", () => { - expect(severityFor("forwarders.0x123.clientMutable.destination")).toBe("EXPECTED-TRANSITION"); + it("treats the per-clone destination and registration as immutable", () => { + expect(severityFor("forwarders.0x123.immutables.destination")).toBe("FAIL"); expect(severityFor("forwarders.0x123.immutables.isForwarder")).toBe("FAIL"); expect(severityFor("forwarders.0x123.runtimeBytecodeHash")).toBe("FAIL"); }); diff --git a/contracts/monerium-forwarder/script/verify-manifest.ts b/contracts/monerium-forwarder/script/verify-manifest.ts index 10490126e..271b22319 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.ts @@ -34,17 +34,13 @@ import { * contract source on a block explorer. * * Severity classes: - * FAIL immutable/bytecode/deploy-provenance mismatch -> exit 1 - * EXPECTED-TRANSITION clientMutable fields (destination/fallbackAddress) changed by - * the client's own fallbackAddress (`onlyFallback` in the - * contract). Owner-authorized, not an incident (re-review R07); - * regenerate + republish the manifest. exit 0 - * NOTICE guardian-tunable forwarder/factory parameters, a stale - * forwarder list (new deployments since publication), or a - * skipped completeness check. exit 0 + * FAIL immutable/bytecode/deploy-provenance mismatch (the per-clone destination + * is immutable too: it has no setter) -> exit 1 + * NOTICE guardian-tunable forwarder/factory parameters, a stale forwarder list + * (new deployments since publication), or a skipped completeness check. exit 0 */ -export type Severity = "FAIL" | "EXPECTED-TRANSITION" | "NOTICE"; +export type Severity = "FAIL" | "NOTICE"; export interface Diff { actual: string; @@ -70,7 +66,6 @@ function flatten(value: unknown, prefix: string, out: Map): void } export function severityFor(path: string): Severity { - if (path.includes(".clientMutable.")) return "EXPECTED-TRANSITION"; if (path.includes(".guardianMutable.")) return "NOTICE"; if (path.includes(".operational.")) return "NOTICE"; return "FAIL"; @@ -192,16 +187,15 @@ async function main(): Promise { } const failures = diffs.filter(diff => diff.severity === "FAIL").length; - const transitions = diffs.filter(diff => diff.severity === "EXPECTED-TRANSITION").length; const notices = diffs.filter(diff => diff.severity === "NOTICE").length; if (failures > 0) { - console.log(`VERIFICATION FAILED: ${failures} mismatch(es), ${transitions} expected transition(s), ${notices} notice(s)`); + console.log(`VERIFICATION FAILED: ${failures} mismatch(es), ${notices} notice(s)`); process.exit(1); } - if (transitions > 0 || notices > 0) { + if (notices > 0) { console.log( - `VERIFICATION PASSED with ${transitions} owner-authorized transition(s) and ${notices} notice(s) — ` + + `VERIFICATION PASSED with ${notices} notice(s) — ` + "regenerate and republish the manifest to fold them in (consistency evidence only, NOT a trust root — R01)" ); return; diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index 03da7ea41..df2eec6e2 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -51,11 +51,15 @@ interface IVortexSubsidyVault { /// Deployed as an EIP-1167 clone by VortexForwarderFactory; the clone address is /// linked to the client's Monerium profile, EURe mints land here, and the only /// ways assets can ever leave are: -/// 1. a factory-whitelisted EURe -> USDC swap (oracle-floored, output to self), +/// 1. a factory-whitelisted EURe -> USDC swap (oracle-floored, output kept here), /// 2. USDC to the client's `destination` (plus a fee <= MAX_FEE_PPM to FEE_RECIPIENT), -/// 3. EURe to the client's `fallbackAddress` (delayed permissionless sweep), -/// 4. anything, by the client's `fallbackAddress` itself (`sweep`). -/// Vortex (guardian/keeper) can execute the policy, pause it, and nothing else. +/// 3. EURe and USDC to the immutable Vortex RECOVERY_WALLET, only by the keeper and +/// only once a batch has been open for RECOVERY_DELAY (the refund path). +/// The keeper converts a bank payment in `swap` chunks that accumulate as USDC on +/// the clone and pushes the whole payment to `destination` with one `forward`, so +/// the client sees one USDC transfer per pay-in. Vortex (guardian/keeper) can +/// execute that policy, pause it, recover a stuck payment to its own wallet for a +/// bank refund, and nothing else. /// @dev EIP-1271 is deliberately constrained to the fixed Monerium link message hash /// signed by ATTESTOR and bound to this clone's address — it must never validate /// redeem orders (that would hand Vortex fiat-payout power; see variant doc §3.2). @@ -81,6 +85,9 @@ contract VortexForwarder { IVortexForwarderFactory public immutable FACTORY; address public immutable ATTESTOR; // signs the Monerium link attestation address public immutable FEE_RECIPIENT; + /// @dev The only address a recovery can move funds to: a Vortex wallet linked to a + /// Vortex company profile at Monerium, from which the bank refund is redeemed. + address public immutable RECOVERY_WALLET; uint256 public immutable MAX_ORACLE_AGE; // registry P8 uint16 public immutable SLIPPAGE_BPS; // registry P1: floor on the client's NET, after fee and subsidy uint32 public immutable MAX_FEE_PPM; // registry P2: caps both the fee and the floor policy @@ -88,12 +95,13 @@ contract VortexForwarder { /// pricing power: a wrong reference can move fee/subsidy only inside this band, and /// MAX_FEE_PPM plus the vault's caps bound it further. uint16 public immutable MAX_REFERENCE_DEVIATION_BPS; - uint256 public immutable SWEEP_DELAY; // registry P3 + /// @dev Registry P3: how long a batch must have been open before the keeper may move + /// it to RECOVERY_WALLET — the promised conversion window, enforced on chain. + uint256 public immutable RECOVERY_DELAY; uint256 public immutable TRIGGER_DELAY; // registry P4 - /// @dev EIP-191 personal-message hash and raw keccak of LINK_MESSAGE. Monerium's - /// exact hashing scheme is a G0 spike output (task 4); accepting both is safe - /// because both encode only the fixed link message. + /// @dev EIP-191 personal-message hash of LINK_MESSAGE, the only hash the attestor's + /// signature is accepted for (G0 sandbox validation confirmed Monerium presents it). bytes32 public immutable LINK_HASH_191; /// @dev Monerium issuer-recovery message hash (registry T1). bytes32(0) = disabled. @@ -110,11 +118,12 @@ contract VortexForwarder { address oracle; address attestor; address feeRecipient; + address recoveryWallet; uint256 maxOracleAge; uint16 slippageBps; uint32 maxFeePpm; uint16 maxReferenceDeviationBps; - uint256 sweepDelay; + uint256 recoveryDelay; uint256 triggerDelay; bytes32 recoveryHash; } @@ -124,7 +133,6 @@ contract VortexForwarder { bool public initialized; address public destination; // client's payout address (may be a CEX deposit address) - address public fallbackAddress; // client's self-custodied recovery address (mandatory) /// @dev Fee policy, in ppm below the reference rate. The client is targeted at /// reference x (1 - targetPpm): any fill above that becomes fee (<= MAX_FEE_PPM); /// a fill below reference x (1 - floorPpm) is topped up from the subsidy vault. @@ -138,28 +146,29 @@ contract VortexForwarder { uint32 public pendingFloorPpm; uint64 public pendingFeePolicyEffectiveAt; - bool public clientPaused; // set by fallbackAddress only - bool public guardianPaused; // set by guardian only (protective-only; cannot block fallback paths) + bool public guardianPaused; // set by guardian only (protective-only; never blocks recovery) - /// @dev R03 marker: when the EURe balance first crossed minSwapAmount with no - /// successful swap since. Start time for TRIGGER_DELAY and SWEEP_DELAY. - uint64 public strandedSince; + /// @dev When the current batch opened: the first time funds (EURe >= MIN_SWAP_FLOOR + /// or any USDC) were seen on the clone since it was last emptied by a forward or + /// a recovery. Start time for RECOVERY_DELAY and TRIGGER_DELAY. A partial swap + /// never re-times it, so chunking cannot restart the recovery clock. + uint64 public batchOpenedAt; uint256 private _reentrancyGuard; // ----------------------------------------------------------------- events - event Initialized(address destination, address fallbackAddress, uint32 targetPpm, uint32 floorPpm); + event Initialized(address destination, uint32 targetPpm, uint32 floorPpm); event FeePolicyDecreased(uint32 previousTarget, uint32 previousFloor, uint32 target, uint32 floor); event FeePolicyIncreaseAnnounced( uint32 currentTarget, uint32 currentFloor, uint32 pendingTarget, uint32 pendingFloor, uint64 effectiveAt ); event FeePolicyIncreaseApplied(uint32 previousTarget, uint32 previousFloor, uint32 target, uint32 floor); event FeePolicyIncreaseCancelled(uint32 pendingTarget, uint32 pendingFloor); - event Poked(uint64 strandedSince); + event Poked(uint64 batchOpenedAt); /// @param referenceRate The rate the fee bands were computed against (keeper-supplied /// for privileged swaps, Chainlink for permissionless ones), ORACLE_DECIMALS. - /// @param subsidy USDC paid by the vault straight to `destination` on top of `forwarded`. + /// @param subsidy USDC the vault paid to this clone on top of `usdcOut`. event SwapExecuted( address indexed caller, uint256 routeIndex, @@ -167,33 +176,29 @@ contract VortexForwarder { uint256 usdcOut, uint256 referenceRate, uint256 fee, - uint256 subsidy, - uint256 forwarded + uint256 subsidy ); - event StrandedEureSwept(address indexed caller, uint256 amount); - event DestinationUpdated(address previous, address current); - event FallbackAddressUpdated(address previous, address current); - event ClientPausedSet(bool paused); + event Forwarded(address indexed caller, uint256 amount); + event Recovered(address indexed caller, uint256 eureAmount, uint256 usdcAmount); event GuardianPausedSet(bool paused); - event TokenSwept(address indexed token, address indexed to, uint256 amount); // ----------------------------------------------------------------- errors error AlreadyInitialized(); error NotFactory(); - error NotFallbackAddress(); error NotGuardian(); + error NotKeeper(); error NotAuthorizedYet(); error Paused(); error ZeroAddress(); error InvalidConfigAddress(); error InvalidFeePolicy(); error BelowMinimum(); + error InvalidAmount(); error StalePrice(); error InvalidPrice(); error InsufficientOutput(); error Overspend(); - error NotStranded(); error NoPendingFeePolicy(); error ReferenceOutOfBand(); error SubsidyUnavailable(); @@ -205,6 +210,8 @@ contract VortexForwarder { // ------------------------------------------------------------ constructor constructor(ImmutableConfig memory cfg) { + // A zero recovery wallet would make `recover` burn client funds. + if (cfg.recoveryWallet == address(0)) revert ZeroAddress(); EURE = IERC20(cfg.eure); EURC = IERC20(cfg.eurc); USDC = IERC20(cfg.usdc); @@ -214,11 +221,12 @@ contract VortexForwarder { FACTORY = IVortexForwarderFactory(msg.sender); ATTESTOR = cfg.attestor; FEE_RECIPIENT = cfg.feeRecipient; + RECOVERY_WALLET = cfg.recoveryWallet; MAX_ORACLE_AGE = cfg.maxOracleAge; SLIPPAGE_BPS = cfg.slippageBps; MAX_FEE_PPM = cfg.maxFeePpm; MAX_REFERENCE_DEVIATION_BPS = cfg.maxReferenceDeviationBps; - SWEEP_DELAY = cfg.sweepDelay; + RECOVERY_DELAY = cfg.recoveryDelay; TRIGGER_DELAY = cfg.triggerDelay; RECOVERY_HASH = cfg.recoveryHash; @@ -237,32 +245,35 @@ contract VortexForwarder { _reentrancyGuard = 0; } - modifier onlyFallback() { - if (msg.sender != fallbackAddress) revert NotFallbackAddress(); + modifier onlyGuardian() { + if (msg.sender != FACTORY.guardian()) revert NotGuardian(); _; } - modifier onlyGuardian() { - if (msg.sender != FACTORY.guardian()) revert NotGuardian(); + modifier onlyKeeper() { + if (!_privileged()) revert NotKeeper(); + _; + } + + modifier whenNotPaused() { + if (guardianPaused || FACTORY.globalPaused()) revert Paused(); _; } // ---------------------------------------------------------- initialization /// @notice Called by the factory in the same transaction as clone deployment. - function initialize(address destination_, address fallbackAddress_, uint32 targetPpm_, uint32 floorPpm_) external { + function initialize(address destination_, uint32 targetPpm_, uint32 floorPpm_) external { if (msg.sender != address(FACTORY)) revert NotFactory(); if (initialized) revert AlreadyInitialized(); _validateConfigAddress(destination_); - _validateConfigAddress(fallbackAddress_); _validateFeePolicy(targetPpm_, floorPpm_); initialized = true; destination = destination_; - fallbackAddress = fallbackAddress_; targetPpm = targetPpm_; floorPpm = floorPpm_; - emit Initialized(destination_, fallbackAddress_, targetPpm_, floorPpm_); + emit Initialized(destination_, targetPpm_, floorPpm_); } // -------------------------------------------------------------- EIP-1271 @@ -301,33 +312,37 @@ contract VortexForwarder { return EIP1271_MAGIC; } - // ------------------------------------------------------------ stranding marker (R03) + // ------------------------------------------------------------ batch marker - /// @notice Permissionless. Records when the EURe balance first crossed the swap - /// threshold (start time for TRIGGER_DELAY / SWEEP_DELAY), and clears the - /// marker if the balance dropped back below it. + /// @notice Permissionless. Opens the batch marker when funds are present and it is + /// not armed yet (start time for RECOVERY_DELAY / TRIGGER_DELAY); clears it + /// when the clone is empty. Never re-times an armed marker. function poke() external { - // Armed against the IMMUTABLE floor, not the guardian-tunable minSwapAmount: - // otherwise the guardian could raise minSwapAmount above a client's balance and - // a poke() would clear the marker, permanently disabling the un-pausable - // dead-man sweep (review r1, finding F1 — breach of plan invariant §2.3.5). - uint256 balance = EURE.balanceOf(address(this)); - if (balance >= FACTORY.MIN_SWAP_FLOOR()) { - if (strandedSince == 0) { - strandedSince = uint64(block.timestamp); - emit Poked(strandedSince); - } - } else if (strandedSince != 0) { - strandedSince = 0; - emit Poked(0); + _syncBatch(false); + } + + /// @dev Armed against the IMMUTABLE swap floor, not the guardian-tunable minSwapAmount, + /// so no guardian action can keep a funded batch from being timed (review r1 F1). + /// `reset` re-times the marker for whatever remains after a forward or a + /// recovery closed the previous batch; otherwise an armed marker is left alone so + /// a partial swap can never restart the recovery clock. + function _syncBatch(bool reset) internal { + bool funded = EURE.balanceOf(address(this)) >= FACTORY.MIN_SWAP_FLOOR() || USDC.balanceOf(address(this)) > 0; + uint64 next = 0; + if (funded) { + next = (reset || batchOpenedAt == 0) ? uint64(block.timestamp) : batchOpenedAt; + } + if (next != batchOpenedAt) { + batchOpenedAt = next; + emit Poked(next); } } // ------------------------------------------------------------------ swap - /// @notice Convert EURe held by this contract to USDC and forward to `destination`. - /// Callable by guardian/keepers any time; by anyone once the stranding - /// marker is older than TRIGGER_DELAY (liveness fallback). + /// @notice Convert `amountIn` EURe held by this contract to USDC, which stays on the + /// clone until `forward`. Callable by guardian/keepers any time; by anyone once + /// the batch marker is older than TRIGGER_DELAY (liveness fallback). /// @param referenceRate The partner-agreed reference (EUR/USD, ORACLE_DECIMALS) the /// fee bands are priced against. A privileged caller must supply one within /// MAX_REFERENCE_DEVIATION_BPS of Chainlink; a permissionless caller's value is @@ -337,19 +352,15 @@ contract VortexForwarder { /// every enabled route off-chain and picks the best; a poor pick only ever /// costs Vortex (more subsidy, less fee), never the client, whose outcome is /// bounded by the oracle floor whichever route runs. - function swapAndForward(uint256 referenceRate, uint256 routeIndex) external nonReentrant { - if (clientPaused || guardianPaused || FACTORY.globalPaused()) revert Paused(); + /// @param amountIn Exactly how much EURe to convert: at least minSwapAmount, at most + /// perSwapCap and the balance. Explicit so the keeper's chunking maps every + /// swap to one bank payment. + function swap(uint256 referenceRate, uint256 routeIndex, uint256 amountIn) external nonReentrant whenNotPaused { + bool privileged = _privileged(); + if (!privileged) _requireBatchAge(TRIGGER_DELAY, NotAuthorizedYet.selector); - bool privileged = msg.sender == FACTORY.guardian() || FACTORY.isKeeper(msg.sender); - if (!privileged) { - if (strandedSince == 0) revert NotAuthorizedYet(); - if (block.timestamp - strandedSince < TRIGGER_DELAY) revert NotAuthorizedYet(); - } - - uint256 amountIn = EURE.balanceOf(address(this)); if (amountIn < FACTORY.minSwapAmount()) revert BelowMinimum(); - uint256 cap = FACTORY.perSwapCap(); - if (amountIn > cap) amountIn = cap; + if (amountIn > FACTORY.perSwapCap() || amountIn > EURE.balanceOf(address(this))) revert InvalidAmount(); uint256 oraclePrice = _oraclePrice(); uint256 referenceUsed = privileged ? _checkedReference(referenceRate, oraclePrice) : oraclePrice; @@ -363,20 +374,13 @@ contract VortexForwarder { // subsidy transfer alike. if (usdcReceived - fee + subsidy < _floorOut(amountIn, oraclePrice)) revert InsufficientOutput(); - // Full-balance sweep: unsolicited USDC goes to the client's destination too (R09). - uint256 forwarded = USDC.balanceOf(address(this)); - _transfer(USDC, destination, forwarded); - - // Re-arm instead of clearing when a perSwapCap remainder stays behind (review r1 - // P2): otherwise the remainder's dead-man/permissionless timers would silently - // restart from zero only after a fresh poke(). - strandedSince = EURE.balanceOf(address(this)) >= FACTORY.MIN_SWAP_FLOOR() ? uint64(block.timestamp) : 0; - emit SwapExecuted(msg.sender, routeIndex, amountIn, usdcReceived, referenceUsed, fee, subsidy, forwarded); + _syncBatch(false); + emit SwapExecuted(msg.sender, routeIndex, amountIn, usdcReceived, referenceUsed, fee, subsidy); } /// @dev Executes the whitelisted route and returns the USDC received. The router /// minimum is deliberately 0: the router cannot see the fee and subsidy that - /// determine the client's net, so the floor is enforced by swapAndForward after + /// determine the client's net, so the floor is enforced by `swap` after /// settlement instead, and a failing floor reverts the whole call. function _swap(uint256 routeIndex, uint256 amountIn) internal returns (uint256 usdcReceived) { (bytes memory path, bool routeEnabled) = FACTORY.route(routeIndex); @@ -401,10 +405,10 @@ contract VortexForwarder { /// - fill above reference x (1 - targetPpm): the surplus is the fee, <= MAX_FEE_PPM; /// - fill between the floor and the target: no fee, no subsidy; /// - fill below reference x (1 - floorPpm): a privileged swap draws the shortfall - /// from the vault straight to `destination`; a permissionless swap pays nothing. + /// from the vault onto this clone; a permissionless swap pays nothing. /// The vault reverts (and so does the swap) when its cap, budget, pause or /// balance cannot cover the shortfall, and the forwarder reverts unless exactly - /// the shortfall arrived at `destination` — a swap is never partially subsidized. + /// the shortfall arrived here — a swap is never partially subsidized. function _settle(uint256 amountIn, uint256 usdcReceived, uint256 referenceUsed, bool privileged) internal returns (uint256 fee, uint256 subsidy) @@ -425,10 +429,10 @@ contract VortexForwarder { address vault = FACTORY.subsidyVault(); if (vault == address(0)) revert SubsidyUnavailable(); // The vault is guardian-settable without a timelock, so its word is not enough: - // count the subsidy only once exactly that amount has landed at `destination`. - uint256 destinationBefore = USDC.balanceOf(destination); - IVortexSubsidyVault(vault).pay(destination, subsidy, referenceOut); - if (USDC.balanceOf(destination) - destinationBefore != subsidy) revert SubsidyUnavailable(); + // count the subsidy only once exactly that amount has landed here. + uint256 before = USDC.balanceOf(address(this)); + IVortexSubsidyVault(vault).pay(address(this), subsidy, referenceOut); + if (USDC.balanceOf(address(this)) - before != subsidy) revert SubsidyUnavailable(); return (0, subsidy); } @@ -459,57 +463,56 @@ contract VortexForwarder { return (_usdcValue(amountIn, oraclePrice) * (BPS - SLIPPAGE_BPS)) / BPS; } - // -------------------------------------------------------------- recovery + // --------------------------------------------------------------- forward - /// @notice Permissionless dead-man sweep: after SWEEP_DELAY of stranding, anyone may - /// move the full EURe balance to the client's fallbackAddress. Deliberately - /// NOT gated on pause flags: recovery must work during incidents. Never - /// targets `destination` (CEX rule — variant doc §6). - function sweepStrandedEure() external nonReentrant { - if (strandedSince == 0) revert NotStranded(); - if (block.timestamp - strandedSince < SWEEP_DELAY) revert DelayNotElapsed(); - uint256 balance = EURE.balanceOf(address(this)); - _transfer(EURE, fallbackAddress, balance); - strandedSince = 0; - emit StrandedEureSwept(msg.sender, balance); + /// @notice Pushes `amount` of the accumulated USDC to `destination`: the keeper calls + /// this once with the whole converted bank payment, so the client sees one + /// transfer per pay-in. Closes the batch marker when nothing remains. + function forward(uint256 amount) external nonReentrant whenNotPaused onlyKeeper { + if (amount == 0 || amount > USDC.balanceOf(address(this))) revert InvalidAmount(); + _transfer(USDC, destination, amount); + _syncBatch(true); + emit Forwarded(msg.sender, amount); } - // ------------------------------------------------------- client (fallback) authority - - function setDestination(address destination_) external onlyFallback { - _validateConfigAddress(destination_); - emit DestinationUpdated(destination, destination_); - destination = destination_; + /// @notice Pushes the whole USDC balance to `destination`. Keeper any time (also the + /// home for unsolicited USDC, R09); anyone once the batch marker is older than + /// TRIGGER_DELAY, so a Vortex outage can never trap converted funds on chain. + /// Batches may merge on that path — the per-payment mapping is the keeper's. + function forwardAll() external nonReentrant whenNotPaused { + if (!_privileged()) _requireBatchAge(TRIGGER_DELAY, NotAuthorizedYet.selector); + uint256 amount = USDC.balanceOf(address(this)); + if (amount == 0) revert InvalidAmount(); + _transfer(USDC, destination, amount); + _syncBatch(true); + emit Forwarded(msg.sender, amount); } - function setFallbackAddress(address fallbackAddress_) external onlyFallback { - _validateConfigAddress(fallbackAddress_); - emit FallbackAddressUpdated(fallbackAddress, fallbackAddress_); - fallbackAddress = fallbackAddress_; - } - - function setClientPaused(bool paused) external onlyFallback { - clientPaused = paused; - emit ClientPausedSet(paused); - } + // -------------------------------------------------------------- recovery - /// @notice Client exit hatch: move any token (incl. EURe/USDC/unsolicited) anywhere. - /// Works while paused — guardian pause must never trap client funds. - function sweep(address token, address to) external onlyFallback nonReentrant { - if (to == address(0)) revert ZeroAddress(); - uint256 balance = IERC20(token).balanceOf(address(this)); - _transfer(IERC20(token), to, balance); - if (token == address(EURE) && strandedSince != 0) { - strandedSince = 0; - emit Poked(0); + /// @notice Moves a stuck bank payment — its unconverted EURe and its chunk-swapped + /// USDC — to RECOVERY_WALLET so Vortex can refund the exact EUR amount to the + /// payer's bank account (docs/architecture-monerium-b2b-onramp.md, recovery). + /// Keeper/guardian only, and only once the batch has been open for + /// RECOVERY_DELAY: the contract, not the keeper, enforces the promised window. + /// Deliberately NOT gated on pause flags: pause-then-recover is the incident + /// sequence. Amounts are explicit because a younger payment may share the clone. + function recover(uint256 eureAmount, uint256 usdcAmount) external nonReentrant onlyKeeper { + _requireBatchAge(RECOVERY_DELAY, DelayNotElapsed.selector); + if (eureAmount == 0 && usdcAmount == 0) revert InvalidAmount(); + if (eureAmount > EURE.balanceOf(address(this)) || usdcAmount > USDC.balanceOf(address(this))) { + revert InvalidAmount(); } - emit TokenSwept(token, to, balance); + _transfer(EURE, RECOVERY_WALLET, eureAmount); + _transfer(USDC, RECOVERY_WALLET, usdcAmount); + _syncBatch(true); + emit Recovered(msg.sender, eureAmount, usdcAmount); } // ----------------------------------------------------------- guardian authority - /// @notice Protective-only: blocks swaps (compliance holds, dormancy gate — R05). - /// Cannot move funds, change config, or block fallback paths. + /// @notice Protective-only: blocks swaps and forwards (compliance holds, dormancy gate — + /// R05). Cannot move funds, change config, or block a recovery. function setGuardianPaused(bool paused) external onlyGuardian { guardianPaused = paused; emit GuardianPausedSet(paused); @@ -565,6 +568,21 @@ contract VortexForwarder { // ---------------------------------------------------------------- helpers + function _privileged() internal view returns (bool) { + return msg.sender == FACTORY.guardian() || FACTORY.isKeeper(msg.sender); + } + + /// @dev Reverts with `err` unless the batch marker is armed and older than `delay`. + function _requireBatchAge(uint256 delay, bytes4 err) internal view { + if (batchOpenedAt == 0 || block.timestamp - batchOpenedAt < delay) { + // solhint-disable-next-line no-inline-assembly + assembly { + mstore(0, err) + revert(0, 4) + } + } + } + /// @dev The floor is the worse-for-the-client bound, so it may never sit above the /// target, and both are capped by the immutable MAX_FEE_PPM. function _validateFeePolicy(uint32 targetPpm_, uint32 floorPpm_) internal view { @@ -575,7 +593,7 @@ contract VortexForwarder { if (account == address(0)) revert ZeroAddress(); if ( account == address(EURE) || account == address(EURC) || account == address(USDC) - || account == address(ROUTER) || account == address(this) + || account == address(ROUTER) || account == address(this) || account == RECOVERY_WALLET ) revert InvalidConfigAddress(); } diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol index f0902da8f..217c4a51d 100644 --- a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -49,12 +49,7 @@ contract VortexForwarderFactory { address public subsidyVault; event ForwarderDeployed( - address indexed forwarder, - address indexed destination, - address fallbackAddress, - uint32 targetPpm, - uint32 floorPpm, - bytes32 salt + address indexed forwarder, address indexed destination, uint32 targetPpm, uint32 floorPpm, bytes32 salt ); event KeeperSet(address indexed keeper, bool enabled); event GlobalPausedSet(bool paused); @@ -102,17 +97,15 @@ contract VortexForwarderFactory { /// @notice Deploy and initialize a client forwarder in one transaction. The clone /// address is deterministic (CREATE2) so it can be communicated/linked /// reliably; predict it with `predictAddress` before deploying. - function deployForwarder( - address destination, - address fallbackAddress, - uint32 targetPpm, - uint32 floorPpm, - bytes32 salt - ) external onlyGuardian returns (address forwarder) { + function deployForwarder(address destination, uint32 targetPpm, uint32 floorPpm, bytes32 salt) + external + onlyGuardian + returns (address forwarder) + { forwarder = _cloneDeterministic(implementation, salt); - VortexForwarder(forwarder).initialize(destination, fallbackAddress, targetPpm, floorPpm); + VortexForwarder(forwarder).initialize(destination, targetPpm, floorPpm); isForwarder[forwarder] = true; - emit ForwarderDeployed(forwarder, destination, fallbackAddress, targetPpm, floorPpm, salt); + emit ForwarderDeployed(forwarder, destination, targetPpm, floorPpm, salt); } function predictAddress(bytes32 salt) external view returns (address) { diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol index e53c312bb..9500d0601 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -38,7 +38,7 @@ contract VortexForwarderForkTest is Test { address attestor = vm.addr(0xA11CE); address destination = makeAddr("destination"); - address fallbackAddr = makeAddr("fallbackAddr"); + address recoveryWallet = makeAddr("recoveryWallet"); address keeper = makeAddr("keeper"); bool forked; @@ -58,11 +58,12 @@ contract VortexForwarderForkTest is Test { oracle: CHAINLINK_EUR_USD, attestor: attestor, feeRecipient: makeAddr("feeRecipient"), + recoveryWallet: recoveryWallet, maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 40, + slippageBps: 60, maxFeePpm: 10_000, maxReferenceDeviationBps: 100, - sweepDelay: 7 days, // registry P3 + recoveryDelay: 2 hours, // registry P3 triggerDelay: 24 hours, recoveryHash: bytes32(0) }), @@ -78,7 +79,7 @@ contract VortexForwarderForkTest is Test { ); deal(USDC, address(vault), 1_000e6); factory.setSubsidyVault(address(vault)); - fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 1_250, 1_500, bytes32(uint256(1)))); + fwd = VortexForwarder(factory.deployForwarder(destination, 1_250, 1_500, bytes32(uint256(1)))); } /// The keeper's reference in these tests is Chainlink itself (trivially inside the band). @@ -117,7 +118,7 @@ contract VortexForwarderForkTest is Test { assertGt(IERC20Meta(EURE_V2).balanceOf(hop1), 0, "pinned hop1 pool holds no V2 EURe"); } - function test_fork_swapAndForward_executesWithinOracleBounds() public onlyForked { + function test_fork_swapThenForward_executesWithinOracleBounds() public onlyForked { uint256 amountIn = 1_000e18; deal(EURE_V2, address(fwd), amountIn); // stdStorage balance override @@ -125,23 +126,38 @@ contract VortexForwarderForkTest is Test { uint256 fair = (amountIn * uint256(answer)) / 1e20; // 6-dec USDC at oracle rate vm.prank(keeper); - fwd.swapAndForward(_reference(), 0); + fwd.swap(_reference(), 0, amountIn); + assertEq(IERC20Meta(USDC).balanceOf(destination), 0, "USDC must wait on the clone until forward"); // With the vault funded the client lands at or above the policy floor (15 bps), // whether by fill, fee, or subsidy; the oracle floor is the hard lower bound. - uint256 received = IERC20Meta(USDC).balanceOf(destination); - assertGe(received, (fair * 998_500) / 1_000_000, "below the policy floor"); - assertGe(received, (fair * 9_960) / 10_000, "below the oracle floor"); - assertLe(received, (fair * 10_300) / 10_000, "implausibly above oracle rate"); + uint256 converted = IERC20Meta(USDC).balanceOf(address(fwd)); + assertGe(converted, (fair * 998_500) / 1_000_000, "below the policy floor"); + assertGe(converted, (fair * 9_940) / 10_000, "below the oracle floor"); + assertLe(converted, (fair * 10_300) / 10_000, "implausibly above oracle rate"); assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 0, "EURe left behind"); + + vm.prank(keeper); + fwd.forward(converted); + assertEq(IERC20Meta(USDC).balanceOf(destination), converted); assertEq(IERC20Meta(USDC).balanceOf(address(fwd)), 0, "USDC left behind"); } - function test_fork_perSwapCapLeavesRemainder() public onlyForked { + function test_fork_chunkedPayment_accumulatesThenRecovers() public onlyForked { deal(EURE_V2, address(fwd), 12_000e18); // cap is 10k vm.prank(keeper); - fwd.swapAndForward(_reference(), 0); + fwd.swap(_reference(), 0, 10_000e18); assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 2_000e18); - assertGt(IERC20Meta(USDC).balanceOf(destination), 0); + uint256 converted = IERC20Meta(USDC).balanceOf(address(fwd)); + assertGt(converted, 0); + + // The promised window passes with the remainder unconverted: recover the whole + // payment (unconverted EURe + converted USDC) to the recovery wallet. + vm.warp(block.timestamp + fwd.RECOVERY_DELAY()); + vm.prank(keeper); + fwd.recover(2_000e18, converted); + assertEq(IERC20Meta(EURE_V2).balanceOf(recoveryWallet), 2_000e18); + assertEq(IERC20Meta(USDC).balanceOf(recoveryWallet), converted); + assertEq(IERC20Meta(USDC).balanceOf(destination), 0); } } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol index 903551910..cd28fd5ac 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -9,9 +9,11 @@ import {MockERC20, MockOracle, MockRouter} from "./VortexForwarder.t.sol"; /// Randomized action handler. Ghost variables track every token unit entering the /// system so the invariants below can assert exit-path exhaustiveness (plan §2.3.1): -/// EURe may sit in the forwarder, be consumed by the router, or reach the client's -/// fallback; USDC may only reach destination + feeRecipient (plus vault subsidies that -/// reach destination); nothing else, ever. +/// EURe may sit in the forwarder, be consumed by the router, or reach the Vortex +/// recovery wallet; USDC may sit on the forwarder or reach destination, feeRecipient +/// or the recovery wallet (plus vault subsidies, which land on the forwarder first); +/// nothing else, ever. A recovery is only ever possible RECOVERY_DELAY after a batch +/// opened, and a chunk swap never re-times an open batch. contract ForwarderHandler is Test { VortexForwarderFactory public factory; VortexForwarder public fwd; @@ -23,7 +25,7 @@ contract ForwarderHandler is Test { MockRouter public router; address public destination = makeAddr("destination"); - address public fallbackAddr = makeAddr("fallbackAddr"); + address public recoveryWallet = makeAddr("recoveryWallet"); address public keeper = makeAddr("keeper"); address public rando = makeAddr("rando"); address public feeRecipient = makeAddr("feeRecipient"); @@ -32,13 +34,17 @@ contract ForwarderHandler is Test { uint256 public ghostEureMinted; uint256 public ghostUsdcPaidByRouter; uint256 public ghostSubsidyPaid; - uint256 public fallbackSweepFailures; /// Successful swaps whose client net landed below the oracle floor, or keeper swaps /// below the policy floor, or fees above MAX_FEE_PPM. Must stay zero. uint256 public pricingViolations; + /// Recoveries that succeeded less than RECOVERY_DELAY after their batch opened. Must stay zero. + uint256 public earlyRecoveries; + /// Swaps that changed an already-armed batch marker. Must stay zero. + uint256 public markerRetimes; uint32 public immutable INITIAL_TARGET_PPM = 1_250; uint32 public immutable INITIAL_FLOOR_PPM = 1_500; uint256 public constant VAULT_FUNDING = 10_000e6; + uint256 public constant RECOVERY_DELAY = 2 hours; uint256 constant REFERENCE = 1.14e8; constructor() { @@ -57,11 +63,12 @@ contract ForwarderHandler is Test { oracle: address(oracle), attestor: vm.addr(0xA11CE), feeRecipient: feeRecipient, + recoveryWallet: recoveryWallet, maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 40, + slippageBps: 60, maxFeePpm: 10_000, maxReferenceDeviationBps: 100, - sweepDelay: 7 days, // registry P3 + recoveryDelay: RECOVERY_DELAY, // registry P3 triggerDelay: 24 hours, recoveryHash: bytes32(0) }), @@ -78,9 +85,7 @@ contract ForwarderHandler is Test { usdc.mint(address(vault), VAULT_FUNDING); factory.setSubsidyVault(address(vault)); fwd = VortexForwarder( - factory.deployForwarder( - destination, fallbackAddr, INITIAL_TARGET_PPM, INITIAL_FLOOR_PPM, bytes32(uint256(1)) - ) + factory.deployForwarder(destination, INITIAL_TARGET_PPM, INITIAL_FLOOR_PPM, bytes32(uint256(1))) ); ghostExpectedTargetPpm = INITIAL_TARGET_PPM; ghostExpectedFloorPpm = INITIAL_FLOOR_PPM; @@ -104,40 +109,61 @@ contract ForwarderHandler is Test { /// Router pays a randomized amount around the fair oracle value: far below exercises /// the floor/cap reverts, slightly below the subsidy path, above the fee path. - function keeperSwap(uint96 raw) external { - _swapAs(keeper, raw); + function keeperSwap(uint96 raw, uint96 rawAmount) external { + _swapAs(keeper, raw, rawAmount); } - function randoSwap(uint96 raw) external { - _swapAs(rando, raw); + function randoSwap(uint96 raw, uint96 rawAmount) external { + _swapAs(rando, raw, rawAmount); } - function _swapAs(address caller, uint96 raw) internal { + function _swapAs(address caller, uint96 raw, uint96 rawAmount) internal { oracle.set(1.14e8, block.timestamp); uint256 balance = eure.balanceOf(address(fwd)); - uint256 amountIn = balance > 10_000e18 ? 10_000e18 : balance; + uint256 ceiling = balance > 10_000e18 ? 10_000e18 : balance; + // Mostly legal amounts; occasionally out of bounds to exercise the reverts. + uint256 amountIn = bound(uint256(rawAmount), 0, ceiling + 30e18); uint256 fair = (amountIn * REFERENCE) / 1e20; uint256 payout = bound(uint256(raw), (fair * 95) / 100, (fair * 105) / 100); router.setNextOut(payout); uint256 routerUsdcBefore = usdc.totalMinted(); uint256 vaultBefore = usdc.balanceOf(address(vault)); - uint256 destinationBefore = usdc.balanceOf(destination); + uint256 cloneBefore = usdc.balanceOf(address(fwd)); uint256 feeBefore = usdc.balanceOf(feeRecipient); + uint64 markerBefore = fwd.batchOpenedAt(); vm.prank(caller); - try fwd.swapAndForward(REFERENCE, 0) { + try fwd.swap(REFERENCE, 0, amountIn) { uint256 paid = usdc.totalMinted() - routerUsdcBefore; ghostUsdcPaidByRouter += paid; ghostSubsidyPaid += vaultBefore - usdc.balanceOf(address(vault)); - uint256 net = usdc.balanceOf(destination) - destinationBefore; - if (net < (fair * 9_960) / 10_000) pricingViolations++; // Chainlink - 40 bps + uint256 net = usdc.balanceOf(address(fwd)) - cloneBefore; // fill - fee + subsidy + if (net < (fair * 9_940) / 10_000) pricingViolations++; // Chainlink - 60 bps if (caller == keeper && net < (fair * (1_000_000 - fwd.floorPpm())) / 1_000_000) pricingViolations++; if (usdc.balanceOf(feeRecipient) - feeBefore > paid / 100) pricingViolations++; // MAX_FEE_PPM + if (markerBefore != 0 && fwd.batchOpenedAt() != markerBefore) markerRetimes++; } catch {} } - function sweepStranded() external { - try fwd.sweepStrandedEure() {} catch {} + function keeperForward(uint96 raw) external { + uint256 amount = bound(uint256(raw), 0, usdc.balanceOf(address(fwd)) + 1e6); + vm.prank(keeper); + try fwd.forward(amount) {} catch {} + } + + function forwardAllAs(bool asKeeper) external { + vm.prank(asKeeper ? keeper : rando); + try fwd.forwardAll() {} catch {} + } + + function keeperRecover(uint96 rawEure, uint96 rawUsdc) external { + uint256 eureAmount = bound(uint256(rawEure), 0, eure.balanceOf(address(fwd)) + 1e18); + uint256 usdcAmount = bound(uint256(rawUsdc), 0, usdc.balanceOf(address(fwd)) + 1e6); + uint64 opened = fwd.batchOpenedAt(); + vm.prank(keeper); + try fwd.recover(eureAmount, usdcAmount) { + if (opened == 0 || block.timestamp - opened < RECOVERY_DELAY) earlyRecoveries++; + } catch {} } function guardianPause(bool paused) external { @@ -169,28 +195,13 @@ contract ForwarderHandler is Test { } catch {} } - function clientPause(bool paused) external { - vm.prank(fallbackAddr); - fwd.setClientPaused(paused); - } - - /// The client exit hatch must NEVER fail, including while paused (plan §2.3.4). - function clientSweepEure() external { - vm.prank(fallbackAddr); - try fwd.sweep(address(eure), fallbackAddr) {} - catch { - fallbackSweepFailures++; - } - } - function randoTriesPrivilegedCalls(uint8 selector) external { vm.startPrank(rando); - if (selector % 6 == 0) try fwd.setDestination(rando) {} catch {} - if (selector % 6 == 1) try fwd.setGuardianPaused(true) {} catch {} - if (selector % 6 == 2) try fwd.setFallbackAddress(rando) {} catch {} - if (selector % 6 == 3) try fwd.sweep(address(eure), rando) {} catch {} - if (selector % 6 == 4) try fwd.setFeePolicy(99, 99) {} catch {} - if (selector % 6 == 5) try vault.setDailyBudget(type(uint256).max) {} catch {} + if (selector % 5 == 0) try fwd.setGuardianPaused(true) {} catch {} + if (selector % 5 == 1) try fwd.forward(usdc.balanceOf(address(fwd))) {} catch {} + if (selector % 5 == 2) try fwd.recover(eure.balanceOf(address(fwd)), usdc.balanceOf(address(fwd))) {} catch {} + if (selector % 5 == 3) try fwd.setFeePolicy(99, 99) {} catch {} + if (selector % 5 == 4) try vault.setDailyBudget(type(uint256).max) {} catch {} vm.stopPrank(); } } @@ -204,25 +215,26 @@ contract VortexForwarderInvariantTest is Test { } /// Exit-path exhaustiveness for EURe: every unit ever minted into the forwarder is - /// either still there, consumed by the router (swap), or at the client's fallback. + /// either still there, consumed by the router (swap), or at the recovery wallet. function invariant_eureConservation() public view { uint256 accounted = handler.eure().balanceOf(address(handler.fwd())) - + handler.eure().balanceOf(address(handler.router())) + handler.eure().balanceOf(handler.fallbackAddr()); + + handler.eure().balanceOf(address(handler.router())) + handler.eure().balanceOf(handler.recoveryWallet()); assertEq(accounted, handler.ghostEureMinted(), "EURe leaked to an unexpected address"); } /// Exit-path exhaustiveness for USDC: everything the router ever paid plus every - /// subsidy the vault ever paid ends up split between destination and feeRecipient; - /// the forwarder retains nothing and the vault only ever shrinks by what it paid. - function invariant_usdcOnlyReachesDestinationAndFee() public view { - uint256 accounted = - handler.usdc().balanceOf(handler.destination()) + handler.usdc().balanceOf(handler.feeRecipient()); + /// subsidy the vault ever paid is either still on the forwarder or split between + /// destination, feeRecipient and the recovery wallet; the vault only ever shrinks + /// by what it paid. + function invariant_usdcOnlyReachesDestinationFeeOrRecovery() public view { + uint256 accounted = handler.usdc().balanceOf(address(handler.fwd())) + + handler.usdc().balanceOf(handler.destination()) + handler.usdc().balanceOf(handler.feeRecipient()) + + handler.usdc().balanceOf(handler.recoveryWallet()); assertEq( accounted, handler.ghostUsdcPaidByRouter() + handler.ghostSubsidyPaid(), "USDC leaked to an unexpected address" ); - assertEq(handler.usdc().balanceOf(address(handler.fwd())), 0, "forwarder retained USDC"); assertEq( handler.usdc().balanceOf(address(handler.vault())), handler.VAULT_FUNDING() - handler.ghostSubsidyPaid(), @@ -237,26 +249,31 @@ contract VortexForwarderInvariantTest is Test { assertEq(handler.pricingViolations(), 0, "a swap violated a pricing bound"); } + /// A recovery can only ever happen RECOVERY_DELAY after the batch opened: the + /// contract, not the keeper, enforces the promised window. + function invariant_recoveryNeverEarly() public view { + assertEq(handler.earlyRecoveries(), 0, "a recovery ran before RECOVERY_DELAY"); + } + + /// Chunking a payment never restarts its recovery clock. + function invariant_swapNeverRetimesTheBatch() public view { + assertEq(handler.markerRetimes(), 0, "a swap re-timed an open batch"); + } + /// Config changes only through their authorized paths: the fee policy moves /// exclusively via the guardian's timelocked setter (P11 ghost model tracks every /// legal transition — a rando call or an early apply can never move it), stays - /// ordered and capped; destination/fallback never change without their owner. + /// ordered and capped; the destination never changes at all. function invariant_configIntegrity() public view { assertEq(handler.fwd().targetPpm(), handler.ghostExpectedTargetPpm(), "target moved outside the timelock path"); assertEq(handler.fwd().floorPpm(), handler.ghostExpectedFloorPpm(), "floor moved outside the timelock path"); assertLe(handler.fwd().targetPpm(), handler.fwd().floorPpm(), "target above floor"); assertLe(handler.fwd().floorPpm(), 10_000, "floor exceeded MAX_FEE_PPM"); assertEq(handler.fwd().destination(), handler.destination()); - assertEq(handler.fwd().fallbackAddress(), handler.fallbackAddr()); - } - - /// Guardian/global pause must never block the client's exit hatch. - function invariant_fallbackSweepNeverBlocked() public view { - assertEq(handler.fallbackSweepFailures(), 0, "client exit hatch was blocked"); } - /// The stranding marker never points into the future. - function invariant_strandedSinceNotInFuture() public view { - assertLe(handler.fwd().strandedSince(), block.timestamp); + /// The batch marker never points into the future. + function invariant_batchMarkerNotInFuture() public view { + assertLe(handler.fwd().batchOpenedAt(), block.timestamp); } } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 08fe451e3..49aebb3be 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -85,10 +85,10 @@ contract MockRouter { } } -/// Malicious router that tries to re-enter swapAndForward during the swap. +/// Malicious router that tries to re-enter swap during the swap. contract MockReentrantRouter { function exactInput(ISwapRouter02.ExactInputParams calldata) external payable returns (uint256) { - VortexForwarder(msg.sender).swapAndForward(REF, 0); // must revert via reentrancy guard + VortexForwarder(msg.sender).swap(REF, 0, 1_000e18); // must revert via reentrancy guard return 0; } } @@ -114,12 +114,12 @@ contract VortexForwarderTest is Test { address feeRecipient = makeAddr("feeRecipient"); address treasury = makeAddr("treasury"); address destination = makeAddr("destination"); - address fallbackAddr = makeAddr("fallbackAddr"); + address recoveryWallet = makeAddr("recoveryWallet"); address keeper = makeAddr("keeper"); address rando = makeAddr("rando"); uint256 constant TRIGGER_DELAY = 24 hours; - uint256 constant SWEEP_DELAY = 7 days; // registry P3 + uint256 constant RECOVERY_DELAY = 2 hours; // registry P3: the promised conversion window // Fee policy defaults (proposal): target 12.5 bps, floor 15 bps below the reference. uint32 constant TARGET_PPM = 1_250; @@ -130,7 +130,7 @@ contract VortexForwarderTest is Test { // 1000 EURe at 1.14 = 1140 USDC reference value and its derived bounds. uint256 constant TARGET_1K = 1_138_575_000; // reference - 12.5 bps uint256 constant FLOOR_1K = 1_138_290_000; // reference - 15 bps - uint256 constant ORACLE_FLOOR_1K = 1_135_440_000; // Chainlink - 40 bps + uint256 constant ORACLE_FLOOR_1K = 1_133_160_000; // Chainlink - 60 bps uint256 constant TARGET_10K = 11_385_750_000; function setUp() public { @@ -142,22 +142,7 @@ contract VortexForwarderTest is Test { router = new MockRouter(eure, usdc); factory = new VortexForwarderFactory( - VortexForwarder.ImmutableConfig({ - eure: address(eure), - eurc: address(eurc), - usdc: address(usdc), - router: address(router), - oracle: address(oracle), - attestor: attestor, - feeRecipient: feeRecipient, - maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 40, - maxFeePpm: 10_000, - maxReferenceDeviationBps: 100, - sweepDelay: SWEEP_DELAY, - triggerDelay: TRIGGER_DELAY, - recoveryHash: bytes32(0) - }), + _config(address(router), bytes32(0)), 1e18, // MIN_SWAP_FLOOR 50_000e18, // CAP_CEILING 25e18, // minSwapAmount @@ -170,13 +155,35 @@ contract VortexForwarderTest is Test { ); usdc.mint(address(vault), 1_000e6); factory.setSubsidyVault(address(vault)); - fwd = VortexForwarder( - factory.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(1))) - ); + fwd = VortexForwarder(factory.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, bytes32(uint256(1)))); } // ---------------------------------------------------------------- helpers + function _config(address router_, bytes32 recoveryHash) + internal + view + returns (VortexForwarder.ImmutableConfig memory) + { + return VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: router_, + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + recoveryWallet: recoveryWallet, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h + slippageBps: 60, // P1: tolerates ~45 bps of weekend drift under a stale Chainlink round + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, + recoveryDelay: RECOVERY_DELAY, + triggerDelay: TRIGGER_DELAY, + recoveryHash: recoveryHash + }); + } + /// Uniswap V3 packed path EURe -> EURC -> USDC at the given fee tiers. function _route(uint24 tier1, uint24 tier2) internal view returns (bytes memory) { return abi.encodePacked(address(eure), tier1, address(eurc), tier2, address(usdc)); @@ -192,6 +199,11 @@ contract VortexForwarderTest is Test { eure.mint(address(fwd), amount); } + function _keeperSwap(uint256 amountIn) internal { + vm.prank(keeper); + fwd.swap(REF, 0, amountIn); + } + // ---------------------------------------------------------------- EIP-1271 function test_linkSignature_valid_eip191Only() public view { @@ -233,30 +245,10 @@ contract VortexForwarderTest is Test { function test_recoveryHash_enabledBranch() public { bytes32 recoveryHash = keccak256("monerium-recovery-message-placeholder"); VortexForwarderFactory f2 = new VortexForwarderFactory( - VortexForwarder.ImmutableConfig({ - eure: address(eure), - eurc: address(eurc), - usdc: address(usdc), - router: address(router), - oracle: address(oracle), - attestor: attestor, - feeRecipient: feeRecipient, - maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 40, - maxFeePpm: 10_000, - maxReferenceDeviationBps: 100, - sweepDelay: SWEEP_DELAY, - triggerDelay: TRIGGER_DELAY, - recoveryHash: recoveryHash - }), - 1e18, - 50_000e18, - 25e18, - 10_000e18, - _route(500, 500) + _config(address(router), recoveryHash), 1e18, 50_000e18, 25e18, 10_000e18, _route(500, 500) ); VortexForwarder fwd2 = - VortexForwarder(f2.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(8)))); + VortexForwarder(f2.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, bytes32(uint256(8)))); // Recovery hash validates with attestor binding; link still validates; others fail. bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(fwd2), recoveryHash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); @@ -290,9 +282,8 @@ contract VortexForwarderTest is Test { } function test_linkSignature_rejectsCrossCloneReplay() public { - VortexForwarder other = VortexForwarder( - factory.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(2))) - ); + VortexForwarder other = + VortexForwarder(factory.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, bytes32(uint256(2)))); bytes32 h = fwd.LINK_HASH_191(); // Signature bound to `fwd` must not validate on `other`. assertEq(other.isValidSignature(h, _attest(address(fwd), h)), bytes4(0xffffffff)); @@ -302,34 +293,69 @@ contract VortexForwarderTest is Test { function test_initialize_onlyFactory_andOnce() public { vm.expectRevert(VortexForwarder.NotFactory.selector); - fwd.initialize(rando, rando, 0, 0); + fwd.initialize(rando, 0, 0); vm.prank(address(factory)); vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); - fwd.initialize(rando, rando, 0, 0); + fwd.initialize(rando, 0, 0); } function test_implementation_isBricked() public { VortexForwarder impl = VortexForwarder(factory.implementation()); vm.prank(address(factory)); vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); - impl.initialize(rando, rando, 0, 0); + impl.initialize(rando, 0, 0); } - // ---------------------------------------------------------------- swap + function test_deploy_rejectsRecoveryWalletAsDestination() public { + vm.expectRevert(VortexForwarder.InvalidConfigAddress.selector); + factory.deployForwarder(recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(3))); + } - function test_swapAndForward_happyPath_forwardsToDestination() public { + function test_implementation_rejectsZeroRecoveryWallet() public { + VortexForwarder.ImmutableConfig memory cfg = _config(address(router), bytes32(0)); + cfg.recoveryWallet = address(0); + vm.expectRevert(VortexForwarder.ZeroAddress.selector); + new VortexForwarderFactory(cfg, 1e18, 50_000e18, 25e18, 10_000e18, _route(500, 500)); + } + + // ---------------------------------------------------------------- swap + forward + + function test_swap_keepsUsdcOnTheClone_untilForward() public { _fund(1_000e18); router.setNextOut(TARGET_1K); // exactly the target: no fee, no subsidy - vm.prank(keeper); - fwd.swapAndForward(REF, 0); - assertEq(usdc.balanceOf(destination), TARGET_1K); + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K, "USDC must accumulate on the clone"); + assertEq(usdc.balanceOf(destination), 0); assertEq(eure.balanceOf(address(fwd)), 0); assertEq(eure.allowance(address(fwd), address(router)), 0); + + vm.prank(keeper); + fwd.forward(TARGET_1K); + assertEq(usdc.balanceOf(destination), TARGET_1K); + assertEq(usdc.balanceOf(address(fwd)), 0); + assertEq(fwd.batchOpenedAt(), 0, "an emptied clone closes its batch"); } - function test_swapAndForward_enforcesOracleFloorOnTheNet() public { - // Permissionless path (no subsidy): a fill below Chainlink - 40 bps must revert in + /// One bank payment, several chunks, one transfer: the partner's 1:1 mapping. + function test_chunkedPayment_forwardedAsOneTransfer() public { + _fund(25_000e18); // cap is 10k: three chunks + router.setNextOut(TARGET_10K); + _keeperSwap(10_000e18); + _keeperSwap(10_000e18); + router.setNextOut(5 * TARGET_1K); + _keeperSwap(5_000e18); + uint256 total = 2 * TARGET_10K + 5 * TARGET_1K; + assertEq(usdc.balanceOf(address(fwd)), total); + assertEq(usdc.balanceOf(destination), 0, "nothing reaches the client before the whole payment is converted"); + + vm.prank(keeper); + fwd.forward(total); + assertEq(usdc.balanceOf(destination), total); + } + + function test_swap_enforcesOracleFloorOnTheNet() public { + // Permissionless path (no subsidy): a fill below Chainlink - 60 bps must revert in // the forwarder's own post-condition, not in the router (its minimum is zero). _fund(1_000e18); fwd.poke(); @@ -338,211 +364,291 @@ contract VortexForwarderTest is Test { router.setNextOut(ORACLE_FLOOR_1K - 1); vm.prank(rando); vm.expectRevert(VortexForwarder.InsufficientOutput.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); router.setNextOut(ORACLE_FLOOR_1K); vm.prank(rando); - fwd.swapAndForward(REF, 0); - assertEq(usdc.balanceOf(destination), ORACLE_FLOOR_1K); + fwd.swap(REF, 0, 1_000e18); + assertEq(usdc.balanceOf(address(fwd)), ORACLE_FLOOR_1K); } - function test_swapAndForward_revertsOnStaleOracle() public { + function test_swap_revertsOnStaleOracle() public { _fund(1_000e18); router.setNextOut(TARGET_1K); oracle.set(1.14e8, block.timestamp); skip(53 hours); // just past the 52h P8 window vm.prank(keeper); vm.expectRevert(VortexForwarder.StalePrice.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); } - function test_swapAndForward_publicOnlyAfterTriggerDelay() public { + function test_swap_publicOnlyAfterTriggerDelay() public { _fund(1_000e18); router.setNextOut(TARGET_1K); vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); fwd.poke(); vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); skip(TRIGGER_DELAY + 1); oracle.set(1.14e8, block.timestamp); vm.prank(rando); - fwd.swapAndForward(REF, 0); - assertEq(usdc.balanceOf(destination), TARGET_1K); + fwd.swap(REF, 0, 1_000e18); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); } - function test_swapAndForward_revertsOnZeroOrNegativePrice() public { + function test_swap_revertsOnZeroOrNegativePrice() public { _fund(1_000e18); router.setNextOut(TARGET_1K); oracle.set(0, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); oracle.set(-1, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); } - /// Review r1 P2: a perSwapCap remainder must keep its stranding timers armed — - /// the swap re-arms the marker rather than clearing it when balance stays >= floor. - function test_swapAndForward_reArmsMarkerForCapRemainder() public { + function test_swap_amountBounds() public { + _fund(15_000e18); // cap is 10k, minimum 25 + router.setNextOut(TARGET_10K); + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.BelowMinimum.selector); + fwd.swap(REF, 0, 24e18); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.swap(REF, 0, 10_000e18 + 1); // above the cap + fwd.swap(REF, 0, 10_000e18); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.swap(REF, 0, 5_000e18 + 1); // above the balance + vm.stopPrank(); + assertEq(eure.balanceOf(address(fwd)), 5_000e18); // remainder awaits the next chunk + } + + /// A partial swap must never restart the recovery clock: the marker keeps the time + /// the batch opened, whatever remains on the clone. + function test_swap_neverRetimesTheBatchMarker() public { _fund(15_000e18); // cap is 10k fwd.poke(); - assertGt(fwd.strandedSince(), 0); + uint64 opened = fwd.batchOpenedAt(); + assertGt(opened, 0); router.setNextOut(TARGET_10K); skip(1 hours); - vm.prank(keeper); - fwd.swapAndForward(REF, 0); + _keeperSwap(10_000e18); assertEq(eure.balanceOf(address(fwd)), 5_000e18); - assertEq(fwd.strandedSince(), block.timestamp, "remainder must stay armed (fresh timestamp)"); + assertEq(fwd.batchOpenedAt(), opened, "a chunk swap re-timed the batch"); } - function test_swapAndForward_respectsPerSwapCap() public { - _fund(15_000e18); // cap is 10k - // minOut for 10k at 1.14*0.99 = 11286 USDC - router.setNextOut(TARGET_10K); - vm.prank(keeper); - fwd.swapAndForward(REF, 0); - assertEq(eure.balanceOf(address(fwd)), 5_000e18); // remainder awaits next execution + function test_swap_armsTheBatchMarkerWhenNobodyPoked() public { + _fund(1_000e18); + router.setNextOut(TARGET_1K); + skip(3 hours); + _keeperSwap(1_000e18); + assertEq(fwd.batchOpenedAt(), block.timestamp); } - function test_swapAndForward_pausedByGuardianOrClientOrGlobal() public { + function test_swapAndForward_pausedByGuardianOrGlobal() public { _fund(1_000e18); router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); fwd.setGuardianPaused(true); // test contract is factory guardian - vm.prank(keeper); + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swap(REF, 0, 1_000e18); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(REF, 0); + fwd.forward(TARGET_1K); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.forwardAll(); + vm.stopPrank(); fwd.setGuardianPaused(false); - vm.prank(fallbackAddr); - fwd.setClientPaused(true); - vm.prank(keeper); + factory.setGlobalPaused(true); + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swap(REF, 0, 1_000e18); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(REF, 0); - vm.prank(fallbackAddr); - fwd.setClientPaused(false); + fwd.forward(TARGET_1K); + vm.stopPrank(); + } - factory.setGlobalPaused(true); + function test_forward_keeperOnly_andBounded() public { + _fund(1_000e18); + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotKeeper.selector); + fwd.forward(TARGET_1K); + + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.forward(0); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.forward(TARGET_1K + 1); + fwd.forward(TARGET_1K - 1); // an explicit amount leaves the rest for a later forward + vm.stopPrank(); + assertEq(usdc.balanceOf(destination), TARGET_1K - 1); + assertEq(usdc.balanceOf(address(fwd)), 1); + assertGt(fwd.batchOpenedAt(), 0, "USDC left behind keeps a batch open"); + } + + /// A forward closes the previous batch: whatever a younger payment left behind is + /// timed from now, never from the older payment's arrival. + function test_forward_retimesTheMarkerForRemainingFunds() public { + _fund(1_000e18); + fwd.poke(); + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); + skip(1 hours); + _fund(500e18); // a younger payment lands while the first is being forwarded vm.prank(keeper); - vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(REF, 0); + fwd.forward(TARGET_1K); + assertEq(fwd.batchOpenedAt(), block.timestamp, "remaining EURe belongs to a new batch"); } - function test_unsolicitedUsdc_forwardedWithNextSwap() public { + function test_unsolicitedUsdc_forwardAllPushesEverything() public { usdc.mint(address(fwd), 500e6); // unsolicited direct transfer (R09) _fund(1_000e18); router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); vm.prank(keeper); - fwd.swapAndForward(REF, 0); + fwd.forwardAll(); assertEq(usdc.balanceOf(destination), TARGET_1K + 500e6); + assertEq(fwd.batchOpenedAt(), 0); + } + + function test_forwardAll_publicOnlyAfterTriggerDelay() public { + _fund(1_000e18); + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); // arms the marker + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.forwardAll(); + + skip(TRIGGER_DELAY + 1); + vm.prank(rando); + fwd.forwardAll(); // liveness fallback: a dead Vortex cannot trap converted funds + assertEq(usdc.balanceOf(destination), TARGET_1K); + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.forwardAll(); // the emptied clone closed its batch: the public path is armed again only by new funds } function test_reentrantRouter_blockedByGuard() public { MockReentrantRouter evil = new MockReentrantRouter(); VortexForwarderFactory f2 = new VortexForwarderFactory( - VortexForwarder.ImmutableConfig({ - eure: address(eure), - eurc: address(eurc), - usdc: address(usdc), - router: address(evil), - oracle: address(oracle), - attestor: attestor, - feeRecipient: feeRecipient, - maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 40, - maxFeePpm: 10_000, - maxReferenceDeviationBps: 100, - sweepDelay: SWEEP_DELAY, - triggerDelay: TRIGGER_DELAY, - recoveryHash: bytes32(0) - }), - 1e18, - 50_000e18, - 25e18, - 10_000e18, - _route(500, 500) + _config(address(evil), bytes32(0)), 1e18, 50_000e18, 25e18, 10_000e18, _route(500, 500) ); f2.setKeeper(keeper, true); VortexForwarder fwd2 = - VortexForwarder(f2.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, bytes32(uint256(7)))); + VortexForwarder(f2.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, bytes32(uint256(7)))); eure.mint(address(fwd2), 1_000e18); vm.prank(keeper); vm.expectRevert(VortexForwarder.Reentrancy.selector); - fwd2.swapAndForward(REF, 0); + fwd2.swap(REF, 0, 1_000e18); } // ---------------------------------------------------------------- recovery - function test_sweepStrandedEure_afterDelay_toFallbackOnly() public { - _fund(500e18); - fwd.poke(); + function test_recover_keeperOnly_afterRecoveryDelay_toRecoveryWalletOnly() public { + _fund(1_500e18); // 1000 converted, 500 stuck unconverted + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); + uint64 opened = fwd.batchOpenedAt(); + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotKeeper.selector); + fwd.recover(500e18, TARGET_1K); + + vm.prank(keeper); vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); - fwd.sweepStrandedEure(); + fwd.recover(500e18, TARGET_1K); - skip(SWEEP_DELAY + 1); - vm.prank(rando); // permissionless - fwd.sweepStrandedEure(); - assertEq(eure.balanceOf(fallbackAddr), 500e18); - assertEq(fwd.strandedSince(), 0); - } + vm.warp(opened + RECOVERY_DELAY - 1); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.recover(500e18, TARGET_1K); - /// Review r1 F1 regression: raising the tunable minSwapAmount above a stranded - /// balance must NOT let a poke() clear the marker — the dead-man sweep is armed - /// against the immutable MIN_SWAP_FLOOR and must survive any guardian action. - function test_guardianCannotDisarmDeadManSweep_byRaisingMinSwap() public { - _fund(500e18); - fwd.poke(); - assertGt(fwd.strandedSince(), 0); + vm.warp(opened + RECOVERY_DELAY); + vm.prank(keeper); + fwd.recover(500e18, TARGET_1K); + assertEq(eure.balanceOf(recoveryWallet), 500e18); + assertEq(usdc.balanceOf(recoveryWallet), TARGET_1K); + assertEq(usdc.balanceOf(destination), 0, "a recovered payment never reaches the client"); + assertEq(fwd.batchOpenedAt(), 0); + } - factory.setMinSwapAmount(1_000e18); // guardian raises threshold above balance - fwd.poke(); // anyone can poke; marker must survive - assertGt(fwd.strandedSince(), 0, "guardian disarmed the dead-man sweep"); + function test_recover_requiresAnOpenBatch() public { + vm.prank(keeper); + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.recover(1, 0); // marker never armed: no batch to recover + } - skip(SWEEP_DELAY + 1); - fwd.sweepStrandedEure(); - assertEq(eure.balanceOf(fallbackAddr), 500e18); + function test_recover_amountsAreExplicitAndBounded() public { + _fund(1_000e18); + fwd.poke(); + skip(RECOVERY_DELAY); + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.recover(0, 0); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.recover(1_000e18 + 1, 0); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.recover(0, 1); + fwd.recover(400e18, 0); // only this payment's share: a younger payment may share the clone + vm.stopPrank(); + assertEq(eure.balanceOf(recoveryWallet), 400e18); + assertEq(eure.balanceOf(address(fwd)), 600e18); + assertEq(fwd.batchOpenedAt(), block.timestamp, "what remains is timed as a new batch"); } - function test_fallbackSweep_worksWhilePaused() public { - _fund(500e18); + function test_recover_worksWhilePaused() public { + _fund(1_000e18); + fwd.poke(); + skip(RECOVERY_DELAY); fwd.setGuardianPaused(true); - vm.prank(fallbackAddr); - fwd.sweep(address(eure), fallbackAddr); - assertEq(eure.balanceOf(fallbackAddr), 500e18); + factory.setGlobalPaused(true); + vm.prank(keeper); + fwd.recover(1_000e18, 0); // pause-then-recover is the incident sequence + assertEq(eure.balanceOf(recoveryWallet), 1_000e18); } - function test_fallbackEureSweep_resetsDeadManTimer() public { + /// Review r1 F1 regression, carried over: raising the tunable minSwapAmount above a + /// funded balance must NOT let a poke() clear the marker — the batch is timed against + /// the immutable MIN_SWAP_FLOOR and must survive any guardian action. + function test_guardianCannotDisarmTheBatchMarker_byRaisingMinSwap() public { _fund(500e18); fwd.poke(); - skip(SWEEP_DELAY + 1); - - vm.prank(fallbackAddr); - fwd.sweep(address(eure), fallbackAddr); - assertEq(fwd.strandedSince(), 0); + uint64 opened = fwd.batchOpenedAt(); + assertGt(opened, 0); - _fund(500e18); - vm.expectRevert(VortexForwarder.NotStranded.selector); - fwd.sweepStrandedEure(); + factory.setMinSwapAmount(1_000e18); // guardian raises threshold above balance + skip(1 hours); + fwd.poke(); // anyone can poke; marker must survive, un-retimed + assertEq(fwd.batchOpenedAt(), opened, "guardian disarmed or re-timed the batch"); } - function test_fallbackAuthority_gated() public { - vm.prank(rando); - vm.expectRevert(VortexForwarder.NotFallbackAddress.selector); - fwd.setDestination(rando); - - address newDest = makeAddr("newDest"); - vm.prank(fallbackAddr); - fwd.setDestination(newDest); - assertEq(fwd.destination(), newDest); + function test_poke_clearsAnArmedMarkerOnlyWhenEmpty() public { + _fund(1_000e18); + fwd.poke(); + assertGt(fwd.batchOpenedAt(), 0); + fwd.poke(); + assertGt(fwd.batchOpenedAt(), 0); + skip(RECOVERY_DELAY); + vm.prank(keeper); + fwd.recover(1_000e18, 0); + assertEq(fwd.batchOpenedAt(), 0); + usdc.mint(address(fwd), 1); // any USDC opens a batch: it must be forwarded or recovered + fwd.poke(); + assertEq(fwd.batchOpenedAt(), block.timestamp); } function test_guardianPause_gated() public { @@ -556,7 +662,7 @@ contract VortexForwarderTest is Test { function test_predictAddress_matchesDeployment() public { bytes32 salt = bytes32(uint256(42)); address predicted = factory.predictAddress(salt); - address deployed = factory.deployForwarder(destination, fallbackAddr, TARGET_PPM, FLOOR_PPM, salt); + address deployed = factory.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, salt); assertEq(predicted, deployed); } @@ -581,8 +687,7 @@ contract VortexForwarderTest is Test { _fund(1_000e18); router.setNextOut(TARGET_1K); - vm.prank(keeper); - fwd.swapAndForward(REF, 0); + _keeperSwap(1_000e18); assertEq(router.lastPath(), _route(500, 500)); } @@ -594,7 +699,7 @@ contract VortexForwarderTest is Test { _fund(1_000e18); router.setNextOut(TARGET_1K); vm.prank(keeper); - fwd.swapAndForward(REF, 1); + fwd.swap(REF, 1, 1_000e18); assertEq(router.lastPath(), direct); } @@ -604,12 +709,12 @@ contract VortexForwarderTest is Test { vm.prank(keeper); vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); - fwd.swapAndForward(REF, 7); + fwd.swap(REF, 7, 1_000e18); factory.setRouteEnabled(0, false); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidRoute.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); factory.setRouteEnabled(7, false); @@ -650,9 +755,8 @@ contract VortexForwarderTest is Test { function test_swap_aboveTarget_surplusIsTheFee() public { _fund(1_000e18); router.setNextOut(1_145e6); - vm.prank(keeper); - fwd.swapAndForward(REF, 0); - assertEq(usdc.balanceOf(destination), TARGET_1K); + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); assertEq(usdc.balanceOf(address(vault)), 1_000e6); } @@ -660,41 +764,37 @@ contract VortexForwarderTest is Test { function test_swap_feeCappedAtMaxFeePpm() public { _fund(1_000e18); router.setNextOut(1_200e6); // ~5% above the reference - vm.prank(keeper); - fwd.swapAndForward(REF, 0); + _keeperSwap(1_000e18); assertEq(usdc.balanceOf(feeRecipient), 12e6); // 1% of the fill, not the whole surplus - assertEq(usdc.balanceOf(destination), 1_188e6); + assertEq(usdc.balanceOf(address(fwd)), 1_188e6); } function test_swap_betweenFloorAndTarget_noFeeNoSubsidy() public { _fund(1_000e18); router.setNextOut(1_138_400_000); - vm.prank(keeper); - fwd.swapAndForward(REF, 0); - assertEq(usdc.balanceOf(destination), 1_138_400_000); + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), 1_138_400_000); assertEq(usdc.balanceOf(feeRecipient), 0); assertEq(usdc.balanceOf(address(vault)), 1_000e6); } - function test_swap_belowFloor_vaultTopsUpToTheFloor() public { + function test_swap_belowFloor_vaultTopsUpTheCloneToTheFloor() public { _fund(1_000e18); router.setNextOut(1_136e6); - vm.prank(keeper); - fwd.swapAndForward(REF, 0); + _keeperSwap(1_000e18); uint256 subsidy = FLOOR_1K - 1_136e6; // 2.29 USDC - assertEq(usdc.balanceOf(destination), FLOOR_1K); + assertEq(usdc.balanceOf(address(fwd)), FLOOR_1K, "the subsidy lands on the clone, forwarded with the payment"); + assertEq(usdc.balanceOf(destination), 0); assertEq(usdc.balanceOf(address(vault)), 1_000e6 - subsidy); assertEq(vault.spentToday(), subsidy); assertEq(usdc.balanceOf(feeRecipient), 0); - assertEq(usdc.balanceOf(address(fwd)), 0); } function test_swap_rawFillBelowOracleFloor_isRescuedBySubsidy() public { _fund(1_000e18); - router.setNextOut(1_134e6); // below Chainlink - 40 bps, within the vault's per-swap cap - vm.prank(keeper); - fwd.swapAndForward(REF, 0); - assertEq(usdc.balanceOf(destination), FLOOR_1K); + router.setNextOut(1_133e6); // below Chainlink - 60 bps, within the vault's per-swap cap + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), FLOOR_1K); } function test_swap_subsidyOverCap_revertsTheWholeSwap() public { @@ -702,9 +802,9 @@ contract VortexForwarderTest is Test { router.setNextOut(1_130e6); // needs 8.29 USDC; the cap is 50 bps of 1140 = 5.7 USDC vm.prank(keeper); vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); assertEq(eure.balanceOf(address(fwd)), 1_000e18); - assertEq(usdc.balanceOf(destination), 0); + assertEq(usdc.balanceOf(address(fwd)), 0); } function test_swap_subsidyNotDelivered_revertsTheWholeSwap() public { @@ -713,9 +813,9 @@ contract VortexForwarderTest is Test { router.setNextOut(1_130e6); // below both floors; the 8.29 USDC top-up the vault "pays" never arrives vm.prank(keeper); vm.expectRevert(VortexForwarder.SubsidyUnavailable.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); assertEq(eure.balanceOf(address(fwd)), 1_000e18); - assertEq(usdc.balanceOf(destination), 0); + assertEq(usdc.balanceOf(address(fwd)), 0); } function test_swap_subsidyOverBudget_reverts() public { @@ -724,7 +824,7 @@ contract VortexForwarderTest is Test { router.setNextOut(1_136e6); vm.prank(keeper); vm.expectRevert(VortexSubsidyVault.BudgetExhausted.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); } function test_swap_withoutVault_onlyFillsAtOrAboveTheFloorSucceed() public { @@ -733,23 +833,22 @@ contract VortexForwarderTest is Test { router.setNextOut(1_136e6); vm.prank(keeper); vm.expectRevert(VortexForwarder.SubsidyUnavailable.selector); - fwd.swapAndForward(REF, 0); + fwd.swap(REF, 0, 1_000e18); router.setNextOut(1_145e6); - vm.prank(keeper); - fwd.swapAndForward(REF, 0); - assertEq(usdc.balanceOf(destination), TARGET_1K); + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); } function test_swap_depeggedReference_cannotBePaperedOverBySubsidy() public { _fund(1_000e18); uint256 lowReference = (REF * 9_910) / 10_000; // 90 bps below Chainlink: inside the band - // The floor at that reference (~1128.05 USDC) is below Chainlink - 40 bps (1135.44): + // The floor at that reference (~1128.05 USDC) is below Chainlink - 60 bps (1133.16): // the vault would top the client up to it, and the swap must still revert. router.setNextOut(1_127e6); vm.prank(keeper); vm.expectRevert(VortexForwarder.InsufficientOutput.selector); - fwd.swapAndForward(lowReference, 0); + fwd.swap(lowReference, 0, 1_000e18); assertEq(usdc.balanceOf(address(vault)), 1_000e6, "subsidy transfer must be undone"); } @@ -757,11 +856,11 @@ contract VortexForwarderTest is Test { _fund(1_000e18); uint256 lowReference = (REF * 9_900) / 10_000; // 100 bps below Chainlink: the band's edge // Above that reference's target (1_127_189_250): fee branch, fee 0.81 USDC, and the - // net 1_127_189_250 still sits below Chainlink - 40 bps (1_135_440_000). + // net 1_127_189_250 still sits below Chainlink - 60 bps (1_133_160_000). router.setNextOut(1_128e6); vm.prank(keeper); vm.expectRevert(VortexForwarder.InsufficientOutput.selector); - fwd.swapAndForward(lowReference, 0); + fwd.swap(lowReference, 0, 1_000e18); assertEq(usdc.balanceOf(feeRecipient), 0, "fee transfer must be undone"); assertEq(eure.balanceOf(address(fwd)), 1_000e18); } @@ -771,14 +870,14 @@ contract VortexForwarderTest is Test { router.setNextOut(1_150e6); vm.startPrank(keeper); vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); - fwd.swapAndForward((REF * 10_101) / 10_000, 0); // 101 bps above + fwd.swap((REF * 10_101) / 10_000, 0, 1_000e18); // 101 bps above vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); - fwd.swapAndForward((REF * 9_899) / 10_000, 0); // 101 bps below + fwd.swap((REF * 9_899) / 10_000, 0, 1_000e18); // 101 bps below vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); - fwd.swapAndForward(0, 0); - fwd.swapAndForward((REF * 10_100) / 10_000, 0); // exactly 100 bps: allowed + fwd.swap(0, 0, 1_000e18); + fwd.swap((REF * 10_100) / 10_000, 0, 1_000e18); // exactly 100 bps: allowed vm.stopPrank(); - assertGt(usdc.balanceOf(destination), 0); + assertGt(usdc.balanceOf(address(fwd)), 0); } function test_swap_permissionless_pricesAgainstChainlinkAndPaysNoSubsidy() public { @@ -788,17 +887,14 @@ contract VortexForwarderTest is Test { oracle.set(1.14e8, block.timestamp); router.setNextOut(1_136e6); // below the floor: the client simply gets the fill vm.prank(rando); - fwd.swapAndForward(1, 0); // garbage reference is ignored on this path - assertEq(usdc.balanceOf(destination), 1_136e6); + fwd.swap(1, 0, 1_000e18); // garbage reference is ignored on this path + assertEq(usdc.balanceOf(address(fwd)), 1_136e6); assertEq(usdc.balanceOf(address(vault)), 1_000e6); _fund(1_000e18); - fwd.poke(); - skip(TRIGGER_DELAY + 1); - oracle.set(1.14e8, block.timestamp); router.setNextOut(1_145e6); // above the Chainlink-based target: the fee still applies vm.prank(rando); - fwd.swapAndForward(999, 0); + fwd.swap(999, 0, 1_000e18); assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); } @@ -806,9 +902,9 @@ contract VortexForwarderTest is Test { function test_feePolicy_validatedAtDeploy() public { vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); - factory.deployForwarder(destination, fallbackAddr, 2_000, 1_500, bytes32(uint256(9))); // target above floor + factory.deployForwarder(destination, 2_000, 1_500, bytes32(uint256(9))); // target above floor vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); - factory.deployForwarder(destination, fallbackAddr, 1_000, 10_001, bytes32(uint256(9))); // floor above cap + factory.deployForwarder(destination, 1_000, 10_001, bytes32(uint256(9))); // floor above cap } function test_setFeePolicy_onlyGuardianAndValidated() public { @@ -896,10 +992,9 @@ contract VortexForwarderTest is Test { fwd.setFeePolicy(2_500, 3_000); // pending, not applied _fund(1_000e18); router.setNextOut(1_145e6); - vm.prank(keeper); - fwd.swapAndForward(REF, 0); + _keeperSwap(1_000e18); // The fee closes the gap to the OLD target: the announced policy never touches a swap. assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); - assertEq(usdc.balanceOf(destination), TARGET_1K); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); } } From c619a3021d6fc10d95b8f063badcf87fdd4d44ef Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 22:10:48 +0200 Subject: [PATCH 30/44] feat(api): convert deposits in chunks, forward them whole, and mark recoveries The keeper now serves one deposit at a time: chunk swaps bound to it (1 deposit : N executions, explicit amountIn), one forward of the summed net once every chunk is confirmed, and a recover for a deposit an operator marked for the refund path. The N:M allocation join and the R04 snapshot attribution have no job left and go; every keeper transaction shares the execution row, the nonce-before-broadcast identity and the calldata-exact crash recovery, per kind. Deposits gain the settlement and refund states, DEPOSIT_CONVERTED fires on the confirmed forward and carries its hash, and the client-held fallback role leaves the account model, the provisioning read-back and the monitors (a destination change is now an incident; stranded batches warn past RECOVERY_DELAY). Two admin endpoints mark a deposit for recovery and close or retry it by hand until the refund is automated. --- .../admin/moneriumB2b.controller.test.ts | 74 +- .../admin/moneriumB2b.controller.ts | 117 ++- .../controllers/monerium-b2b.controller.ts | 63 +- .../api/routes/v1/admin/monerium-b2b.route.ts | 13 +- .../monerium-b2b/account-provisioning.ts | 34 +- .../src/api/services/monerium-b2b/chain.ts | 83 +- .../conversion-allocation.test.ts | 74 -- .../monerium-b2b/conversion-executor.test.ts | 412 +++++---- .../monerium-b2b/conversion-executor.ts | 789 +++++++++++------- .../monerium-b2b/deposit-processor.test.ts | 76 +- .../monerium-b2b/deposit-processor.ts | 35 +- .../monerium-b2b/manager-events.test.ts | 88 +- .../services/monerium-b2b/manager-events.ts | 66 +- .../services/monerium-b2b/monitoring.test.ts | 50 +- .../api/services/monerium-b2b/monitoring.ts | 126 ++- .../services/monerium-b2b/onboarding.test.ts | 3 - .../src/api/workers/monerium-b2b.worker.ts | 30 +- .../080-monerium-whole-deposit-settlement.ts | 83 ++ apps/api/src/models/index.ts | 8 +- apps/api/src/models/moneriumAccount.model.ts | 7 - .../moneriumConversionExecution.model.ts | 37 +- .../models/moneriumDepositAllocation.model.ts | 82 -- .../src/models/moneriumFiatDeposit.model.ts | 20 +- ...erium-b2b-account-read.integration.test.ts | 38 +- docs/api/wire-contract.snapshot.md | 19 +- .../shared/src/endpoints/webhook.endpoints.ts | 20 +- 26 files changed, 1457 insertions(+), 990 deletions(-) delete mode 100644 apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts create mode 100644 apps/api/src/database/migrations/080-monerium-whole-deposit-settlement.ts delete mode 100644 apps/api/src/models/moneriumDepositAllocation.model.ts diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts index 3b9713dbc..0726f88cb 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -5,6 +5,10 @@ import KycCase from "../../../models/kycCase.model"; import ManagedProfile from "../../../models/managedProfile.model"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import User from "../../../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; @@ -17,7 +21,6 @@ const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; -const FALLBACK = "0x3333333333333333333333333333333333333333"; const FACTORY = "0x4444444444444444444444444444444444444444"; describe("monerium b2b account mapping admin route", () => { @@ -68,7 +71,6 @@ describe("monerium b2b account mapping admin route", () => { contactEmail: "ops@client.example.com", destination: DESTINATION, externalSubjectId: "client-1", - fallbackAddress: FALLBACK, forwarderAddress: FORWARDER, managerProfileId, moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e", @@ -122,7 +124,6 @@ describe("monerium b2b account mapping admin route", () => { const row = await MoneriumAccount.findByPk(account.accountId); expect(row).toMatchObject({ destination: DESTINATION, - fallbackAddress: FALLBACK, floorPpm: 1500, forwarderAddress: FORWARDER, targetPpm: 1250, @@ -150,7 +151,6 @@ describe("monerium b2b account mapping admin route", () => { const managerProfileId = await createManager(); await MoneriumAccount.create({ destination: DESTINATION, - fallbackAddress: FALLBACK, forwarderAddress: FORWARDER, profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" }); @@ -206,14 +206,12 @@ describe("monerium b2b account mapping admin route", () => { const expected = { destination: DESTINATION.toLowerCase(), factory: FACTORY.toLowerCase(), - fallbackAddress: FALLBACK.toLowerCase(), floorPpm: 1500, targetPpm: 1250 }; const matching = { destination: DESTINATION, factory: FACTORY, - fallbackAddress: FALLBACK, floorPpm: 1500, isForwarder: true, targetPpm: 1250 @@ -222,8 +220,9 @@ describe("monerium b2b account mapping admin route", () => { expect(forwarderConfigMismatch(expected, matching)).toBeNull(); expect(forwarderConfigMismatch(expected, { ...matching, factory: FORWARDER })).toContain("trusted factory"); expect(forwarderConfigMismatch(expected, { ...matching, isForwarder: false })).toContain("not a clone"); - expect(forwarderConfigMismatch(expected, { ...matching, destination: FALLBACK })).toContain("destination"); - expect(forwarderConfigMismatch(expected, { ...matching, fallbackAddress: DESTINATION })).toContain("fallbackAddress"); + expect( + forwarderConfigMismatch(expected, { ...matching, destination: "0x3333333333333333333333333333333333333333" }) + ).toContain("destination"); expect(forwarderConfigMismatch(expected, { ...matching, targetPpm: 1_000 })).toContain("targetPpm"); expect(forwarderConfigMismatch(expected, { ...matching, floorPpm: 2_000 })).toContain("floorPpm"); }); @@ -234,7 +233,6 @@ describe("monerium b2b account mapping admin route", () => { for (const overrides of [ { forwarderAddress: "not-an-address" }, { destination: "0x12345" }, - { fallbackAddress: "" }, { moneriumProfileId: "not-a-uuid" }, { targetPpm: 3.5 }, { floorPpm: -1 }, @@ -302,6 +300,64 @@ describe("monerium b2b account mapping admin route", () => { expect((await patchStatus(crypto.randomUUID(), "active")).status).toBe(404); }); + it("marks a settling deposit for recovery and lets an operator close or retry it", async () => { + const managerProfileId = await createManager(); + const created = await post(validBody(managerProfileId)); + const { account } = (await created.json()) as { account: { accountId: string } }; + const deposit = await MoneriumFiatDeposit.create({ + accountId: account.accountId, + amountRaw: "100000000000000000000", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Converting, + txHash: "0xmint" + }); + const recover = (depositId: string) => + fetch(`${baseUrl}/deposits/${depositId}/recover`, { headers: ADMIN_HEADERS, method: "POST" }); + const patchStatus = (depositId: string, status: unknown) => + fetch(`${baseUrl}/deposits/${depositId}/status`, { + body: JSON.stringify({ status }), + headers: ADMIN_HEADERS, + method: "PATCH" + }); + + // A pending keeper transaction must settle first: the amounts to recover depend on it. + const pending = await MoneriumConversionExecution.create({ + accountId: account.accountId, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: "60000000000000000000", + status: MoneriumConversionExecutionStatus.Pending + }); + const blocked = await recover(deposit.id); + expect(blocked.status).toBe(409); + expect(await blocked.json()).toMatchObject({ error: { message: expect.stringContaining("pending execution") } }); + await pending.update({ status: MoneriumConversionExecutionStatus.Failed }); + + const marked = await recover(deposit.id); + expect(marked.status).toBe(200); + expect(await marked.json()).toMatchObject({ deposit: { depositId: deposit.id, status: "recovering" } }); + expect((await MoneriumFiatDeposit.findByPk(deposit.id))?.status).toBe(MoneriumFiatDepositStatus.Recovering); + + // Forward-only: a recovering deposit cannot be marked again, but closes or retries. + expect((await recover(deposit.id)).status).toBe(409); + expect((await patchStatus(deposit.id, "forwarded")).status).toBe(400); + const failed = await patchStatus(deposit.id, "recovery_failed"); + expect(failed.status).toBe(200); + const retried = await patchStatus(deposit.id, "recovering"); + expect(retried.status).toBe(200); + const refunded = await patchStatus(deposit.id, "refunded"); + expect(refunded.status).toBe(200); + expect((await patchStatus(deposit.id, "recovering")).status).toBe(409); + expect((await MoneriumFiatDeposit.findByPk(deposit.id))?.status).toBe(MoneriumFiatDepositStatus.Refunded); + + expect((await recover(crypto.randomUUID())).status).toBe(404); + expect((await recover("not-a-uuid")).status).toBe(400); + }); + it("refuses managers not allowed to provision business customers", async () => { const profile = await createTestUser(); await ManagedProfileManager.create({ diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts index b9cf13e5e..e8c844318 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -2,8 +2,11 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../../config/logger"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import { ManagedProfileProvisioningError } from "../../services/managed-profile-provisioning.service"; import { MoneriumB2bProvisioningError, provisionMoneriumB2bAccount } from "../../services/monerium-b2b/account-provisioning"; +import { markDepositForRecovery } from "../../services/monerium-b2b/conversion-executor"; +import { isForwardTransition, withForwarderLock } from "../../services/monerium-b2b/deposit-processor"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -13,7 +16,6 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi contactEmail, destination, externalSubjectId, - fallbackAddress, floorPpm, forwarderAddress, managerProfileId, @@ -30,7 +32,6 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi typeof contactEmail !== "string" || typeof forwarderAddress !== "string" || typeof destination !== "string" || - typeof fallbackAddress !== "string" || (targetPpm !== undefined && typeof targetPpm !== "number") || (floorPpm !== undefined && typeof floorPpm !== "number") ) { @@ -38,7 +39,7 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi error: { code: "MONERIUM_B2B_INVALID_INPUT", message: - "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; targetPpm and floorPpm must be numbers when present", + "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, and destination are required; targetPpm and floorPpm must be numbers when present", status: httpStatus.BAD_REQUEST } }); @@ -49,7 +50,6 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi contactEmail, destination, externalSubjectId, - fallbackAddress, floorPpm, forwarderAddress, managerProfileId, @@ -153,3 +153,112 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st }); } } + +/** + * POST /v1/admin/monerium-b2b/deposits/:depositId/recover — marks a settling deposit for + * the refund path (runbook §2.7). The keeper moves its unconverted EURe and converted + * USDC to the recovery wallet once the clone's batch has been open for RECOVERY_DELAY; + * the bank refund itself follows the runbook until it is automated. + */ +export async function postMoneriumB2bDepositRecovery(req: Request<{ depositId: string }>, res: Response): Promise { + try { + if (!UUID_PATTERN.test(req.params.depositId)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { code: "MONERIUM_B2B_INVALID_INPUT", message: "depositId must be a UUID", status: httpStatus.BAD_REQUEST } + }); + return; + } + const refusal = await markDepositForRecovery(req.params.depositId); + if (refusal === "deposit not found") { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "MONERIUM_B2B_DEPOSIT_NOT_FOUND", message: "Monerium deposit not found", status: httpStatus.NOT_FOUND } + }); + return; + } + if (refusal) { + res.status(httpStatus.CONFLICT).json({ + error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", message: refusal, status: httpStatus.CONFLICT } + }); + return; + } + res + .status(httpStatus.OK) + .json({ deposit: { depositId: req.params.depositId, status: MoneriumFiatDepositStatus.Recovering } }); + } catch (error) { + logger.error("Error marking Monerium B2B deposit for recovery:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to mark the deposit for recovery", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +const OPERATOR_DEPOSIT_STATUSES: readonly string[] = [ + MoneriumFiatDepositStatus.Refunded, + MoneriumFiatDepositStatus.RecoveryFailed, + MoneriumFiatDepositStatus.Recovering +]; + +/** + * PATCH /v1/admin/monerium-b2b/deposits/:depositId/status — closes or retries a + * recovery by hand: `refunded` once the bank refund went out, `recovery_failed` when it + * cannot, `recovering` to retry a failed one. Forward-only like every deposit transition. + */ +export async function patchMoneriumB2bDepositStatus(req: Request<{ depositId: string }>, res: Response): Promise { + try { + const { status } = req.body ?? {}; + if (!UUID_PATTERN.test(req.params.depositId) || typeof status !== "string" || !OPERATOR_DEPOSIT_STATUSES.includes(status)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MONERIUM_B2B_INVALID_INPUT", + message: `depositId must be a UUID and status must be one of ${OPERATOR_DEPOSIT_STATUSES.join(", ")}`, + status: httpStatus.BAD_REQUEST + } + }); + return; + } + const deposit = await MoneriumFiatDeposit.findByPk(req.params.depositId); + if (!deposit) { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "MONERIUM_B2B_DEPOSIT_NOT_FOUND", message: "Monerium deposit not found", status: httpStatus.NOT_FOUND } + }); + return; + } + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND", message: "Monerium account not found", status: httpStatus.NOT_FOUND } + }); + return; + } + const targetStatus = status as MoneriumFiatDepositStatus; + const outcome = await withForwarderLock(account.forwarderAddress, async transaction => { + const current = await MoneriumFiatDeposit.findByPk(deposit.id, { transaction }); + if (!current) return "missing"; + if (targetStatus === current.status) return "same"; + if (!isForwardTransition(current.status, targetStatus)) + return `Monerium deposit cannot transition from ${current.status} to ${targetStatus}`; + await current.update({ status: targetStatus }, { transaction }); + return "updated"; + }); + if (outcome !== "updated" && outcome !== "same") { + res.status(httpStatus.CONFLICT).json({ + error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", message: outcome, status: httpStatus.CONFLICT } + }); + return; + } + res.status(httpStatus.OK).json({ deposit: { depositId: deposit.id, status: targetStatus } }); + } catch (error) { + logger.error("Error updating Monerium B2B deposit status:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update Monerium B2B deposit status", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index 02d3857b6..b093ccaf4 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -4,8 +4,10 @@ import { Op } from "sequelize"; import logger from "../../config/logger"; import { config } from "../../config/vars"; import MoneriumAccount from "../../models/moneriumAccount.model"; -import MoneriumConversionExecution from "../../models/moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "../../models/moneriumDepositAllocation.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit from "../../models/moneriumFiatDeposit.model"; import { APIError } from "../errors/api-error"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; @@ -98,7 +100,6 @@ export const getMoneriumB2bAccount = async (req: Request, res: Response, next: N createdAt: account.createdAt, destination: account.destination, dormantSince: account.dormantSince, - fallbackAddress: account.fallbackAddress, floorPpm: account.floorPpm, forwarderAddress: account.forwarderAddress, iban: account.iban, @@ -115,8 +116,8 @@ const DEPOSIT_LIST_MAX_LIMIT = 100; /** * GET /v1/monerium-b2b/deposits — the acting profile's EUR deposits, newest first, - * each with its allocated conversion execution once the swap has run. This is the - * polling surface for "payment received / converted". + * each with its chunk conversions and, once the whole deposit reached the destination, + * the forward transaction. This is the polling surface for "payment received / converted". */ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: NextFunction): Promise => { try { @@ -140,44 +141,48 @@ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: where: { accountId: account.id, moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` } } }); - const allocations = rows.length - ? await MoneriumDepositAllocation.findAll({ + const executions = rows.length + ? await MoneriumConversionExecution.findAll({ order: [["created_at", "ASC"]], - where: { depositId: rows.map(row => row.id) } + where: { depositId: rows.map(row => row.id), status: { [Op.ne]: MoneriumConversionExecutionStatus.Failed } } }) : []; - const executionIds = [...new Set(allocations.map(allocation => allocation.executionId))]; - const executions = executionIds.length ? await MoneriumConversionExecution.findAll({ where: { id: executionIds } }) : []; - const executionById = new Map(executions.map(execution => [execution.id, execution])); - const allocationsByDeposit = new Map(); - for (const allocation of allocations) { - const grouped = allocationsByDeposit.get(allocation.depositId) ?? []; - grouped.push(allocation); - allocationsByDeposit.set(allocation.depositId, grouped); + const executionsByDeposit = new Map(); + for (const execution of executions) { + const grouped = executionsByDeposit.get(execution.depositId as string) ?? []; + grouped.push(execution); + executionsByDeposit.set(execution.depositId as string, grouped); } res.status(httpStatus.OK).json({ deposits: rows.map(row => { - const depositAllocations = allocationsByDeposit.get(row.id) ?? []; + const depositExecutions = executionsByDeposit.get(row.id) ?? []; + const swaps = depositExecutions.filter(execution => execution.kind === MoneriumConversionExecutionKind.Swap); + const forward = depositExecutions.find( + execution => + execution.kind === MoneriumConversionExecutionKind.Forward && + execution.status === MoneriumConversionExecutionStatus.Confirmed + ); return { amountRaw: row.amountRaw, - conversions: depositAllocations.map(allocation => { - const execution = executionById.get(allocation.executionId); - return { - eureInRaw: allocation.eureInRaw, - execution: execution ? executionPricing(execution) : { feeRaw: null, referenceRateRaw: null, subsidyRaw: null }, - executionId: allocation.executionId, - status: execution?.status ?? "pending", - txHash: execution?.txHash ?? null, - usdcNetRaw: allocation.usdcNetRaw - }; - }), + conversions: swaps.map(execution => ({ + eureInRaw: execution.eureInRaw, + execution: executionPricing(execution), + executionId: execution.id, + status: execution.status, + txHash: execution.txHash, + usdcNetRaw: execution.usdcNetRaw ?? "0" + })), createdAt: row.createdAt, currency: row.currency, depositId: row.id, + forwardTxHash: forward?.txHash ?? null, status: row.status, txHash: row.txHash, - usdcNetRaw: depositAllocations.reduce((sum, allocation) => sum + BigInt(allocation.usdcNetRaw), 0n).toString() + usdcNetRaw: swaps + .filter(execution => execution.status === MoneriumConversionExecutionStatus.Confirmed) + .reduce((sum, execution) => sum + BigInt(execution.usdcNetRaw ?? "0"), 0n) + .toString() }; }), pagination: { limit, offset, total: count } diff --git a/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts index cf405f2c4..16f383c2a 100644 --- a/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts +++ b/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts @@ -1,5 +1,10 @@ import { Router } from "express"; -import { patchMoneriumB2bAccountStatus, postMoneriumB2bAccount } from "../../../controllers/admin/moneriumB2b.controller"; +import { + patchMoneriumB2bAccountStatus, + patchMoneriumB2bDepositStatus, + postMoneriumB2bAccount, + postMoneriumB2bDepositRecovery +} from "../../../controllers/admin/moneriumB2b.controller"; import { adminAuth } from "../../../middlewares/adminAuth"; const router: Router = Router({ mergeParams: true }); @@ -13,4 +18,10 @@ router.post("/accounts", postMoneriumB2bAccount); // Operator lifecycle transitions (activate after the penny test, suspend, close). router.patch("/accounts/:accountId/status", patchMoneriumB2bAccountStatus); +// Refund path (runbook §2.7): mark a settling deposit for recovery — the keeper moves +// its funds to the recovery wallet once the clone allows it — and close or retry a +// recovery by hand. +router.post("/deposits/:depositId/recover", postMoneriumB2bDepositRecovery); +router.patch("/deposits/:depositId/status", patchMoneriumB2bDepositStatus); + export default router; diff --git a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts index d10ab9123..b815f1625 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -25,7 +25,6 @@ export interface ProvisionMoneriumB2bAccountInput { contactEmail: string; destination: string; externalSubjectId: string; - fallbackAddress: string; /** Fee policy in ppm below the reference rate; defaults to the agreed launch policy. */ floorPpm?: number; forwarderAddress: string; @@ -70,7 +69,6 @@ export function isValidFeePolicy(targetPpm: number, floorPpm: number): boolean { const forwarderConfigAbi = parseAbi([ "function destination() view returns (address)", - "function fallbackAddress() view returns (address)", "function targetPpm() view returns (uint32)", "function floorPpm() view returns (uint32)", "function FACTORY() view returns (address)" @@ -81,7 +79,6 @@ const factoryRegistryAbi = parseAbi(["function isForwarder(address forwarder) vi export interface ForwarderPolicyConfig { destination: string; factory: string; - fallbackAddress: string; floorPpm: number; targetPpm: number; } @@ -99,9 +96,6 @@ export function forwarderConfigMismatch( if (onchain.destination.toLowerCase() !== expected.destination) { return `on-chain destination ${onchain.destination} differs from the submitted value`; } - if (onchain.fallbackAddress.toLowerCase() !== expected.fallbackAddress) { - return `on-chain fallbackAddress ${onchain.fallbackAddress} differs from the submitted value`; - } if (onchain.targetPpm !== expected.targetPpm) { return `on-chain targetPpm ${onchain.targetPpm} differs from the submitted ${expected.targetPpm}`; } @@ -122,7 +116,6 @@ export function forwarderConfigMismatch( async function verifyForwarderOnChain( forwarderAddress: string, destination: string, - fallbackAddress: string, targetPpm: number, floorPpm: number ): Promise { @@ -140,9 +133,8 @@ async function verifyForwarderOnChain( const address = forwarderAddress as Address; let onchain: ForwarderPolicyConfig & { isForwarder: boolean }; try { - const [onchainDestination, onchainFallback, onchainTargetPpm, onchainFloorPpm, factory] = await Promise.all([ + const [onchainDestination, onchainTargetPpm, onchainFloorPpm, factory] = await Promise.all([ client.readContract({ abi: forwarderConfigAbi, address, functionName: "destination" }), - client.readContract({ abi: forwarderConfigAbi, address, functionName: "fallbackAddress" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "targetPpm" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "floorPpm" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "FACTORY" }) @@ -156,7 +148,6 @@ async function verifyForwarderOnChain( onchain = { destination: onchainDestination, factory, - fallbackAddress: onchainFallback, floorPpm: onchainFloorPpm, isForwarder, targetPpm: onchainTargetPpm @@ -169,10 +160,7 @@ async function verifyForwarderOnChain( }` ); } - const mismatch = forwarderConfigMismatch( - { destination, factory: trustedFactory, fallbackAddress, floorPpm, targetPpm }, - onchain - ); + const mismatch = forwarderConfigMismatch({ destination, factory: trustedFactory, floorPpm, targetPpm }, onchain); if (mismatch) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", @@ -260,14 +248,12 @@ function accountMatchesInput( childProfileId: string, forwarderAddress: string, destination: string, - fallbackAddress: string, targetPpm: number, floorPpm: number ): boolean { return ( account.forwarderAddress.toLowerCase() === forwarderAddress && account.destination.toLowerCase() === destination && - account.fallbackAddress.toLowerCase() === fallbackAddress && account.targetPpm === targetPpm && account.floorPpm === floorPpm && (account.vortexProfileId === null || account.vortexProfileId === childProfileId) @@ -290,7 +276,6 @@ export async function provisionMoneriumB2bAccount( } const forwarderAddress = normalizeAddress(input.forwarderAddress, "forwarderAddress"); const destination = normalizeAddress(input.destination, "destination"); - const fallbackAddress = normalizeAddress(input.fallbackAddress, "fallbackAddress"); const targetPpm = input.targetPpm ?? DEFAULT_TARGET_PPM; const floorPpm = input.floorPpm ?? DEFAULT_FLOOR_PPM; if (!isValidFeePolicy(targetPpm, floorPpm)) { @@ -302,7 +287,7 @@ export async function provisionMoneriumB2bAccount( // Before any persistence: a wrong clone address must fail here, not become a mapped // account whose config the monitors later legitimize. - await verifyForwarderOnChain(forwarderAddress, destination, fallbackAddress, targetPpm, floorPpm); + await verifyForwarderOnChain(forwarderAddress, destination, targetPpm, floorPpm); let result: { account: { created: boolean; row: MoneriumAccount }; managedProfile: ProvisionManagedProfileResult }; try { @@ -325,17 +310,7 @@ export async function provisionMoneriumB2bAccount( const existing = await MoneriumAccount.findOne({ transaction, where: { profileId: moneriumProfileId } }); if (existing) { - if ( - !accountMatchesInput( - existing, - managedProfile.profileId, - forwarderAddress, - destination, - fallbackAddress, - targetPpm, - floorPpm - ) - ) { + if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, targetPpm, floorPpm)) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", "The Monerium profile is already mapped with different account data" @@ -368,7 +343,6 @@ export async function provisionMoneriumB2bAccount( const row = await MoneriumAccount.create( { destination, - fallbackAddress, floorPpm, forwarderAddress, profileId: moneriumProfileId, diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index 41102c9e5..e7f4bd50c 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -54,11 +54,13 @@ export const NOTIFY_CONFIRMATION_DEPTH = 32; export const eureTransferEvent = parseAbiItem("event Transfer(address indexed from, address indexed to, uint256 value)"); -// SwapExecuted as a standalone event item for getLogs-based crash recovery (must stay -// in sync with the entry in forwarderAbi below). +// Standalone event items for getLogs-based crash recovery, one per keeper transaction +// kind (must stay in sync with the entries in forwarderAbi below). export const swapExecutedEvent = parseAbiItem( - "event SwapExecuted(address indexed caller, uint256 routeIndex, uint256 eureIn, uint256 usdcOut, uint256 referenceRate, uint256 fee, uint256 subsidy, uint256 forwarded)" + "event SwapExecuted(address indexed caller, uint256 routeIndex, uint256 eureIn, uint256 usdcOut, uint256 referenceRate, uint256 fee, uint256 subsidy)" ); +export const forwardedEvent = parseAbiItem("event Forwarded(address indexed caller, uint256 amount)"); +export const recoveredEvent = parseAbiItem("event Recovered(address indexed caller, uint256 eureAmount, uint256 usdcAmount)"); export const erc20Abi = [ { @@ -75,9 +77,28 @@ export const forwarderAbi = [ { inputs: [ { name: "referenceRate", type: "uint256" }, - { name: "routeIndex", type: "uint256" } + { name: "routeIndex", type: "uint256" }, + { name: "amountIn", type: "uint256" } ], - name: "swapAndForward", + name: "swap", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, + { + inputs: [{ name: "amount", type: "uint256" }], + name: "forward", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, + { inputs: [], name: "forwardAll", outputs: [], stateMutability: "nonpayable", type: "function" }, + { + inputs: [ + { name: "eureAmount", type: "uint256" }, + { name: "usdcAmount", type: "uint256" } + ], + name: "recover", outputs: [], stateMutability: "nonpayable", type: "function" @@ -89,7 +110,9 @@ export const forwarderAbi = [ stateMutability: "nonpayable", type: "function" }, - { inputs: [], name: "strandedSince", outputs: [{ name: "", type: "uint64" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "batchOpenedAt", outputs: [{ name: "", type: "uint64" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "RECOVERY_DELAY", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "RECOVERY_WALLET", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, { inputs: [], name: "guardianPaused", outputs: [{ name: "", type: "bool" }], stateMutability: "view", type: "function" }, { inputs: [], name: "EURE", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, { inputs: [], name: "FACTORY", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, @@ -109,7 +132,7 @@ export const forwarderAbi = [ { inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, { anonymous: false, - inputs: [{ indexed: false, name: "strandedSince", type: "uint64" }], + inputs: [{ indexed: false, name: "batchOpenedAt", type: "uint64" }], name: "Poked", type: "event" }, @@ -122,12 +145,30 @@ export const forwarderAbi = [ { indexed: false, name: "usdcOut", type: "uint256" }, { indexed: false, name: "referenceRate", type: "uint256" }, { indexed: false, name: "fee", type: "uint256" }, - { indexed: false, name: "subsidy", type: "uint256" }, - { indexed: false, name: "forwarded", type: "uint256" } + { indexed: false, name: "subsidy", type: "uint256" } ], name: "SwapExecuted", type: "event" }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "caller", type: "address" }, + { indexed: false, name: "amount", type: "uint256" } + ], + name: "Forwarded", + type: "event" + }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "caller", type: "address" }, + { indexed: false, name: "eureAmount", type: "uint256" }, + { indexed: false, name: "usdcAmount", type: "uint256" } + ], + name: "Recovered", + type: "event" + }, { anonymous: false, inputs: [{ indexed: false, name: "paused", type: "bool" }], @@ -275,6 +316,9 @@ export interface ForwarderImmutables { maxReferenceDeviationBps: number; oracle: Address; oracleDecimals: number; + /** Seconds a batch must have been open before the clone accepts `recover` (registry P3). */ + recoveryDelaySeconds: number; + recoveryWallet: Address; slippageBps: number; usdc: Address; } @@ -300,10 +344,23 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise | "SLIPPAGE_BPS" | "MAX_FEE_PPM" | "MAX_REFERENCE_DEVIATION_BPS" + | "RECOVERY_DELAY" + | "RECOVERY_WALLET" >( functionName: T ) => client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName }); - const [eure, factory, usdc, oracle, oracleDecimals, slippageBps, maxFeePpm, maxReferenceDeviationBps] = await Promise.all([ + const [ + eure, + factory, + usdc, + oracle, + oracleDecimals, + slippageBps, + maxFeePpm, + maxReferenceDeviationBps, + recoveryDelay, + recoveryWallet + ] = await Promise.all([ read("EURE"), read("FACTORY"), read("USDC"), @@ -311,7 +368,9 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise read("ORACLE_DECIMALS"), read("SLIPPAGE_BPS"), read("MAX_FEE_PPM"), - read("MAX_REFERENCE_DEVIATION_BPS") + read("MAX_REFERENCE_DEVIATION_BPS"), + read("RECOVERY_DELAY"), + read("RECOVERY_WALLET") ]); const immutables: ForwarderImmutables = { eure, @@ -320,6 +379,8 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise maxReferenceDeviationBps: Number(maxReferenceDeviationBps), oracle, oracleDecimals: Number(oracleDecimals), + recoveryDelaySeconds: Number(recoveryDelay), + recoveryWallet, slippageBps: Number(slippageBps), usdc }; diff --git a/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts deleted file mode 100644 index af3bc0794..000000000 --- a/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import MoneriumAccount from "../../../models/moneriumAccount.model"; -import MoneriumChainCursor from "../../../models/moneriumChainCursor.model"; -import MoneriumConversionExecution, { - MoneriumConversionExecutionStatus -} from "../../../models/moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; -import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; -import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; -import { reconcileConfirmedExecutionAllocations } from "./conversion-executor"; - -describe("confirmed Monerium conversion allocation", () => { - beforeAll(setupTestDatabase); - beforeEach(resetTestDatabase); - - it("waits for the mint cursor and uses the swap log as the exact snapshot boundary", async () => { - const account = await MoneriumAccount.create({ - destination: "0x2222222222222222222222222222222222222222", - fallbackAddress: "0x3333333333333333333333333333333333333333", - forwarderAddress: "0x1111111111111111111111111111111111111111", - profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" - }); - const execution = await MoneriumConversionExecution.create({ - accountId: account.id, - blockNumber: 100, - destination: account.destination, - eureInRaw: "60000000000000000000", - status: MoneriumConversionExecutionStatus.Confirmed, - swapLogIndex: 10, - txHash: "0xswap", - usdcNetRaw: "64800000" - }); - const included = await MoneriumFiatDeposit.create({ - accountId: account.id, - amountRaw: "60000000000000000000", - blockNumber: 100, - chainId: 1, - currency: "eur", - logIndex: 9, - moneriumOrderId: "included-order", - status: MoneriumFiatDepositStatus.Minted, - txHash: "0xmint-before" - }); - await MoneriumFiatDeposit.create({ - accountId: account.id, - amountRaw: "10000000000000000000", - blockNumber: 100, - chainId: 1, - currency: "eur", - logIndex: 11, - moneriumOrderId: "later-order", - status: MoneriumFiatDepositStatus.Minted, - txHash: "0xmint-after" - }); - const cursor = await MoneriumChainCursor.create({ lastBlock: "99", name: "eure-mints:1" }); - const deps = { getChainId: async () => 1 }; - - expect(await reconcileConfirmedExecutionAllocations(deps)).toBe(0); - expect(await MoneriumDepositAllocation.count()).toBe(0); - - await cursor.update({ lastBlock: "100" }); - expect(await reconcileConfirmedExecutionAllocations(deps)).toBe(1); - expect(await reconcileConfirmedExecutionAllocations(deps)).toBe(0); - - const allocations = await MoneriumDepositAllocation.findAll(); - expect(allocations).toHaveLength(1); - expect(allocations[0]).toMatchObject({ - depositId: included.id, - eureInRaw: execution.eureInRaw, - executionId: execution.id, - usdcNetRaw: execution.usdcNetRaw - }); - }); -}); 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 451146bcc..f77f94c74 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 @@ -4,134 +4,148 @@ import { Address, encodeAbiParameters, encodeEventTopics, encodeFunctionData, He import sequelize from "../../../config/database"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import * as chain from "./chain"; import { - AllocatableDeposit, - allocateUsdcProRata, - broadcastSwapSequence, + broadcastExecutionSequence, classifyHashlessPending, conversionAmountsFromSwapEvent, - expectedSwapCalldata, + expectedCalldata, finalizeExecution, - isExpectedSwapTransaction, + isExpectedTransaction, + planAction, + planChunk, pricePlannedSwap, projectSwap, recoveryBlockRanges, runConversionExecutor, - selectDepositsForExecution + settlementState } from "./conversion-executor"; -import * as chain from "./chain"; import * as referenceRate from "./reference-rate"; import { ReferenceQuote } from "./reference-rate"; -// R04 attribution (docs/architecture-monerium-b2b-onramp.md §3): pro-rata by -// amount_raw against eureInRaw, floor division, remainder to the largest deposit. -// No chain or database involved — pure math. - const EUR = 10n ** 18n; const USDC = 10n ** 6n; -function deposit(id: string, amountRaw: bigint): AllocatableDeposit { - return { amountRaw, id }; -} +// One deposit converts in chunks (1 deposit : N swaps); the chunk plan never leaves a +// sub-minimum dust remainder when the last two chunks can share it. +describe("planChunk", () => { + const MIN = 25n * EUR; + const CAP = 10_000n * EUR; -describe("selectDepositsForExecution", () => { - it("selects all deposits when they fit within eureInRaw", () => { - const deposits = [deposit("a", 100n * EUR), deposit("b", 50n * EUR)]; - expect(selectDepositsForExecution(deposits, 150n * EUR)).toEqual(deposits); + it("converts a deposit at or below the cap in one chunk", () => { + expect(planChunk(9_000n * EUR, MIN, CAP)).toBe(9_000n * EUR); + expect(planChunk(CAP, MIN, CAP)).toBe(CAP); }); - it("splits a deposit at the per-swap cap cut", () => { - const deposits = [deposit("a", 50n * EUR), deposit("b", 30n * EUR)]; - expect(selectDepositsForExecution(deposits, 60n * EUR)).toEqual([ - deposits[0], - deposit("b", 10n * EUR) - ]); + it("caps a large deposit and leaves a swappable remainder", () => { + expect(planChunk(25_000n * EUR, MIN, CAP)).toBe(CAP); + expect(planChunk(10_100n * EUR, MIN, CAP)).toBe(CAP); // leftover 100 >= minimum }); - it("allocates only the converted portion of an oversized deposit", () => { - expect(selectDepositsForExecution([deposit("a", 100n * EUR)], 60n * EUR)).toEqual([deposit("a", 60n * EUR)]); + it("shortens the chunk so the leftover is never sub-minimum dust", () => { + expect(planChunk(10_010n * EUR, MIN, CAP)).toBe(10_010n * EUR - MIN); // leftover exactly the minimum }); - it("allocates a remaining deposit portion before younger deposits", () => { - const outstanding = deposit("big", 20n * EUR); - const younger = deposit("small", 5n * EUR); - expect(selectDepositsForExecution([outstanding, younger], 25n * EUR)).toEqual([outstanding, younger]); + it("returns null below the minimum: such a remainder waits for the refund path", () => { + expect(planChunk(24n * EUR, MIN, CAP)).toBeNull(); + expect(planChunk(0n, MIN, CAP)).toBeNull(); }); - it("handles an exact fit and an empty list", () => { - const deposits = [deposit("a", 25n * EUR), deposit("b", 75n * EUR)]; - expect(selectDepositsForExecution(deposits, 100n * EUR)).toEqual(deposits); - expect(selectDepositsForExecution([], 100n * EUR)).toEqual([]); + it("falls back to the cap when it cannot avoid dust (minimum close to the cap)", () => { + expect(planChunk(30n * EUR, 25n * EUR, 25n * EUR)).toBe(25n * EUR); }); }); -describe("allocateUsdcProRata", () => { - it("gives a single deposit covering the full eureIn the entire net USDC", () => { - const shares = allocateUsdcProRata([deposit("a", 100n * EUR)], 100n * EUR, 108n * USDC); - expect(shares.get("a")).toBe(108n * USDC); - }); +function swapRow(eureInRaw: bigint, usdcNetRaw: bigint): MoneriumConversionExecution { + return { + eureInRaw: eureInRaw.toString(), + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Confirmed, + usdcNetRaw: usdcNetRaw.toString() + } as unknown as MoneriumConversionExecution; +} - it("splits proportionally when amounts divide evenly", () => { - const shares = allocateUsdcProRata([deposit("a", 75n * EUR), deposit("b", 25n * EUR)], 100n * EUR, 100n * USDC); - expect(shares.get("a")).toBe(75n * USDC); - expect(shares.get("b")).toBe(25n * USDC); +describe("settlementState", () => { + it("aggregates the confirmed chunks of a deposit", () => { + const state = settlementState({ amountRaw: (100n * EUR).toString() }, [ + swapRow(60n * EUR, 65n * USDC), + swapRow(30n * EUR, 32n * USDC) + ]); + expect(state).toMatchObject({ convertedEureRaw: 90n * EUR, remainingEureRaw: 10n * EUR, usdcNetRaw: 97n * USDC }); }); - it("floors each share and gives the division remainder to the largest deposit", () => { - // 100 USDC over three equal thirds: floor gives 33.333333 each, 1 raw unit of dust - // remains and goes to the largest (tie -> earliest). - const shares = allocateUsdcProRata( - [deposit("a", 1n * EUR), deposit("b", 1n * EUR), deposit("c", 1n * EUR)], - 3n * EUR, - 100n * USDC - ); - expect(shares.get("a")).toBe(33333334n); - expect(shares.get("b")).toBe(33333333n); - expect(shares.get("c")).toBe(33333333n); - expect([...shares.values()].reduce((sum, share) => sum + share, 0n)).toBe(100n * USDC); + it("never reports a negative remainder", () => { + expect(settlementState({ amountRaw: (100n * EUR).toString() }, [swapRow(101n * EUR, 1n)]).remainingEureRaw).toBe(0n); }); +}); - it("gives the remainder to the largest deposit, not the first", () => { - const shares = allocateUsdcProRata([deposit("small", 1n * EUR), deposit("big", 2n * EUR)], 3n * EUR, 100n * USDC); - expect(shares.get("small")).toBe(33333333n); - expect(shares.get("big")).toBe(66666667n); +describe("planAction", () => { + const base = { + batchOpenedAtSec: 1_000n, + convertible: true, + minSwapAmount: 25n * EUR, + nowMs: 1_000_000 + 3 * 60 * 60 * 1000, // three hours after the batch opened + perSwapCap: 10_000n * EUR, + recoveryDelaySeconds: 2 * 60 * 60 + }; + const deposit = (id: string, status: MoneriumFiatDepositStatus, amount: bigint) => + ({ amountRaw: amount.toString(), id, status }) as MoneriumFiatDeposit; + const withSwaps = (row: MoneriumFiatDeposit, swaps: MoneriumConversionExecution[]) => ({ + deposit: row, + state: settlementState(row, swaps) }); - it("handles a dust deposit whose floor share is zero", () => { - // 1 raw-unit deposit against 100 EUR in: floor share is 0; the sum invariant holds - // because the remainder lands on the large deposit. - const shares = allocateUsdcProRata([deposit("dust", 1n), deposit("big", 100n * EUR - 1n)], 100n * EUR, 100n * USDC); - expect(shares.get("dust")).toBe(0n); - expect(shares.get("big")).toBe(100n * USDC); + it("swaps the next chunk of the oldest convertible deposit", () => { + const plan = planAction([withSwaps(deposit("a", MoneriumFiatDepositStatus.Minted, 25_000n * EUR), [])], base); + expect(plan).toMatchObject({ amountIn: 10_000n * EUR, kind: "swap" }); }); - it("conserves the total exactly whenever the selection covers eureInRaw", () => { - const deposits = [deposit("a", 7n * EUR), deposit("b", 13n * EUR), deposit("c", 17n * EUR)]; - const usdcNet = 39_876_543n; - const shares = allocateUsdcProRata(deposits, 37n * EUR, usdcNet); - expect([...shares.values()].reduce((sum, share) => sum + share, 0n)).toBe(usdcNet); + it("forwards a deposit once every chunk is confirmed, with the sum of the nets", () => { + const row = deposit("a", MoneriumFiatDepositStatus.Converting, 100n * EUR); + const plan = planAction([withSwaps(row, [swapRow(60n * EUR, 65n * USDC), swapRow(40n * EUR, 43n * USDC)])], base); + expect(plan).toMatchObject({ kind: "forward", usdcRaw: 108n * USDC }); }); - it("returns an empty allocation for an empty selection or non-positive eureIn", () => { - expect(allocateUsdcProRata([], 100n * EUR, 100n * USDC).size).toBe(0); - expect(allocateUsdcProRata([deposit("a", 1n * EUR)], 0n, 100n * USDC).size).toBe(0); + it("recovers a marked deposit first, once the batch is old enough, and never before", () => { + const stuck = withSwaps(deposit("old", MoneriumFiatDepositStatus.Recovering, 1_500n * EUR), [ + swapRow(1_000n * EUR, 1_138n * USDC) + ]); + const young = withSwaps(deposit("young", MoneriumFiatDepositStatus.Minted, 500n * EUR), []); + expect(planAction([stuck, young], base)).toMatchObject({ + eureRaw: 500n * EUR, + kind: "recover", + usdcRaw: 1_138n * USDC + }); + // Too early for the contract: the younger deposit keeps converting meanwhile. + expect(planAction([stuck, young], { ...base, nowMs: 1_000_000 + 60 * 60 * 1000 })).toMatchObject({ + amountIn: 500n * EUR, + kind: "swap" + }); + expect(planAction([stuck, young], { ...base, batchOpenedAtSec: 0n })).toMatchObject({ kind: "swap" }); }); - it("clamps an oversized sole deposit to the swapped amount and conserves the total", () => { - const shares = allocateUsdcProRata([deposit("big", 100n * EUR)], 100n * EUR, 108n * USDC); - expect(shares.get("big")).toBe(108n * USDC); + it("still recovers on an account that may not convert", () => { + const stuck = withSwaps(deposit("old", MoneriumFiatDepositStatus.Recovering, 500n * EUR), []); + const young = withSwaps(deposit("young", MoneriumFiatDepositStatus.Minted, 500n * EUR), []); + expect(planAction([stuck, young], { ...base, convertible: false })).toMatchObject({ kind: "recover" }); + expect(planAction([young], { ...base, convertible: false })).toMatchObject({ kind: "none" }); }); - it("does not assign output for an unindexed portion of an execution", () => { - const shares = allocateUsdcProRata([deposit("known", 60n * EUR)], 100n * EUR, 100n * USDC); - expect(shares.get("known")).toBe(60n * USDC); + 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") + }); + expect(planAction([], base)).toMatchObject({ kind: "none" }); }); }); describe("conversionAmountsFromSwapEvent", () => { - it("nets the fee out of this swap's output regardless of what was forwarded", () => { + it("nets the fee out of this chunk's output", () => { expect(conversionAmountsFromSwapEvent({ fee: 8n * USDC, subsidy: 0n, usdcOut: 108n * USDC })).toEqual({ feeRaw: "8000000", subsidyRaw: "0", @@ -155,7 +169,7 @@ describe("conversionAmountsFromSwapEvent", () => { }); // Off-chain mirror of the contract's settlement: same numbers as the Foundry suite -// (1000 EURe at 1.14: reference 1140 USDC, target 1138.575, floor 1138.29, oracle floor 1135.44). +// (1000 EURe at 1.14: reference 1140 USDC, target 1138.575, floor 1138.29, oracle floor 1133.16). describe("projectSwap", () => { const vault = { balance: 1_000n * USDC, dailyBudget: 200n * USDC, maxSubsidyPpm: 5_000, paused: false, spentToday: 0n }; const base = { @@ -165,7 +179,7 @@ describe("projectSwap", () => { oracleDecimals: 8, oracleRaw: 114_000_000n, referenceRaw: 114_000_000n, - slippageBps: 40, + slippageBps: 60, targetPpm: 1_250, vault }; @@ -218,7 +232,7 @@ describe("projectSwap", () => { it("defers when a fee-band net sits below the oracle floor (depegged reference, fee side)", () => { // Mirrors test_swap_depeggedReference_feeBranchStillEnforcesOracleFloor: reference 100 bps - // under Chainlink, fill above its target -> fee 0.81 USDC, net 1_127_189_250 < 1_135_440_000. + // under Chainlink, fill above its target -> fee 0.81 USDC, net 1_127_189_250 < 1_133_160_000. const projection = projectSwap({ ...base, quotedOut: 1_128n * USDC, referenceRaw: (114_000_000n * 9_900n) / 10_000n }); expect(projection.fee).toBe(810_750n); expect(projection.subsidy).toBe(0n); @@ -226,11 +240,11 @@ describe("projectSwap", () => { expect(projection.defer).toContain("oracle floor"); }); - it("tolerates a reference only SLIPPAGE_BPS - floorPpm (~25 bps) below Chainlink before the floor binds", () => { + it("tolerates a reference only SLIPPAGE_BPS - floorPpm (~45 bps) below Chainlink before the floor binds", () => { // A fill exactly at the client's floor: no fee, no subsidy, net = reference x (1 - floorPpm). const floorFill = (referenceRaw: bigint) => (((base.amountIn * referenceRaw) / 10n ** 20n) * 998_500n) / 1_000_000n; - const tooLow = (114_000_000n * 9_973n) / 10_000n; // 27 bps below - const fine = (114_000_000n * 9_976n) / 10_000n; // 24 bps below + const tooLow = (114_000_000n * 9_953n) / 10_000n; // 47 bps below + const fine = (114_000_000n * 9_957n) / 10_000n; // 43 bps below expect(projectSwap({ ...base, quotedOut: floorFill(tooLow), referenceRaw: tooLow }).defer).toContain("oracle floor"); expect(projectSwap({ ...base, quotedOut: floorFill(fine), referenceRaw: fine })).toMatchObject({ defer: null, @@ -240,88 +254,95 @@ describe("projectSwap", () => { }); }); -describe("expectedSwapCalldata", () => { - it("rebuilds the exact calldata from the persisted reference and route, or nothing", () => { - expect(expectedSwapCalldata({ referenceRateRaw: null, routeIndex: 0 })).toBeNull(); - expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: null })).toBeNull(); - expect(expectedSwapCalldata({ referenceRateRaw: "114000000", routeIndex: 1 })).toBe( - encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 1n], functionName: "swapAndForward" }) +describe("expectedCalldata", () => { + const swap = { eureInRaw: (1_000n * EUR).toString(), kind: MoneriumConversionExecutionKind.Swap, usdcNetRaw: null }; + + it("rebuilds a swap's calldata from the persisted reference, route and chunk, or nothing", () => { + expect(expectedCalldata({ ...swap, referenceRateRaw: null, routeIndex: 0 })).toBeNull(); + expect(expectedCalldata({ ...swap, referenceRateRaw: "114000000", routeIndex: null })).toBeNull(); + expect(expectedCalldata({ ...swap, referenceRateRaw: "114000000", routeIndex: 1 })).toBe( + encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 1n, 1_000n * EUR], functionName: "swap" }) ); }); + + it("rebuilds a forward's and a recovery's calldata from the persisted amounts", () => { + expect( + expectedCalldata({ + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Forward, + referenceRateRaw: null, + routeIndex: null, + usdcNetRaw: (108n * USDC).toString() + }) + ).toBe(encodeFunctionData({ abi: chain.forwarderAbi, args: [108n * USDC], functionName: "forward" })); + expect( + expectedCalldata({ + eureInRaw: (40n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + referenceRateRaw: null, + routeIndex: null, + usdcNetRaw: (65n * USDC).toString() + }) + ).toBe(encodeFunctionData({ abi: chain.forwarderAbi, args: [40n * EUR, 65n * USDC], functionName: "recover" })); + }); }); describe("classifyHashlessPending", () => { it("fails a row whose send phase was never reached (no persisted nonce)", () => { - expect( - classifyHashlessPending({ latestNonceCount: 0, matchingSwapTxHashes: [], nonce: null, scanComplete: true }) - ).toEqual({ kind: "fail", reason: "crashed before the transaction was sent" }); + expect(classifyHashlessPending({ latestNonceCount: 0, matchingTxHashes: [], nonce: null, scanComplete: true })).toEqual({ + kind: "fail", + reason: "crashed before the transaction was sent" + }); }); - it("adopts the unclaimed SwapExecuted hash when the nonce was consumed", () => { + it("adopts the unclaimed matching hash when the nonce was consumed", () => { expect( - classifyHashlessPending({ latestNonceCount: 8, matchingSwapTxHashes: ["0xlost"], nonce: 7, scanComplete: true }) + classifyHashlessPending({ latestNonceCount: 8, matchingTxHashes: ["0xlost"], nonce: 7, scanComplete: true }) ).toEqual({ kind: "adopt", txHash: "0xlost" }); }); - it("fails a consumed nonce with no SwapExecuted (reverted or replaced)", () => { - const result = classifyHashlessPending({ - latestNonceCount: 8, - matchingSwapTxHashes: [], - nonce: 7, - scanComplete: true - }); - expect(result.kind).toBe("fail"); + it("fails a consumed nonce with no matching transaction (reverted or replaced)", () => { + expect(classifyHashlessPending({ latestNonceCount: 8, matchingTxHashes: [], nonce: 7, scanComplete: true }).kind).toBe( + "fail" + ); }); it("waits while the broadcast may still be in the mempool", () => { - expect( - classifyHashlessPending({ latestNonceCount: 7, matchingSwapTxHashes: [], nonce: 7, scanComplete: true }) - ).toEqual({ kind: "in-flight", reason: "the persisted nonce has not been consumed" }); - }); - - it("remains fail-closed when a persisted nonce is not visible in the mempool", () => { - const result = classifyHashlessPending({ - latestNonceCount: 7, - matchingSwapTxHashes: [], - nonce: 7, - scanComplete: true + expect(classifyHashlessPending({ latestNonceCount: 7, matchingTxHashes: [], nonce: 7, scanComplete: true })).toEqual({ + kind: "in-flight", + reason: "the persisted nonce has not been consumed" }); - expect(result.kind).toBe("in-flight"); }); it("remains pending when recovery is incomplete or ambiguous", () => { expect( - classifyHashlessPending({ latestNonceCount: 8, matchingSwapTxHashes: [], nonce: 7, scanComplete: false }).kind + classifyHashlessPending({ latestNonceCount: 8, matchingTxHashes: [], nonce: 7, scanComplete: false }).kind ).toBe("in-flight"); expect( - classifyHashlessPending({ - latestNonceCount: 8, - matchingSwapTxHashes: ["0xone", "0xtwo"], - nonce: 7, - scanComplete: true - }).kind + classifyHashlessPending({ latestNonceCount: 8, matchingTxHashes: ["0xone", "0xtwo"], nonce: 7, scanComplete: true }) + .kind ).toBe("in-flight"); }); }); -describe("isExpectedSwapTransaction", () => { +describe("isExpectedTransaction", () => { const keeper = "0x1111111111111111111111111111111111111111"; const forwarder = "0x2222222222222222222222222222222222222222"; - const input = encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 0n], functionName: "swapAndForward" }); + const input = encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 0n, 1_000n * EUR], functionName: "swap" }); const expected = { from: keeper, input, nonce: 7, to: forwarder }; - it("requires the exact keeper, nonce, forwarder, and priced calldata", () => { - expect(isExpectedSwapTransaction(expected, keeper, forwarder, 7, input)).toBe(true); - expect(isExpectedSwapTransaction({ ...expected, from: forwarder }, keeper, forwarder, 7, input)).toBe(false); - expect(isExpectedSwapTransaction({ ...expected, nonce: 8 }, keeper, forwarder, 7, input)).toBe(false); - expect(isExpectedSwapTransaction({ ...expected, to: keeper }, keeper, forwarder, 7, input)).toBe(false); - expect(isExpectedSwapTransaction({ ...expected, input: "0x" }, keeper, forwarder, 7, input)).toBe(false); - const otherReference = encodeFunctionData({ + it("requires the exact keeper, nonce, forwarder, and calldata", () => { + expect(isExpectedTransaction(expected, keeper, forwarder, 7, input)).toBe(true); + expect(isExpectedTransaction({ ...expected, from: forwarder }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedTransaction({ ...expected, nonce: 8 }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedTransaction({ ...expected, to: keeper }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedTransaction({ ...expected, input: "0x" }, keeper, forwarder, 7, input)).toBe(false); + const otherChunk = encodeFunctionData({ abi: chain.forwarderAbi, - args: [114_100_000n, 0n], - functionName: "swapAndForward" + args: [114_000_000n, 0n, 999n * EUR], + functionName: "swap" }); - expect(isExpectedSwapTransaction({ ...expected, input: otherReference }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedTransaction({ ...expected, input: otherChunk }, keeper, forwarder, 7, input)).toBe(false); }); }); @@ -336,52 +357,52 @@ describe("recoveryBlockRanges", () => { }); }); -describe("broadcastSwapSequence", () => { - it("never reserves or sends a swap when the preceding poke fails", async () => { +describe("broadcastExecutionSequence", () => { + it("never reserves or sends when the preceding poke fails", async () => { const actions: string[] = []; await expect( - broadcastSwapSequence({ + broadcastExecutionSequence({ broadcastBlockNumber: 100, pendingNonce: 7, pokeNeeded: true, - reserveSwap: async () => { + reserve: async () => { actions.push("reserve"); return true; }, + send: async nonce => { + actions.push(`send:${nonce}`); + return "0xsend"; + }, sendPoke: async nonce => { actions.push(`poke:${nonce}`); throw new Error("poke rejected"); - }, - sendSwap: async nonce => { - actions.push(`swap:${nonce}`); - return "0xswap"; } }) ).rejects.toThrow("poke rejected"); expect(actions).toEqual(["poke:7"]); }); - it("durably reserves the exact swap nonce after poke and before broadcast", async () => { + it("durably reserves the exact nonce after poke and before broadcast", async () => { const actions: string[] = []; - const hash = await broadcastSwapSequence({ + const hash = await broadcastExecutionSequence({ broadcastBlockNumber: 100, pendingNonce: 7, pokeNeeded: true, - reserveSwap: async (nonce, blockNumber) => { + reserve: async (nonce, blockNumber) => { actions.push(`reserve:${nonce}:${blockNumber}`); return true; }, + send: async nonce => { + actions.push(`send:${nonce}`); + return "0xsend"; + }, sendPoke: async nonce => { actions.push(`poke:${nonce}`); - }, - sendSwap: async nonce => { - actions.push(`swap:${nonce}`); - return "0xswap"; } }); - expect(hash).toBe("0xswap"); - expect(actions).toEqual(["poke:7", "reserve:8:100", "swap:8"]); + expect(hash).toBe("0xsend"); + expect(actions).toEqual(["poke:7", "reserve:8:100", "send:8"]); }); }); @@ -403,6 +424,7 @@ describe("runConversionExecutor recovery ordering", () => { accountId: account.id, createdAt: new Date(), id: "execution-1", + kind: MoneriumConversionExecutionKind.Swap, nonce: null, status: MoneriumConversionExecutionStatus.Pending, txHash: null, @@ -457,7 +479,9 @@ describe("pricePlannedSwap", () => { maxReferenceDeviationBps: 100, oracle: "0x5555555555555555555555555555555555555555", oracleDecimals: 8, - slippageBps: 40, + recoveryDelaySeconds: 7_200, + recoveryWallet: "0x7777777777777777777777777777777777777777", + slippageBps: 60, usdc: "0x6666666666666666666666666666666666666666" }; const reference: ReferenceQuote = { @@ -574,7 +598,7 @@ describe("finalizeExecution", () => { function swapLog( address: Address, - args: Record<"eureIn" | "fee" | "forwarded" | "referenceRate" | "routeIndex" | "subsidy" | "usdcOut", bigint> + args: Record<"eureIn" | "fee" | "referenceRate" | "routeIndex" | "subsidy" | "usdcOut", bigint> ) { const inputs = chain.swapExecutedEvent.inputs; return { @@ -582,7 +606,7 @@ describe("finalizeExecution", () => { blockNumber: 100n, data: encodeAbiParameters( inputs.filter(input => !("indexed" in input)), - [args.routeIndex, args.eureIn, args.usdcOut, args.referenceRate, args.fee, args.subsidy, args.forwarded] + [args.routeIndex, args.eureIn, args.usdcOut, args.referenceRate, args.fee, args.subsidy] ), logIndex: 7, topics: encodeEventTopics({ abi: [chain.swapExecutedEvent], args: { caller: KEEPER }, eventName: "SwapExecuted" }), @@ -590,13 +614,38 @@ describe("finalizeExecution", () => { }; } - function receipt(status: "reverted" | "success", logs: ReturnType[] = []): TransactionReceipt { + function forwardedLog(amount: bigint) { + return { + address: FORWARDER, + blockNumber: 100n, + data: encodeAbiParameters([{ type: "uint256" }], [amount]), + logIndex: 3, + topics: encodeEventTopics({ abi: [chain.forwardedEvent], args: { caller: KEEPER }, eventName: "Forwarded" }), + transactionHash: TX + }; + } + + function recoveredLog(eureAmount: bigint, usdcAmount: bigint) { + return { + address: FORWARDER, + blockNumber: 100n, + data: encodeAbiParameters([{ type: "uint256" }, { type: "uint256" }], [eureAmount, usdcAmount]), + logIndex: 4, + topics: encodeEventTopics({ abi: [chain.recoveredEvent], args: { caller: KEEPER }, eventName: "Recovered" }), + transactionHash: TX + }; + } + + function receipt(status: "reverted" | "success", logs: Array> = []): TransactionReceipt { return { blockNumber: 100n, logs, status, transactionHash: TX } as unknown as TransactionReceipt; } - function pendingExecution() { + function pendingExecution(fields: Partial = {}) { const updates: Record[] = []; const execution = { + depositId: null, + kind: MoneriumConversionExecutionKind.Swap, + ...fields, async update(values: Record) { updates.push(values); } @@ -604,12 +653,10 @@ describe("finalizeExecution", () => { return { execution, updates }; } - it("fails the execution on a reverted receipt", async () => { - const { execution, updates } = pendingExecution(); + it("fails the execution on a reverted receipt, naming its kind", async () => { + const { execution, updates } = pendingExecution({ kind: MoneriumConversionExecutionKind.Forward }); await finalizeExecution(execution, receipt("reverted"), FORWARDER, {} as Transaction); - expect(updates).toEqual([ - { blockNumber: 100, error: "swapAndForward reverted", status: MoneriumConversionExecutionStatus.Failed } - ]); + expect(updates).toEqual([{ blockNumber: 100, error: "forward reverted", status: MoneriumConversionExecutionStatus.Failed }]); }); it("fails a successful receipt that carries no SwapExecuted from the forwarder itself", async () => { @@ -617,7 +664,6 @@ describe("finalizeExecution", () => { const foreign = swapLog("0x9999999999999999999999999999999999999999", { eureIn: 1_000n * EUR, fee: 0n, - forwarded: 1_138n * USDC, referenceRate: 114_000_000n, routeIndex: 0n, subsidy: 0n, @@ -633,12 +679,11 @@ describe("finalizeExecution", () => { ]); }); - it("confirms from the forwarder's SwapExecuted and records the event's pricing as authoritative", async () => { + it("confirms a swap from the forwarder's SwapExecuted and records the event's pricing as authoritative", async () => { const { execution, updates } = pendingExecution(); const log = swapLog(FORWARDER, { eureIn: 1_000n * EUR, fee: 425_000n, - forwarded: 1_140n * USDC, // includes unsolicited USDC: must not leak into the amounts referenceRate: 114_000_000n, routeIndex: 1n, subsidy: 0n, @@ -662,4 +707,39 @@ describe("finalizeExecution", () => { } ]); }); + + it("confirms a forward only when the forwarded amount is the planned one", async () => { + const planned = pendingExecution({ kind: MoneriumConversionExecutionKind.Forward, usdcNetRaw: (108n * USDC).toString() }); + await finalizeExecution(planned.execution, receipt("success", [forwardedLog(108n * USDC)]), FORWARDER, {} as Transaction); + expect(planned.updates).toEqual([ + { blockNumber: 100, error: null, status: MoneriumConversionExecutionStatus.Confirmed, swapLogIndex: 3, txHash: TX } + ]); + + const mismatch = pendingExecution({ kind: MoneriumConversionExecutionKind.Forward, usdcNetRaw: (108n * USDC).toString() }); + await finalizeExecution(mismatch.execution, receipt("success", [forwardedLog(107n * USDC)]), FORWARDER, {} as Transaction); + expect(mismatch.updates[0]).toMatchObject({ + error: expect.stringContaining("forwarded 107000000 but the execution planned 108000000"), + status: MoneriumConversionExecutionStatus.Failed + }); + }); + + it("confirms a recovery only when both recovered amounts match the plan", async () => { + const planned = pendingExecution({ + eureInRaw: (40n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + usdcNetRaw: (65n * USDC).toString() + }); + await finalizeExecution(planned.execution, receipt("success", [recoveredLog(40n * EUR, 65n * USDC)]), FORWARDER, {} as Transaction); + expect(planned.updates).toEqual([ + { blockNumber: 100, error: null, status: MoneriumConversionExecutionStatus.Confirmed, swapLogIndex: 4, txHash: TX } + ]); + + const mismatch = pendingExecution({ + eureInRaw: (40n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + usdcNetRaw: (65n * USDC).toString() + }); + await finalizeExecution(mismatch.execution, receipt("success", [recoveredLog(40n * EUR, 60n * USDC)]), FORWARDER, {} as Transaction); + expect(mismatch.updates[0]).toMatchObject({ status: MoneriumConversionExecutionStatus.Failed }); + }); }); 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 7f7591a2f..3cfe4ef24 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -4,16 +4,16 @@ import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; -import MoneriumChainCursor from "../../../models/moneriumChainCursor.model"; import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import { chainlinkAbi, erc20Abi, factoryAbi, + forwardedEvent, forwarderAbi, getChainId, getForwarderImmutables, @@ -22,18 +22,25 @@ import { quoteRouteOutput, readEnabledRoutes, readSubsidyVaultState, + recoveredEvent, SubsidyVaultState, swapExecutedEvent } from "./chain"; -import { withForwarderLock } from "./deposit-processor"; +import { isForwardTransition, withForwarderLock } from "./deposit-processor"; +import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; import { fetchCoinbaseReference, isWithinReferenceBand, ReferenceQuote } from "./reference-rate"; /** - * Per-account conversion executor (plan §3, "Keeper" + "Attribution (R04)"): - * balance >= minSwapAmount -> poke() (stranding marker, R03) + swapAndForward() via the - * private submission transport, with an execution record created and committed BEFORE - * anything is sent. Snapshot-based deposit attribution is deferred until the mint - * cursor covers the confirmed swap's exact block/log boundary. + * Per-account keeper (docs/architecture-monerium-b2b-onramp.md, "Keeper"). Every keeper + * transaction on a forwarder is an execution row bound to the deposit it serves and + * committed BEFORE broadcast: + * - `swap(reference, route, amountIn)`: one chunk of one deposit (1 deposit : N swaps); + * the USDC waits on the clone; + * - `forward(amount)`: once every chunk is confirmed, the whole converted deposit goes + * 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. * * Serialization: every database mutation runs inside the per-forwarder advisory lock * (withForwarderLock). The chain send/wait itself deliberately happens OUTSIDE a lock — @@ -48,7 +55,7 @@ import { fetchCoinbaseReference, isWithinReferenceBand, ReferenceQuote } from ". const RETRY_BASE_MS = 60_000; const RETRY_MAX_MS = 60 * 60_000; -/** How long one cycle waits for the swap receipt before deferring to the next cycle. */ +/** How long one cycle waits for the receipt before deferring to the next cycle. */ const RECEIPT_TIMEOUT_MS = 3 * 60_000; /** @@ -61,6 +68,16 @@ const PRE_SEND_RESERVATION_MS = 5 * 60_000; /** Keep recovery log requests below common RPC block-range limits. */ const RECOVERY_LOG_BLOCK_RANGE = 2000n; +/** Wall-clock margin over the on-chain delay so a `recover` is never simulated a few seconds early. */ +const RECOVERY_ELIGIBILITY_MARGIN_MS = 30_000; + +/** Deposit states the keeper still has work for. */ +const SETTLING_STATUSES = [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Converting, + MoneriumFiatDepositStatus.Recovering +] as const; + /** * Serializes nonce derivation and the broadcasts that consume it across every process * sharing the database: two concurrent senders would otherwise derive the same pending @@ -77,32 +94,31 @@ async function withKeeperSendLock(fn: () => Promise): Promise { }); } -interface SwapBroadcastSequence { +interface ExecutionBroadcastSequence { broadcastBlockNumber: number; pendingNonce: number; pokeNeeded: boolean; - reserveSwap(nonce: number, broadcastBlockNumber: number): Promise; + reserve(nonce: number, broadcastBlockNumber: number): Promise; sendPoke(nonce: number): Promise; - sendSwap(nonce: number): Promise; + send(nonce: number): Promise; } -/** Safety-critical ordering: harmless poke, durable swap identity, value-moving send. */ -export async function broadcastSwapSequence(input: SwapBroadcastSequence): Promise { - let swapNonce = input.pendingNonce; +/** Safety-critical ordering: harmless poke, durable transaction identity, value-moving send. */ +export async function broadcastExecutionSequence(input: ExecutionBroadcastSequence): Promise { + let nonce = input.pendingNonce; if (input.pokeNeeded) { - await input.sendPoke(swapNonce); - swapNonce += 1; + await input.sendPoke(nonce); + nonce += 1; } - if (!(await input.reserveSwap(swapNonce, input.broadcastBlockNumber))) { + if (!(await input.reserve(nonce, input.broadcastBlockNumber))) { throw new Error("execution lost its pre-send reservation"); } - return input.sendSwap(swapNonce); + return input.send(nonce); } /** - * Maps SwapExecuted into accounting values. The client's net for this swap is the fill - * minus the fee plus the vault subsidy paid straight to the destination; `forwarded` is - * deliberately ignored because it may include pre-existing (unsolicited) USDC. + * Maps SwapExecuted into accounting values. The client's net for this chunk is the fill + * minus the fee plus the vault subsidy, all of which stays on the clone until forward. */ export function conversionAmountsFromSwapEvent(event: { fee: bigint; subsidy: bigint; usdcOut: bigint }): { feeRaw: string; @@ -193,183 +209,106 @@ export function projectSwap(input: SwapProjectionInput): SwapProjection { return { defer, fee, net, subsidy }; } -// ------------------------------------------------------------------ R04 allocation math - -export interface AllocatableDeposit { - id: string; - amountRaw: bigint; -} +// ------------------------------------------------------------------ chunk planning /** - * Allocates an execution across oldest outstanding deposit balances. A cap-cut deposit - * is split: its remainder remains available for the next execution. This is what makes - * both one-execution-to-many-deposits and one-deposit-to-many-executions representable. + * Next chunk of a deposit with `remaining` unconverted EURe, or null when nothing can be + * swapped: below `minSwapAmount` the contract refuses, and such a remainder waits for + * the refund path (registry D5). A chunk is capped at `perSwapCap`, but never leaves a + * sub-minimum dust remainder behind when it can avoid it: the last two chunks split so + * both stay swappable. */ -export function selectDepositsForExecution(deposits: AllocatableDeposit[], eureInRaw: bigint): AllocatableDeposit[] { - const selected: AllocatableDeposit[] = []; - let remaining = eureInRaw; - for (const deposit of deposits) { - if (remaining <= 0n) break; - const amountRaw = deposit.amountRaw > remaining ? remaining : deposit.amountRaw; - if (amountRaw <= 0n) continue; - selected.push({ amountRaw, id: deposit.id }); - remaining -= amountRaw; - } - return selected; +export function planChunk(remaining: bigint, minSwapAmount: bigint, perSwapCap: bigint): bigint | null { + if (remaining < minSwapAmount) return null; + if (remaining <= perSwapCap) return remaining; + const leftover = remaining - perSwapCap; + if (leftover >= minSwapAmount) return perSwapCap; + const shortened = remaining - minSwapAmount; + return shortened >= minSwapAmount ? shortened : perSwapCap; } -/** - * R04 pro-rata attribution of the execution's net USDC: each deposit gets - * floor(usdcNetRaw * effectiveAmount / eureInRaw), where effectiveAmount is the - * allocated EURe amount / eureInRaw. When allocations cover the execution exactly, - * floor dust goes to the largest allocation (ties: earliest). If indexed deposits do - * not cover the execution, unknown value remains unattributed instead of inflating a - * known customer's share. - */ -export function allocateUsdcProRata( - deposits: AllocatableDeposit[], - eureInRaw: bigint, - usdcNetRaw: bigint -): Map { - const shares = new Map(); - if (deposits.length === 0 || eureInRaw <= 0n) { - return shares; - } - let allocated = 0n; - let largest = deposits[0]; - for (const deposit of deposits) { - const share = (usdcNetRaw * deposit.amountRaw) / eureInRaw; - shares.set(deposit.id, share); - allocated += share; - if (deposit.amountRaw > largest.amountRaw) { - largest = deposit; - } - } - const coveredEure = deposits.reduce((sum, deposit) => sum + deposit.amountRaw, 0n); - const remainder = usdcNetRaw - allocated; - if (coveredEure === eureInRaw && remainder > 0n) { - shares.set(largest.id, (shares.get(largest.id) as bigint) + remainder); - } - return shares; -} +// ------------------------------------------------------------------ deposit bookkeeping -// ------------------------------------------------------------------ finalization + attribution +export interface DepositSettlementState { + /** Confirmed chunk swaps of the deposit, oldest first. */ + swaps: MoneriumConversionExecution[]; + convertedEureRaw: bigint; + remainingEureRaw: bigint; + /** Sum of the confirmed chunks' net USDC: what a forward or a recovery moves. */ + usdcNetRaw: bigint; +} -function errorText(error: unknown): string { - return (error instanceof Error ? error.message : String(error)).slice(0, 500); +/** Pure aggregation of a deposit's confirmed chunk swaps. */ +export function settlementState( + deposit: Pick, + swaps: MoneriumConversionExecution[] +): DepositSettlementState { + const convertedEureRaw = swaps.reduce((sum, swap) => sum + BigInt(swap.eureInRaw), 0n); + const usdcNetRaw = swaps.reduce((sum, swap) => sum + BigInt(swap.usdcNetRaw ?? "0"), 0n); + const remainingEureRaw = BigInt(deposit.amountRaw) - convertedEureRaw; + return { convertedEureRaw, remainingEureRaw: remainingEureRaw < 0n ? 0n : remainingEureRaw, swaps, usdcNetRaw }; } -async function allocateDeposits(execution: MoneriumConversionExecution, transaction: Transaction): Promise { - if (execution.blockNumber === null || execution.swapLogIndex === null) { - return 0; - } - // R04 snapshot: outstanding portions of minted deposits before the execution's exact - // block/log position, oldest mint first. Unattributed inflows participate because - // their EURe was part of the swapped balance, but never surface as customer claims. - const deposits = await MoneriumFiatDeposit.findAll({ - order: [ - ["block_number", "ASC"], - ["log_index", "ASC"] - ], +async function loadSettlementState(deposit: MoneriumFiatDeposit, transaction?: Transaction): Promise { + const swaps = await MoneriumConversionExecution.findAll({ + order: [["created_at", "ASC"]], transaction, where: { - accountId: execution.accountId, - [Op.or]: [ - { blockNumber: { [Op.lt]: execution.blockNumber } }, - { blockNumber: execution.blockNumber, logIndex: { [Op.lt]: execution.swapLogIndex } } - ], - status: MoneriumFiatDepositStatus.Minted + depositId: deposit.id, + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Confirmed } }); - const existingAllocations = deposits.length - ? await MoneriumDepositAllocation.findAll({ transaction, where: { depositId: deposits.map(deposit => deposit.id) } }) - : []; - const allocatedByDeposit = new Map(); - for (const allocation of existingAllocations) { - allocatedByDeposit.set( - allocation.depositId, - (allocatedByDeposit.get(allocation.depositId) ?? 0n) + BigInt(allocation.eureInRaw) - ); - } - const eureInRaw = BigInt(execution.eureInRaw); - const selected = selectDepositsForExecution( - deposits - .map(deposit => ({ - amountRaw: BigInt(deposit.amountRaw) - (allocatedByDeposit.get(deposit.id) ?? 0n), - id: deposit.id - })) - .filter(deposit => deposit.amountRaw > 0n), - eureInRaw - ); - if (selected.length === 0) { - return 0; - } - const shares = allocateUsdcProRata(selected, eureInRaw, BigInt(execution.usdcNetRaw ?? "0")); - await MoneriumDepositAllocation.bulkCreate( - selected.map(deposit => ({ - depositId: deposit.id, - eureInRaw: deposit.amountRaw.toString(), - executionId: execution.id, - usdcNetRaw: (shares.get(deposit.id) ?? 0n).toString() - })), - { transaction } - ); - const coveredEure = selected.reduce((sum, deposit) => sum + deposit.amountRaw, 0n); - if (coveredEure !== eureInRaw) { - logger.error( - `monerium-b2b: execution ${execution.id} converted ${eureInRaw.toString()} raw EURe but only ` + - `${coveredEure.toString()} was covered by indexed deposit allocations` - ); - } - logger.info( - `monerium-b2b: execution ${execution.id} allocated ${selected.length} deposit portion(s): ` + - selected - .map(deposit => `${deposit.id}:eure=${deposit.amountRaw.toString()},usdc=${(shares.get(deposit.id) ?? 0n).toString()}`) - .join(", ") - ); - return selected.length; + return settlementState(deposit, swaps); } /** - * Allocates confirmed swaps only after the mint cursor has scanned through their - * block. This closes the normal head-lag race and also includes a mint that landed - * between the executor's balance read and the swap transaction. + * The deposits the keeper may act on for an account: chain-indexed (the mint watcher has + * proven the mint), provider-attributed (R09 rows are never converted), oldest mint first. */ -export async function reconcileConfirmedExecutionAllocations( - deps: { getChainId(): Promise } = { getChainId } -): Promise { - const chainId = await deps.getChainId(); - const cursor = await MoneriumChainCursor.findByPk(`eure-mints:${chainId}`); - if (!cursor) return 0; - - const executions = await MoneriumConversionExecution.findAll({ +async function settlingDeposits(accountId: string, transaction?: Transaction): Promise { + return MoneriumFiatDeposit.findAll({ order: [ ["block_number", "ASC"], - ["swap_log_index", "ASC"] + ["log_index", "ASC"] ], + transaction, where: { - blockNumber: { [Op.lte]: Number(cursor.lastBlock) }, - id: { [Op.notIn]: sequelize.literal("(SELECT execution_id FROM monerium_deposit_allocations)") }, - status: MoneriumConversionExecutionStatus.Confirmed, - swapLogIndex: { [Op.ne]: null } + accountId, + blockNumber: { [Op.ne]: null }, + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + status: { [Op.in]: [...SETTLING_STATUSES] } } }); - let allocated = 0; - for (const execution of executions) { - const account = await MoneriumAccount.findByPk(execution.accountId); - if (!account) continue; - allocated += await withForwarderLock(account.forwarderAddress, async transaction => { - if (await MoneriumDepositAllocation.count({ transaction, where: { executionId: execution.id } })) { - return 0; - } - const current = await MoneriumConversionExecution.findByPk(execution.id, { transaction }); - if (!current || current.status !== MoneriumConversionExecutionStatus.Confirmed) { - return 0; - } - return allocateDeposits(current, transaction); - }); +} + +// ------------------------------------------------------------------ finalization + +function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 500); +} + +function eventForKind(kind: MoneriumConversionExecutionKind) { + switch (kind) { + case MoneriumConversionExecutionKind.Swap: + return swapExecutedEvent; + case MoneriumConversionExecutionKind.Forward: + return forwardedEvent; + case MoneriumConversionExecutionKind.Recover: + return recoveredEvent; } - return allocated; +} + +async function failExecution( + execution: MoneriumConversionExecution, + receipt: TransactionReceipt, + error: string, + transaction: Transaction +): Promise { + await execution.update( + { blockNumber: Number(receipt.blockNumber), error, status: MoneriumConversionExecutionStatus.Failed }, + { transaction } + ); } /** Applies a mined receipt to a pending execution: confirmed + event amounts, or failed on revert. */ @@ -379,53 +318,105 @@ export async function finalizeExecution( forwarderAddress: string, transaction: Transaction ): Promise { + const kind = execution.kind; if (receipt.status !== "success") { + await failExecution(execution, receipt, `${kind} reverted`, transaction); + return; + } + const event = eventForKind(kind); + const events = parseEventLogs({ abi: [event], logs: receipt.logs }).filter( + log => log.address.toLowerCase() === forwarderAddress.toLowerCase() + ); + if (events.length === 0) { + // A successful keeper transaction always emits its event; treat absence as failure. + await failExecution( + execution, + receipt, + `receipt succeeded but no ${event.name} event was emitted by the forwarder`, + transaction + ); + return; + } + const log = events[0]; + const blockNumber = Number(receipt.blockNumber); + const txHash = receipt.transactionHash; + + if (kind === MoneriumConversionExecutionKind.Swap) { + const args = log.args as { + eureIn: bigint; + fee: bigint; + referenceRate: bigint; + routeIndex: bigint; + subsidy: bigint; + usdcOut: bigint; + }; await execution.update( { - blockNumber: Number(receipt.blockNumber), - error: "swapAndForward reverted", - status: MoneriumConversionExecutionStatus.Failed + blockNumber, + error: null, + // The event's amountIn, reference and route are authoritative: what the contract + // actually priced and executed, whoever triggered it. + eureInRaw: args.eureIn.toString(), + referenceRateRaw: args.referenceRate.toString(), + routeIndex: Number(args.routeIndex), + ...conversionAmountsFromSwapEvent(args), + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: log.logIndex, + txHash }, { transaction } ); return; } - const swapEvents = parseEventLogs({ abi: forwarderAbi, eventName: "SwapExecuted", logs: receipt.logs }).filter( - log => log.address.toLowerCase() === forwarderAddress.toLowerCase() - ); - if (swapEvents.length === 0) { - // A successful swapAndForward always emits SwapExecuted; treat absence as failure. + + if (kind === MoneriumConversionExecutionKind.Forward) { + const { amount } = log.args as { amount: bigint }; + if (amount.toString() !== execution.usdcNetRaw) { + await failExecution( + execution, + receipt, + `forwarded ${amount} but the execution planned ${execution.usdcNetRaw}`, + transaction + ); + return; + } await execution.update( - { - blockNumber: Number(receipt.blockNumber), - error: "receipt succeeded but no SwapExecuted event was emitted by the forwarder", - status: MoneriumConversionExecutionStatus.Failed - }, + { blockNumber, error: null, status: MoneriumConversionExecutionStatus.Confirmed, swapLogIndex: log.logIndex, txHash }, { transaction } ); + await settleDeposit(execution, MoneriumFiatDepositStatus.Forwarded, transaction); + return; + } + + const { eureAmount, usdcAmount } = log.args as { eureAmount: bigint; usdcAmount: bigint }; + if (eureAmount.toString() !== execution.eureInRaw || usdcAmount.toString() !== execution.usdcNetRaw) { + await failExecution( + execution, + receipt, + `recovered ${eureAmount} EURe / ${usdcAmount} USDC but the execution planned ${execution.eureInRaw} / ${execution.usdcNetRaw}`, + transaction + ); return; } - const swapEvent = swapEvents[0]; - const { eureIn, referenceRate, routeIndex } = swapEvent.args; - const conversionAmounts = conversionAmountsFromSwapEvent(swapEvent.args); await execution.update( - { - blockNumber: Number(receipt.blockNumber), - error: null, - // The event's amountIn, reference and route are authoritative: what the contract - // actually priced and executed, whoever triggered it. - eureInRaw: eureIn.toString(), - referenceRateRaw: referenceRate.toString(), - routeIndex: Number(routeIndex), - ...conversionAmounts, - status: MoneriumConversionExecutionStatus.Confirmed, - swapLogIndex: swapEvent.logIndex, - txHash: receipt.transactionHash - }, + { blockNumber, error: null, status: MoneriumConversionExecutionStatus.Confirmed, swapLogIndex: log.logIndex, txHash }, { transaction } ); } +/** Forward-only deposit transition driven by a confirmed execution; ignored when already past it. */ +async function settleDeposit( + execution: MoneriumConversionExecution, + status: MoneriumFiatDepositStatus, + transaction: Transaction +): Promise { + if (!execution.depositId) return; + const deposit = await MoneriumFiatDeposit.findByPk(execution.depositId, { transaction }); + if (deposit && isForwardTransition(deposit.status, status)) { + await deposit.update({ status }, { transaction }); + } +} + // ------------------------------------------------------------------ pending resolution + backoff type PreparationResult = { kind: "proceed"; attempt: number } | { kind: "skip"; reason: string }; @@ -443,22 +434,36 @@ export interface RecoveryTransactionIdentity { } /** - * The exact swapAndForward calldata a row would have broadcast, rebuilt from the - * reference and route persisted before the send. Null for a row that never got priced. + * The exact calldata a row would have broadcast, rebuilt from what was persisted before + * the send: reference, route and chunk for a swap; the amount for a forward; both + * amounts for a recovery. Null for a swap that never got priced. */ -export function expectedSwapCalldata(execution: { referenceRateRaw: string | null; routeIndex: number | null }): Hex | null { - if (execution.referenceRateRaw === null || execution.routeIndex === null) { - return null; +export function expectedCalldata( + execution: Pick +): Hex | null { + switch (execution.kind) { + case MoneriumConversionExecutionKind.Swap: + if (execution.referenceRateRaw === null || execution.routeIndex === null) return null; + return encodeFunctionData({ + abi: forwarderAbi, + args: [BigInt(execution.referenceRateRaw), BigInt(execution.routeIndex), BigInt(execution.eureInRaw)], + functionName: "swap" + }); + case MoneriumConversionExecutionKind.Forward: + if (execution.usdcNetRaw === null) return null; + return encodeFunctionData({ abi: forwarderAbi, args: [BigInt(execution.usdcNetRaw)], functionName: "forward" }); + case MoneriumConversionExecutionKind.Recover: + if (execution.usdcNetRaw === null) return null; + return encodeFunctionData({ + abi: forwarderAbi, + args: [BigInt(execution.eureInRaw), BigInt(execution.usdcNetRaw)], + functionName: "recover" + }); } - return encodeFunctionData({ - abi: forwarderAbi, - args: [BigInt(execution.referenceRateRaw), BigInt(execution.routeIndex)], - functionName: "swapAndForward" - }); } /** Exact transaction identity required before a lost hash may be adopted. */ -export function isExpectedSwapTransaction( +export function isExpectedTransaction( transaction: RecoveryTransactionIdentity, keeperAddress: string, forwarderAddress: string, @@ -482,7 +487,7 @@ export function isExpectedSwapTransaction( export function classifyHashlessPending(input: { nonce: number | null; latestNonceCount: number; - matchingSwapTxHashes: string[]; + matchingTxHashes: string[]; scanComplete: boolean; }): HashlessPendingClassification { if (input.nonce === null) { @@ -496,13 +501,13 @@ export function classifyHashlessPending(input: { if (!input.scanComplete) { return { kind: "in-flight", reason: "an exact recovery scan could not be completed" }; } - if (input.matchingSwapTxHashes.length === 1) { - return { kind: "adopt", txHash: input.matchingSwapTxHashes[0] }; + if (input.matchingTxHashes.length === 1) { + return { kind: "adopt", txHash: input.matchingTxHashes[0] }; } - if (input.matchingSwapTxHashes.length > 1) { + if (input.matchingTxHashes.length > 1) { return { kind: "in-flight", reason: "multiple exact recovery candidates were found" }; } - return { kind: "fail", reason: "nonce consumed without the expected swap transaction" }; + return { kind: "fail", reason: "nonce consumed without the expected transaction" }; } /** Inclusive, non-overlapping block ranges for a complete bounded recovery scan. */ @@ -517,16 +522,16 @@ export function recoveryBlockRanges(fromBlock: bigint, toBlock: bigint): Array<{ /** * Scans every block since the pre-broadcast head and returns only unclaimed - * SwapExecuted transactions with the exact keeper identity persisted on the row. + * transactions of the row's kind with the exact keeper identity persisted on the row. */ -async function findMatchingSwapTxHashes( +async function findMatchingTxHashes( pending: MoneriumConversionExecution, account: MoneriumAccount, transaction: Transaction -): Promise<{ matchingSwapTxHashes: string[]; scanComplete: boolean }> { - const expectedInput = expectedSwapCalldata(pending); +): Promise<{ matchingTxHashes: string[]; scanComplete: boolean }> { + const expectedInput = expectedCalldata(pending); if (pending.nonce === null || pending.broadcastBlockNumber === null || expectedInput === null) { - return { matchingSwapTxHashes: [], scanComplete: false }; + return { matchingTxHashes: [], scanComplete: false }; } const client = getPublicClient(); const latestBlock = await client.getBlockNumber(); @@ -534,7 +539,7 @@ async function findMatchingSwapTxHashes( for (const range of recoveryBlockRanges(BigInt(pending.broadcastBlockNumber), latestBlock)) { const logs = await client.getLogs({ address: account.forwarderAddress as Address, - event: swapExecutedEvent, + event: eventForKind(pending.kind), ...range }); for (const log of logs) { @@ -542,7 +547,7 @@ async function findMatchingSwapTxHashes( } } if (loggedHashes.size === 0) { - return { matchingSwapTxHashes: [], scanComplete: true }; + return { matchingTxHashes: [], scanComplete: true }; } const known = await MoneriumConversionExecution.findAll({ attributes: ["txHash"], @@ -550,22 +555,21 @@ async function findMatchingSwapTxHashes( where: { id: { [Op.ne]: pending.id }, txHash: { [Op.ne]: null } } }); const claimed = new Set(known.map(row => (row.txHash as string).toLowerCase())); - const hashes = [...loggedHashes]; const keeperAddress = getKeeperWalletClient().account.address; - const matchingSwapTxHashes: string[] = []; + const matchingTxHashes: string[] = []; let claimedExactMatch = false; - for (const hash of hashes) { + for (const hash of loggedHashes) { const candidate = await client.getTransaction({ hash }); - if (!isExpectedSwapTransaction(candidate, keeperAddress, account.forwarderAddress, pending.nonce, expectedInput)) { + if (!isExpectedTransaction(candidate, keeperAddress, account.forwarderAddress, pending.nonce, expectedInput)) { continue; } if (claimed.has(hash.toLowerCase())) { claimedExactMatch = true; } else { - matchingSwapTxHashes.push(hash); + matchingTxHashes.push(hash); } } - return { matchingSwapTxHashes, scanComplete: !claimedExactMatch }; + return { matchingTxHashes, scanComplete: !claimedExactMatch }; } /** @@ -617,8 +621,8 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans const latestNonceCount = await client.getTransactionCount({ address: keeperAddress, blockTag: "latest" }); const recovery = latestNonceCount > pending.nonce - ? await findMatchingSwapTxHashes(pending, account, transaction) - : { matchingSwapTxHashes: [], scanComplete: true }; + ? await findMatchingTxHashes(pending, account, transaction) + : { matchingTxHashes: [], scanComplete: true }; const classification = classifyHashlessPending({ latestNonceCount, nonce: pending.nonce, ...recovery }); if (classification.kind === "in-flight") { return { kind: "skip", reason: `execution ${pending.id} remains pending: ${classification.reason}` }; @@ -759,11 +763,75 @@ export async function pricePlannedSwap(forwarder: Address, factory: Address, amo return { kind: "ready", projection, reference, routeIndex: best.index }; } +// ------------------------------------------------------------------ action planning + +export type PlannedAction = + | { kind: "none"; reason: string } + | { kind: "recover"; deposit: MoneriumFiatDeposit; eureRaw: bigint; usdcRaw: bigint } + | { kind: "forward"; deposit: MoneriumFiatDeposit; usdcRaw: bigint } + | { kind: "swap"; deposit: MoneriumFiatDeposit; amountIn: bigint }; + +export interface ActionPlanningInput { + batchOpenedAtSec: bigint; + convertible: boolean; + minSwapAmount: bigint; + nowMs: number; + perSwapCap: bigint; + recoveryDelaySeconds: number; +} + +/** + * What the keeper should do next for an account, given its settling deposits (oldest + * mint first) and their confirmed chunks. A deposit marked `recovering` goes first, once + * the clone's batch has been open for RECOVERY_DELAY (else it waits without blocking + * younger deposits); then the oldest convertible deposit is forwarded when all of its + * EURe is converted, or swapped in its next chunk. + */ +export function planAction( + deposits: Array<{ deposit: MoneriumFiatDeposit; state: DepositSettlementState }>, + input: ActionPlanningInput +): PlannedAction { + const recoveryEligibleAtMs = + (Number(input.batchOpenedAtSec) + input.recoveryDelaySeconds) * 1000 + RECOVERY_ELIGIBILITY_MARGIN_MS; + for (const { deposit, state } of deposits) { + if (deposit.status !== MoneriumFiatDepositStatus.Recovering) continue; + if (state.remainingEureRaw === 0n && state.usdcNetRaw === 0n) { + // Nothing on chain belongs to it (e.g. forwarded permissionlessly): operator matter. + continue; + } + if (input.batchOpenedAtSec === 0n || input.nowMs < recoveryEligibleAtMs) { + continue; // the contract would revert DelayNotElapsed; younger deposits keep converting + } + return { deposit, eureRaw: state.remainingEureRaw, kind: "recover", usdcRaw: state.usdcNetRaw }; + } + 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` }; + } + return { deposit: next.deposit, kind: "forward", usdcRaw: next.state.usdcNetRaw }; + } + const amountIn = planChunk(next.state.remainingEureRaw, input.minSwapAmount, input.perSwapCap); + if (amountIn === null) { + return { + kind: "none", + reason: `deposit ${next.deposit.id} has ${next.state.remainingEureRaw} raw EURe left, below the minimum swap` + }; + } + return { amountIn, deposit: next.deposit, kind: "swap" }; +} + // ------------------------------------------------------------------ executor /** - * Runs one conversion cycle for an account. Safe to call for accounts with nothing to - * do (cheap chain reads, then returns). + * Runs one keeper cycle for an account: at most one transaction. Safe to call for + * accounts with nothing to do (cheap chain reads, then returns). */ export async function runConversionExecutor(accountId: string): Promise { const account = await MoneriumAccount.findByPk(accountId); @@ -771,8 +839,7 @@ export async function runConversionExecutor(accountId: string): Promise { return; } - // Recover an earlier broadcast before current account state or balance can make this - // cycle return. A successful swap commonly drains the balance below the minimum. + // Recover an earlier broadcast before current account state can make this cycle return. const existingPending = await MoneriumConversionExecution.findOne({ attributes: ["id"], where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Pending } @@ -786,58 +853,77 @@ export async function runConversionExecutor(accountId: string): Promise { return; } } + if (account.status === MoneriumAccountStatus.Closed) { + return; + } - // Suspended/closed/dormant accounts never swap (dormancy is guardian-paused — - // swapAndForward would revert Paused()), but the stranding marker MUST still arm for - // them: the un-pausable dead-man sweep is the client's escape hatch for exactly the - // accounts nobody is operating any more, and poke() is pause-immune by design. - const convertible = - account.status !== MoneriumAccountStatus.Suspended && - account.status !== MoneriumAccountStatus.Closed && - !account.dormantSince; + // Suspended/dormant accounts never swap or forward (dormancy is guardian-paused — the + // clone would revert Paused()), but a recovery still runs for them: the refund path is + // exactly for payments nobody is converting any more, and `recover` ignores the pause. + const convertible = account.status !== MoneriumAccountStatus.Suspended && !account.dormantSince; const client = getPublicClient(); const forwarder = account.forwarderAddress as Address; - const { eure, factory } = await getForwarderImmutables(forwarder); + const immutables = await getForwarderImmutables(forwarder); + const { eure, factory, usdc } = immutables; if ( !config.moneriumB2b.forwarderFactoryAddress || factory.toLowerCase() !== config.moneriumB2b.forwarderFactoryAddress.toLowerCase() ) { throw new Error(`Forwarder ${forwarder} is not bound to the configured trusted factory`); } - const [balance, strandedSince, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([ + const [eureBalance, usdcBalance, batchOpenedAt, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([ client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), - client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }), + 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" }) ]); - // R03: arm the stranding marker whenever funds cross the immutable floor, even below - // the (guardian-tunable) minSwapAmount — the dead-man timers must start regardless of - // whether a swap is currently possible. - const pokeNeeded = strandedSince === 0n && balance >= minSwapFloor; + // 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. + const pokeNeeded = batchOpenedAt === 0n && (eureBalance >= minSwapFloor || usdcBalance > 0n); - if (!convertible || balance < minSwapAmount) { + const planned = await withForwarderLock(account.forwarderAddress, async transaction => { + const deposits = await settlingDeposits(account.id, transaction); + const withState = []; + for (const deposit of deposits) { + withState.push({ deposit, state: await loadSettlementState(deposit, transaction) }); + } + return planAction(withState, { + batchOpenedAtSec: batchOpenedAt, + convertible, + minSwapAmount, + nowMs: Date.now(), + perSwapCap, + recoveryDelaySeconds: immutables.recoveryDelaySeconds + }); + }); + if (planned.kind === "none") { if (pokeNeeded) { await sendPoke(forwarder); } return; } - // Price the planned swap before anything is reserved: reference, route and the - // projected fee/subsidy. A deferral leaves the funds waiting (marker still armed) - // and never creates an execution row. - const amountIn = balance > perSwapCap ? perSwapCap : balance; - const plan = await pricePlannedSwap(forwarder, factory, amountIn); - if (plan.kind === "defer") { - logger.warn(`monerium-b2b: deferring conversion for account ${account.id}: ${plan.reason}`); - if (pokeNeeded) { - await sendPoke(forwarder); + // Price a chunk before anything is reserved: reference, route and the projected + // fee/subsidy. A deferral leaves the funds waiting (marker still armed) and never + // creates an execution row. + let plan: PlannedSwap | null = null; + if (planned.kind === "swap") { + plan = await pricePlannedSwap(forwarder, factory, planned.amountIn); + if (plan.kind === "defer") { + logger.warn(`monerium-b2b: deferring conversion for account ${account.id}: ${plan.reason}`); + if (pokeNeeded) { + await sendPoke(forwarder); + } + return; } - return; } - const swapArgs: readonly [bigint, bigint] = [plan.reference.rateRaw, BigInt(plan.routeIndex)]; + const readyPlan = plan; + const call = executionCall(planned, readyPlan); // 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. @@ -846,21 +932,31 @@ export async function runConversionExecutor(accountId: string): Promise { if (preparation.kind === "skip") { return preparation; } - // Execution-before-send record (plan §3): committed before any broadcast so a crash - // leaves an auditable pending row, never an untracked on-chain swap. + // Execution-before-send record: committed before any broadcast so a crash leaves an + // auditable pending row, never an untracked on-chain transaction. const execution = await MoneriumConversionExecution.create( { accountId: account.id, + depositId: planned.deposit.id, destination: account.destination, - eureInRaw: amountIn.toString(), - referenceAt: plan.reference.time, - referenceRateRaw: plan.reference.rateRaw.toString(), - referenceSource: plan.reference.source, - referenceWindowSeconds: plan.reference.windowSeconds, - routeIndex: plan.routeIndex + eureInRaw: call.eureInRaw, + kind: call.kind, + usdcNetRaw: call.usdcNetRaw, + ...(readyPlan?.kind === "ready" + ? { + referenceAt: readyPlan.reference.time, + referenceRateRaw: readyPlan.reference.rateRaw.toString(), + referenceSource: readyPlan.reference.source, + referenceWindowSeconds: readyPlan.reference.windowSeconds, + routeIndex: readyPlan.routeIndex + } + : {}) }, { transaction } ); + if (planned.kind === "swap" && planned.deposit.status === MoneriumFiatDepositStatus.Minted) { + await planned.deposit.update({ status: MoneriumFiatDepositStatus.Converting }, { transaction }); + } return { attempt: preparation.attempt, execution, kind: "proceed" as const }; }); if (slot.kind === "skip") { @@ -877,30 +973,24 @@ export async function runConversionExecutor(accountId: string): Promise { if (pokeNeeded) { await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); } - await client.simulateContract({ - abi: forwarderAbi, - account: keeper.account, - address: forwarder, - args: swapArgs, - functionName: "swapAndForward" - }); + await simulateCall(client, keeper, forwarder, call.request); - // Send phase, serialized across processes: explicit nonces because poke + swap go + // Send phase, serialized across processes: explicit nonces because poke + send go // back-to-back through the private transport, which may not expose a coherent // pending pool for derivation. Poke is harmless and may fail before the value-moving - // send is attempted; persist the swap nonce only after poke succeeds, immediately - // before swapAndForward is broadcast. + // send is attempted; persist the nonce only after poke succeeds, immediately before + // the value-moving transaction is broadcast. const txHash = await withKeeperSendLock(async () => { const [pendingNonce, broadcastBlock] = await Promise.all([ client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }), client.getBlockNumber() ]); const broadcastBlockNumber = Number(broadcastBlock); - return broadcastSwapSequence({ + return broadcastExecutionSequence({ broadcastBlockNumber, pendingNonce, pokeNeeded, - reserveSwap: async nonce => { + reserve: async nonce => { const [reserved] = await MoneriumConversionExecution.update( { broadcastBlockNumber, nonce }, { where: { id: execution.id, nonce: null, status: MoneriumConversionExecutionStatus.Pending } } @@ -910,6 +1000,7 @@ export async function runConversionExecutor(accountId: string): Promise { } return reserved === 1; }, + send: nonce => writeCall(keeper, forwarder, call.request, nonce), sendPoke: async nonce => { await keeper.writeContract({ abi: forwarderAbi, @@ -919,17 +1010,7 @@ export async function runConversionExecutor(accountId: string): Promise { functionName: "poke", nonce }); - }, - sendSwap: nonce => - keeper.writeContract({ - abi: forwarderAbi, - account: keeper.account, - address: forwarder, - args: swapArgs, - chain: null, - functionName: "swapAndForward", - nonce - }) + } }); }); await execution.update({ txHash }); @@ -958,17 +1039,92 @@ export async function runConversionExecutor(accountId: string): Promise { error: `attempt ${attempt}: ${errorText(error)}`, status: MoneriumConversionExecutionStatus.Failed }); - logger.error(`monerium-b2b: conversion for account ${account.id} failed (attempt ${attempt}):`, error); + logger.error(`monerium-b2b: ${call.kind} for account ${account.id} failed (attempt ${attempt}):`, error); } } -/** Standalone stranding-marker poke for balances between the floor and minSwapAmount. */ +type ExecutionRequest = + | { args: readonly [bigint, bigint, bigint]; functionName: "swap" } + | { args: readonly [bigint]; functionName: "forward" } + | { args: readonly [bigint, bigint]; functionName: "recover" }; + +/** viem needs a literal function name per overload, so the three calls are spelled out. */ +async function simulateCall( + client: ReturnType, + keeper: ReturnType, + address: Address, + request: ExecutionRequest +): Promise { + const base = { abi: forwarderAbi, account: keeper.account, address } as const; + switch (request.functionName) { + case "swap": + await client.simulateContract({ ...base, args: request.args, functionName: "swap" }); + return; + case "forward": + await client.simulateContract({ ...base, args: request.args, functionName: "forward" }); + return; + case "recover": + await client.simulateContract({ ...base, args: request.args, functionName: "recover" }); + return; + } +} + +function writeCall( + keeper: ReturnType, + address: Address, + request: ExecutionRequest, + nonce: number +): Promise { + const base = { abi: forwarderAbi, account: keeper.account, address, chain: null, nonce } as const; + switch (request.functionName) { + case "swap": + return keeper.writeContract({ ...base, args: request.args, functionName: "swap" }); + case "forward": + return keeper.writeContract({ ...base, args: request.args, functionName: "forward" }); + case "recover": + return keeper.writeContract({ ...base, args: request.args, functionName: "recover" }); + } +} + +/** The contract call and the row amounts for a planned action. */ +function executionCall( + planned: Exclude, + plan: PlannedSwap | null +): { eureInRaw: string; kind: MoneriumConversionExecutionKind; request: ExecutionRequest; usdcNetRaw: string | null } { + switch (planned.kind) { + case "swap": { + if (!plan || plan.kind !== "ready") throw new Error("a swap needs a priced plan"); + return { + eureInRaw: planned.amountIn.toString(), + kind: MoneriumConversionExecutionKind.Swap, + request: { args: [plan.reference.rateRaw, BigInt(plan.routeIndex), planned.amountIn], functionName: "swap" }, + usdcNetRaw: null + }; + } + case "forward": + return { + eureInRaw: planned.deposit.amountRaw, + kind: MoneriumConversionExecutionKind.Forward, + request: { args: [planned.usdcRaw], functionName: "forward" }, + usdcNetRaw: planned.usdcRaw.toString() + }; + case "recover": + return { + eureInRaw: planned.eureRaw.toString(), + kind: MoneriumConversionExecutionKind.Recover, + request: { args: [planned.eureRaw, planned.usdcRaw], functionName: "recover" }, + usdcNetRaw: planned.usdcRaw.toString() + }; + } +} + +/** Standalone batch-marker poke for funds the keeper cannot act on yet. */ async function sendPoke(forwarder: Address): Promise { try { const client = getPublicClient(); const keeper = getKeeperWalletClient(); await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); - // Implicit nonce, so the send still serializes with the swap path's derivation. + // Implicit nonce, so the send still serializes with the value-moving path's derivation. const hash = await withKeeperSendLock(() => keeper.writeContract({ abi: forwarderAbi, @@ -981,7 +1137,38 @@ async function sendPoke(forwarder: Address): Promise { logger.info(`monerium-b2b: poked forwarder ${forwarder} (${hash})`); } catch (error) { // Best-effort: poke is also permissionless on-chain, so a missed poke only delays - // the stranding timers until the next cycle. + // the batch clocks until the next cycle. logger.warn(`monerium-b2b: poke for 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 + * batch has been open for RECOVERY_DELAY. Returns the reason it could not, or null. + */ +export async function markDepositForRecovery(depositId: string): Promise { + const deposit = await MoneriumFiatDeposit.findByPk(depositId); + if (!deposit) return "deposit not found"; + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) return "deposit has no account"; + return withForwarderLock(account.forwarderAddress, async transaction => { + const current = await MoneriumFiatDeposit.findByPk(depositId, { transaction }); + if (!current) return "deposit not found"; + if (!isForwardTransition(current.status, MoneriumFiatDepositStatus.Recovering)) { + return `deposit is ${current.status} and cannot be recovered`; + } + if (current.blockNumber === null) { + return "deposit has no chain-indexed mint yet"; + } + const pending = await MoneriumConversionExecution.count({ + transaction, + where: { depositId, status: MoneriumConversionExecutionStatus.Pending } + }); + if (pending > 0) { + return "deposit has a pending execution; retry once it settled"; + } + await current.update({ status: MoneriumFiatDepositStatus.Recovering }, { transaction }); + return null; + }); +} 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 27cfa3e81..0a372e750 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 @@ -3,7 +3,6 @@ import MoneriumAccount from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; @@ -15,7 +14,8 @@ import { processMoneriumWebhookInbox } from "./deposit-processor"; -const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; +const { Converting, Forwarded, Held, Minted, Pending, Recovering, RecoveryFailed, Refunded, Returned } = + MoneriumFiatDepositStatus; const PROFILE_ID = "11111111-1111-4111-8111-111111111111"; const ORDER_ID = "22222222-2222-4222-8222-222222222222"; const PROCESSOR_DEPS = { getChainId: async () => 11155111 }; @@ -33,17 +33,34 @@ describe("forward-only deposit status transitions", () => { expect(isForwardTransition(Held, Pending)).toBe(false); }); - it("treats minted and returned as terminal", () => { - for (const to of [Pending, Held, Returned]) { + it("lets a minted deposit convert or enter the refund path, never regress", () => { + expect(isForwardTransition(Minted, Converting)).toBe(true); + expect(isForwardTransition(Minted, Recovering)).toBe(true); + for (const to of [Pending, Held, Returned, Forwarded, Refunded]) { expect(isForwardTransition(Minted, to)).toBe(false); } - for (const to of [Pending, Held, Minted]) { - expect(isForwardTransition(Returned, to)).toBe(false); + }); + + it("settles a converting deposit by forward or by recovery", () => { + expect(isForwardTransition(Converting, Forwarded)).toBe(true); + expect(isForwardTransition(Converting, Recovering)).toBe(true); + expect(isForwardTransition(Converting, Minted)).toBe(false); + expect(isForwardTransition(Recovering, Refunded)).toBe(true); + expect(isForwardTransition(Recovering, RecoveryFailed)).toBe(true); + expect(isForwardTransition(RecoveryFailed, Recovering)).toBe(true); // operator retry + expect(isForwardTransition(Recovering, Forwarded)).toBe(false); + }); + + it("treats forwarded, returned and refunded as terminal", () => { + for (const terminal of [Forwarded, Returned, Refunded]) { + for (const to of Object.values(MoneriumFiatDepositStatus)) { + expect(isForwardTransition(terminal, to)).toBe(false); + } } }); it("never allows a self-transition write", () => { - for (const status of [Pending, Held, Minted, Returned]) { + for (const status of Object.values(MoneriumFiatDepositStatus)) { expect(isForwardTransition(status, status)).toBe(false); } }); @@ -180,7 +197,6 @@ describe("order-event inbox processing (end to end)", () => { async function createAccount(): Promise { return MoneriumAccount.create({ destination: "0x2222222222222222222222222222222222222222", - fallbackAddress: "0x3333333333333333333333333333333333333333", forwarderAddress: FORWARDER, profileId: PROFILE_ID }); @@ -263,12 +279,7 @@ describe("order-event inbox processing (end to end)", () => { status: MoneriumFiatDepositStatus.Minted, txHash: "0xmint" }); - const allocation = await MoneriumDepositAllocation.create({ - depositId: unattributed.id, - eureInRaw: unattributed.amountRaw, - executionId: execution.id, - usdcNetRaw: execution.usdcNetRaw as string - }); + await execution.update({ depositId: unattributed.id }); await MoneriumWebhookEvent.create({ eventId: "evt-late-order", payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) @@ -277,7 +288,7 @@ describe("order-event inbox processing (end to end)", () => { expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1); expect(await MoneriumFiatDeposit.count()).toBe(1); await unattributed.reload(); - await allocation.reload(); + await execution.reload(); expect(unattributed).toMatchObject({ blockHash: "0xblock", blockNumber: 100, @@ -286,7 +297,7 @@ describe("order-event inbox processing (end to end)", () => { moneriumOrderId: ORDER_ID, txHash: "0xmint" }); - expect(allocation.depositId).toBe(unattributed.id); + expect(execution.depositId).toBe(unattributed.id); }); it("merges an unattributed mint when a tx hash resolves equal-amount order ambiguity", async () => { @@ -325,12 +336,7 @@ describe("order-event inbox processing (end to end)", () => { status: MoneriumFiatDepositStatus.Minted, txHash: "0xmint" }); - const allocation = await MoneriumDepositAllocation.create({ - depositId: unattributed.id, - eureInRaw: unattributed.amountRaw, - executionId: execution.id, - usdcNetRaw: execution.usdcNetRaw as string - }); + await execution.update({ depositId: unattributed.id }); await MoneriumWebhookEvent.create({ eventId: "evt-ambiguous-order-resolved", payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) @@ -339,7 +345,7 @@ describe("order-event inbox processing (end to end)", () => { await processMoneriumWebhookInbox(PROCESSOR_DEPS); await providerDeposit.reload(); await otherDeposit.reload(); - await allocation.reload(); + await execution.reload(); expect(await MoneriumFiatDeposit.count()).toBe(2); expect(providerDeposit).toMatchObject({ blockHash: "0xblock", @@ -350,7 +356,7 @@ describe("order-event inbox processing (end to end)", () => { txHash: "0xmint" }); expect(otherDeposit).toMatchObject({ blockNumber: null, status: MoneriumFiatDepositStatus.Pending, txHash: null }); - expect(allocation.depositId).toBe(providerDeposit.id); + expect(execution.depositId).toBe(providerDeposit.id); }); it("never merges a quarantined mint into a terminal returned order", async () => { @@ -385,12 +391,7 @@ describe("order-event inbox processing (end to end)", () => { txHash: "0xswap", usdcNetRaw: "108000000" }); - const allocation = await MoneriumDepositAllocation.create({ - depositId: unattributed.id, - eureInRaw: amountRaw, - executionId: execution.id, - usdcNetRaw: execution.usdcNetRaw as string - }); + await execution.update({ depositId: unattributed.id }); await MoneriumWebhookEvent.create({ eventId: "evt-returned-order-mint", payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) @@ -399,7 +400,7 @@ describe("order-event inbox processing (end to end)", () => { await processMoneriumWebhookInbox(PROCESSOR_DEPS); await providerDeposit.reload(); await unattributed.reload(); - await allocation.reload(); + await execution.reload(); expect(providerDeposit).toMatchObject({ blockHash: null, blockNumber: null, @@ -409,7 +410,7 @@ describe("order-event inbox processing (end to end)", () => { txHash: null }); expect(unattributed.txHash).toBe("0xmint"); - expect(allocation.depositId).toBe(unattributed.id); + expect(execution.depositId).toBe(unattributed.id); }); it("does not adopt an unattributed mint for a first-seen returned order", async () => { @@ -437,12 +438,7 @@ describe("order-event inbox processing (end to end)", () => { txHash: "0xswap", usdcNetRaw: "108000000" }); - const allocation = await MoneriumDepositAllocation.create({ - depositId: unattributed.id, - eureInRaw: amountRaw, - executionId: execution.id, - usdcNetRaw: execution.usdcNetRaw as string - }); + await execution.update({ depositId: unattributed.id }); await MoneriumWebhookEvent.create({ eventId: "evt-first-seen-returned", payload: orderEvent("rejected", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) @@ -451,7 +447,7 @@ describe("order-event inbox processing (end to end)", () => { await processMoneriumWebhookInbox(PROCESSOR_DEPS); const providerDeposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: ORDER_ID } }); await unattributed.reload(); - await allocation.reload(); + await execution.reload(); expect(await MoneriumFiatDeposit.count()).toBe(2); expect(providerDeposit).toMatchObject({ blockNumber: null, @@ -459,7 +455,7 @@ describe("order-event inbox processing (end to end)", () => { txHash: "0xmint" }); expect(unattributed.moneriumOrderId).toBe("unattr:first-seen-returned"); - expect(allocation.depositId).toBe(unattributed.id); + expect(execution.depositId).toBe(unattributed.id); }); it("discards wrong-currency, wrong-chain, and foreign-profile orders", async () => { 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 87b2c53f5..4a0965546 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -8,7 +8,7 @@ import { parseUnits } from "viem"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import MoneriumAccount from "../../../models/moneriumAccount.model"; -import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; +import MoneriumConversionExecution from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; import { getChainId, moneriumChainForChainId } from "./chain"; @@ -39,8 +39,10 @@ export async function withForwarderLock(forwarderAddress: string, fn: (transa }); } -// Forward-only lattice (plan §3): pending → minted/held/returned; a compliance hold can -// still resolve to minted or returned; minted/returned are terminal. +// Forward-only lattice (plan §3): the provider states first — pending → minted/held/ +// returned, a compliance hold resolves to minted or returned — then the keeper's +// settlement branch (minted → converting → forwarded) or the refund branch (minted or +// converting → recovering → refunded, or recovery_failed for the operator, who may retry). const FORWARD_TRANSITIONS: Record = { [MoneriumFiatDepositStatus.Pending]: [ MoneriumFiatDepositStatus.Minted, @@ -48,14 +50,28 @@ const FORWARD_TRANSITIONS: Record { @@ -47,7 +48,6 @@ describe("monerium b2b manager events", () => { contactEmail: "ops@client.example.com", destination: DESTINATION, externalSubjectId: "client-1", - fallbackAddress: FALLBACK, forwarderAddress: FORWARDER, managerProfileId: manager.id, moneriumProfileId: MONERIUM_PROFILE @@ -155,11 +155,44 @@ describe("monerium b2b manager events", () => { expect(await WebhookDelivery.count()).toBe(0); }); - it("emits one aggregate DEPOSIT_CONVERTED only after every allocation reaches confirmation depth", async () => { + it("emits DEPOSIT_RECEIVED for a deposit the keeper already started converting", async () => { + const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Converting, + txHash: "0xmint" + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + const deliveries = await WebhookDelivery.findAll(); + expect(deliveries).toHaveLength(1); + expect(deliveries[0].payload).toMatchObject({ payload: { depositId: deposit.id, status: "converting" } }); + }); + + it("emits one DEPOSIT_CONVERTED with every chunk once the forward reaches confirmation depth", async () => { const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_CONVERTED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 999, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + receivedEventAt: new Date(), + status: MoneriumFiatDepositStatus.Converting, + txHash: "0xmint" + }); const firstExecution = await MoneriumConversionExecution.create({ accountId: mapped.accountId, blockNumber: 1000, + depositId: deposit.id, destination: DESTINATION, eureInRaw: "60000000000000000000", feeRaw: "81000", @@ -172,6 +205,7 @@ describe("monerium b2b manager events", () => { const secondExecution = await MoneriumConversionExecution.create({ accountId: mapped.accountId, blockNumber: 1001, + depositId: deposit.id, destination: DESTINATION, eureInRaw: "40000000000000000000", feeRaw: "0", @@ -181,43 +215,31 @@ describe("monerium b2b manager events", () => { txHash: "0xswap2", usdcNetRaw: "43200000" }); - const deposit = await MoneriumFiatDeposit.create({ - accountId: mapped.accountId, - amountRaw: "100000000000000000000", - blockNumber: 999, - chainId: 11155111, - currency: "eur", - logIndex: 1, - moneriumOrderId: "order-1", - receivedEventAt: new Date(), - status: MoneriumFiatDepositStatus.Minted, - txHash: "0xmint" - }); - await MoneriumDepositAllocation.create({ - depositId: deposit.id, - eureInRaw: "60000000000000000000", - executionId: firstExecution.id, - usdcNetRaw: "64800000" - }); - // A partially converted deposit must not produce a misleading final event. - await emitMoneriumDepositEvents(depsAtBlock(BigInt(1000 + NOTIFY_CONFIRMATION_DEPTH))); + // Converted but not forwarded: the partner must not see a final event yet. + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH))); expect(await WebhookDelivery.count()).toBe(0); - await MoneriumDepositAllocation.create({ + await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1002, depositId: deposit.id, - eureInRaw: "40000000000000000000", - executionId: secondExecution.id, - usdcNetRaw: "43200000" + destination: DESTINATION, + eureInRaw: "100000000000000000000", + kind: MoneriumConversionExecutionKind.Forward, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xforward", + usdcNetRaw: "108000000" }); + await deposit.update({ status: MoneriumFiatDepositStatus.Forwarded }); // One block short of the depth: nothing emitted, marker untouched. - await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH - 1))); + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1002 + NOTIFY_CONFIRMATION_DEPTH - 1))); expect(await WebhookDelivery.count()).toBe(0); await deposit.reload(); expect(deposit.convertedEventAt).toBeNull(); - await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH))); + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1002 + NOTIFY_CONFIRMATION_DEPTH))); const deliveries = await WebhookDelivery.findAll(); expect(deliveries).toHaveLength(1); expect(deliveries[0]).toMatchObject({ @@ -244,6 +266,8 @@ describe("monerium b2b manager events", () => { } ], depositId: deposit.id, + forwardTxHash: "0xforward", + status: "forwarded", usdcNetRaw: "108000000" } }); @@ -251,7 +275,7 @@ describe("monerium b2b manager events", () => { expect(deposit.convertedEventAt).not.toBeNull(); // Replay is a no-op. - await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH))); + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1002 + NOTIFY_CONFIRMATION_DEPTH))); expect(await WebhookDelivery.count()).toBe(1); }); 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 83f37220d..6c9d4f776 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -6,15 +6,14 @@ import { type WebhookPayload } from "@vortexfi/shared"; import { Op } from "sequelize"; -import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import ManagedProfile from "../../../models/managedProfile.model"; import MoneriumAccount from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import webhookService from "../webhook/webhook.service"; import { enqueueWebhookDeliveries } from "../webhook/webhook-outbox.service"; @@ -90,7 +89,10 @@ async function emitReceivedEvents(): Promise { logIndex: { [Op.ne]: null }, moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, receivedEventAt: null, - status: MoneriumFiatDepositStatus.Minted, + // Any state past the mint: the keeper may have started converting within the cycle. + status: { + [Op.notIn]: [MoneriumFiatDepositStatus.Pending, MoneriumFiatDepositStatus.Held, MoneriumFiatDepositStatus.Returned] + }, txHash: { [Op.ne]: null } } }); @@ -124,9 +126,8 @@ async function emitConvertedEvents(deps: ManagerEventDeps): Promise { order: [["created_at", "ASC"]], where: { convertedEventAt: null, - id: { [Op.in]: sequelize.literal("(SELECT deposit_id FROM monerium_deposit_allocations)") }, moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, - status: MoneriumFiatDepositStatus.Minted + status: MoneriumFiatDepositStatus.Forwarded } }); if (deposits.length === 0) return; @@ -144,28 +145,17 @@ async function emitConvertedEvents(deps: ManagerEventDeps): Promise { } async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: bigint): Promise { - const allocations = await MoneriumDepositAllocation.findAll({ - order: [["created_at", "ASC"]], - where: { depositId: deposit.id } - }); - if (allocations.length === 0) return; - const allocatedEure = allocations.reduce((sum, allocation) => sum + BigInt(allocation.eureInRaw), 0n); - if (allocatedEure !== BigInt(deposit.amountRaw)) return; - const executions = await MoneriumConversionExecution.findAll({ - where: { id: allocations.map(allocation => allocation.executionId) } + order: [["created_at", "ASC"]], + where: { depositId: deposit.id, status: MoneriumConversionExecutionStatus.Confirmed } }); - const executionById = new Map(executions.map(execution => [execution.id, execution])); - if (executions.length !== allocations.length) return; - if (executions.some(execution => execution.status !== MoneriumConversionExecutionStatus.Confirmed)) return; - // Confirmation-depth gate (plan §3, registry P9): only notify once the execution - // blocks are NOTIFY_CONFIRMATION_DEPTH below the head, so a shallow reorg cannot - // produce a delivered-then-vanished aggregate conversion event. - if ( - executions.some( - execution => execution.blockNumber === null || head < BigInt(execution.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH) - ) - ) { + const forward = executions.find(execution => execution.kind === MoneriumConversionExecutionKind.Forward); + const swaps = executions.filter(execution => execution.kind === MoneriumConversionExecutionKind.Swap); + if (!forward || swaps.length === 0) return; + // Confirmation-depth gate (plan §3, registry P9): only notify once the forward is + // NOTIFY_CONFIRMATION_DEPTH blocks below the head, so a shallow reorg cannot produce a + // delivered-then-vanished conversion event. The chunks precede the forward by construction. + if (forward.blockNumber === null || head < BigInt(forward.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH)) { return; } @@ -177,17 +167,15 @@ async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: eventType: WebhookEventType.DEPOSIT_CONVERTED, payload: { ...depositPayloadBase(deposit, account), - conversions: allocations.map(allocation => { - const execution = executionById.get(allocation.executionId) as MoneriumConversionExecution; - return { - eureInRaw: allocation.eureInRaw, - execution: executionPricing(execution), - executionId: execution.id, - txHash: execution.txHash, - usdcNetRaw: allocation.usdcNetRaw - }; - }), - usdcNetRaw: allocations.reduce((sum, allocation) => sum + BigInt(allocation.usdcNetRaw), 0n).toString() + conversions: swaps.map(execution => ({ + eureInRaw: execution.eureInRaw, + execution: executionPricing(execution), + executionId: execution.id, + txHash: execution.txHash, + usdcNetRaw: execution.usdcNetRaw ?? "0" + })), + forwardTxHash: forward.txHash, + usdcNetRaw: forward.usdcNetRaw ?? "0" }, timestamp: new Date().toISOString() }; @@ -197,9 +185,9 @@ async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: /** * Emits the manager-facing deposit events into the durable webhook outbox: - * DEPOSIT_RECEIVED once a deposit is minted, DEPOSIT_CONVERTED once every portion is - * allocated and all of its executions are confirmed at notification depth. Emission - * markers make each event fire exactly once regardless of the advancing component. + * DEPOSIT_RECEIVED once a deposit is minted, DEPOSIT_CONVERTED once the whole converted + * deposit was forwarded to the destination and that forward sits at notification depth. + * Emission markers make each event fire exactly once regardless of the advancing component. */ export async function emitMoneriumDepositEvents(deps: ManagerEventDeps = defaultDeps): Promise { try { 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 abd9eb4c5..43645111e 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -7,8 +7,7 @@ import { detectConfigDrift, diffAssociation, eip1167RuntimeCode, - normalizeIban, - STRANDED_WARN_MS + normalizeIban } from "./monitoring"; // Pure monitoring logic (implementation plan D3): quote-impact math against the T6 @@ -53,21 +52,22 @@ describe("computeQuoteImpactBps", () => { }); describe("classifyExecutableDepth", () => { - const SLIPPAGE_BPS = 40; + const SLIPPAGE_BPS = 60; it("is ok when the best route clears SLIPPAGE_BPS at both sizes", () => { expect(classifyExecutableDepth(11, 30, SLIPPAGE_BPS).severity).toBe("ok"); }); it("warns when only cap-sized fills would need a subsidy", () => { - const verdict = classifyExecutableDepth(11, 55, SLIPPAGE_BPS); + const verdict = classifyExecutableDepth(11, 75, SLIPPAGE_BPS); expect(verdict.severity).toBe("warn"); expect(verdict.reason).toContain("perSwapCap"); }); it("errors on a subsidizable min-size impact but names the subsidy, not a pause", () => { - // 50 bps raw impact: the vault (50 bps cap) still covers it and the keeper executes. - const verdict = classifyExecutableDepth(50, 80, SLIPPAGE_BPS); + // 70 bps raw impact: the vault (50 bps cap) still covers the shortfall below the + // policy floor and the keeper executes. + const verdict = classifyExecutableDepth(70, 90, SLIPPAGE_BPS); expect(verdict.severity).toBe("error"); expect(verdict.reason).toContain("subsidy"); expect(verdict.reason).toContain("permissionless path would revert"); @@ -76,25 +76,26 @@ describe("classifyExecutableDepth", () => { }); describe("classifyStranding", () => { - const TRIGGER_DELAY = 86_400n; // 24h, registry P4 placeholder + const RECOVERY_DELAY = 7_200n; // 2h, registry P3 + const TRIGGER_DELAY = 86_400n; // 24h, registry P4 const now = 1_800_000_000_000; // fixed epoch ms - const armedAt = (msAgo: number): bigint => BigInt(Math.floor((now - msAgo) / 1000)); + const openedAt = (msAgo: number): bigint => BigInt(Math.floor((now - msAgo) / 1000)); - it("is ok when the marker is not armed", () => { - expect(classifyStranding(0n, TRIGGER_DELAY, now)).toBe("ok"); + it("is ok when no batch is open", () => { + expect(classifyStranding(0n, RECOVERY_DELAY, TRIGGER_DELAY, now)).toBe("ok"); }); - it("is ok within the warn window", () => { - expect(classifyStranding(armedAt(60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("ok"); + it("is ok inside the promised window", () => { + expect(classifyStranding(openedAt(60 * 60 * 1000), RECOVERY_DELAY, TRIGGER_DELAY, now)).toBe("ok"); }); - it("warns after 12h", () => { - expect(classifyStranding(armedAt(STRANDED_WARN_MS + 60_000), TRIGGER_DELAY, now)).toBe("warn"); + it("warns once the promised window (RECOVERY_DELAY) is missed", () => { + expect(classifyStranding(openedAt(2 * 60 * 60 * 1000 + 60_000), RECOVERY_DELAY, TRIGGER_DELAY, now)).toBe("warn"); }); it("errors past TRIGGER_DELAY", () => { - expect(classifyStranding(armedAt(25 * 60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("error"); + expect(classifyStranding(openedAt(25 * 60 * 60 * 1000), RECOVERY_DELAY, TRIGGER_DELAY, now)).toBe("error"); }); }); @@ -176,7 +177,6 @@ describe("normalizeIban", () => { describe("detectConfigDrift", () => { const base = { destination: "0x1111111111111111111111111111111111111111", - fallbackAddress: "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", floorPpm: 1500, targetPpm: 1250 }; @@ -186,18 +186,12 @@ describe("detectConfigDrift", () => { expect(detectConfigDrift(base, onchain)).toEqual({ errors: [], ownerAuthorizedUpdates: {} }); }); - it("classifies destination/fallback changes as owner-authorized updates (R07)", () => { - const onchain = { - ...base, - destination: "0x4444444444444444444444444444444444444444", - fallbackAddress: "0x5555555555555555555555555555555555555555" - }; - const drift = detectConfigDrift(base, onchain); - expect(drift.errors).toEqual([]); - expect(drift.ownerAuthorizedUpdates).toEqual({ - destination: onchain.destination, - fallbackAddress: onchain.fallbackAddress - }); + it("alarms on a destination change: the clone has no setter for it", () => { + const drift = detectConfigDrift(base, { ...base, destination: "0x4444444444444444444444444444444444444444" }); + expect(drift.ownerAuthorizedUpdates).toEqual({}); + expect(drift.errors).toEqual([ + "destination changed on chain to 0x4444444444444444444444444444444444444444 (recorded 0x1111111111111111111111111111111111111111)" + ]); }); it("classifies a fee-policy change as a guardian-authorized reconciliation (P11)", () => { diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index f90b38ade..47e3a4374 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -30,11 +30,10 @@ import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProduc * 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). - * 2. Stranded-balance monitor: forwarders whose on-chain stranding marker (R03) has - * been armed for more than STRANDED_WARN_MS warn; past TRIGGER_DELAY (the - * permissionless-trigger delay, registry P4) they error — the keeper should have - * converted long before either — and within SWEEP_IMMINENT_MS of SWEEP_DELAY the - * error says so: the dead-man sweep to the fallback is about to become possible. + * 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. * 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. @@ -44,10 +43,9 @@ import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProduc * linked). Vortex holds the whitelabel credentials, so association changes cannot * be prevented client-side — only detected. * 4. Config reconciliation (manifest re-verification, R07): re-reads per-clone config - * and clone bytecode. destination/fallbackAddress changes are owner-authorized by - * construction (`onlyFallback` in the contract) — they are reconciled into the DB - * and logged, not alarmed, as are guardian fee-policy changes (P11); bytecode or - * registration drift is an incident. + * and clone bytecode. Guardian fee-policy changes (P11) are reconciled into the DB + * and logged, not alarmed; the destination has no setter, so a change there, like + * bytecode or registration drift, is an incident. * 6. Reference-venue monitor: the Coinbase product the reference VWAP reads. A delisted * or halted product keeps answering the candles endpoint with stale data, so every * keeper swap would defer silently; its status is probed instead of assumed. @@ -55,23 +53,16 @@ import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProduc * None of these monitors hold keys or send transactions; they are detection-only. */ -/** Stranding marker armed longer than this warns (the keeper converts within minutes normally). */ -export const STRANDED_WARN_MS = 12 * 60 * 60 * 1000; - -/** Inside this window before SWEEP_DELAY the stranding error names the imminent sweep. */ -export const SWEEP_IMMINENT_MS = 2 * 24 * 60 * 60 * 1000; - /** Full monitoring pass at most this often (the worker cycles every minute). */ const MONITORING_INTERVAL_MS = 30 * 60_000; // Read-only getters beyond the keeper ABI surface in ./chain.ts. const forwarderMonitoringAbi = parseAbi([ "function destination() view returns (address)", - "function fallbackAddress() view returns (address)", "function targetPpm() view returns (uint32)", "function floorPpm() view returns (uint32)", "function TRIGGER_DELAY() view returns (uint256)", - "function SWEEP_DELAY() view returns (uint256)" + "function RECOVERY_DELAY() view returns (uint256)" ]); const factoryMonitoringAbi = parseAbi([ @@ -135,18 +126,23 @@ export function classifyExecutableDepth( export type StrandingSeverity = "error" | "ok" | "warn"; /** - * Severity of an armed stranding marker (R03): older than TRIGGER_DELAY (the - * permissionless-trigger delay) is an error; older than STRANDED_WARN_MS a warning. + * Severity of an open batch marker: older than TRIGGER_DELAY (the permissionless-trigger + * delay) is an error; older than RECOVERY_DELAY (the promised window) a warning. */ -export function classifyStranding(strandedSinceSec: bigint, triggerDelaySec: bigint, nowMs: number): StrandingSeverity { - if (strandedSinceSec === 0n) { +export function classifyStranding( + batchOpenedAtSec: bigint, + recoveryDelaySec: bigint, + triggerDelaySec: bigint, + nowMs: number +): StrandingSeverity { + if (batchOpenedAtSec === 0n) { return "ok"; } - const armedMs = nowMs - Number(strandedSinceSec) * 1000; - if (armedMs >= Number(triggerDelaySec) * 1000) { + const openMs = nowMs - Number(batchOpenedAtSec) * 1000; + if (openMs >= Number(triggerDelaySec) * 1000) { return "error"; } - if (armedMs >= STRANDED_WARN_MS) { + if (openMs >= Number(recoveryDelaySec) * 1000) { return "warn"; } return "ok"; @@ -231,7 +227,6 @@ export function diffAssociation(db: AssociationDbRecord, live: LiveAssociationSt export interface ForwarderConfigRecord { destination: string; - fallbackAddress: string; floorPpm: number; targetPpm: number; } @@ -239,18 +234,16 @@ export interface ForwarderConfigRecord { export interface ConfigDriftResult { /** Immutable-config violations — should be impossible; alarm, never reconcile. */ errors: string[]; - /** Authorized on-chain transitions — reconcile the DB: destination/fallbackAddress - * change only via the client's own key (R07), the fee policy only via the guardian's - * timelocked setter (P11); both leave an on-chain event trail. */ - ownerAuthorizedUpdates: Partial; + /** Authorized on-chain transitions — reconcile the DB: the fee policy changes only via + * the guardian's timelocked setter (P11), which leaves an on-chain event trail. */ + ownerAuthorizedUpdates: Partial>; } /** - * Classifies drift between the DB config record and on-chain clone state. - * destination/fallbackAddress are mutable ONLY by the client's fallbackAddress - * (`onlyFallback`) and the fee policy ONLY by the guardian's timelocked setter (P11), so any - * change in those is an expected authorized transition to reconcile; everything else - * (bytecode, registration) is immutable and a change there is an incident. + * Classifies drift between the DB config record and on-chain clone state. The fee + * policy is mutable ONLY by the guardian's timelocked setter (P11), so a change there is + * an expected authorized transition to reconcile; the destination has no setter at all, + * so a change there (like bytecode or registration drift) is an incident. */ export function detectConfigDrift(db: ForwarderConfigRecord, onchain: ForwarderConfigRecord): ConfigDriftResult { const result: ConfigDriftResult = { errors: [], ownerAuthorizedUpdates: {} }; @@ -261,10 +254,7 @@ export function detectConfigDrift(db: ForwarderConfigRecord, onchain: ForwarderC result.ownerAuthorizedUpdates.floorPpm = onchain.floorPpm; } if (db.destination.toLowerCase() !== onchain.destination.toLowerCase()) { - result.ownerAuthorizedUpdates.destination = onchain.destination; - } - if (db.fallbackAddress.toLowerCase() !== onchain.fallbackAddress.toLowerCase()) { - result.ownerAuthorizedUpdates.fallbackAddress = onchain.fallbackAddress; + result.errors.push(`destination changed on chain to ${onchain.destination} (recorded ${db.destination})`); } return result; } @@ -354,7 +344,7 @@ export async function runExecutableDepthCheck(): Promise { } } -/** Stranded-balance monitor: armed R03 markers older than 12h warn, older than TRIGGER_DELAY error. */ +/** Stranded-balance monitor: batches open longer than RECOVERY_DELAY warn, longer than TRIGGER_DELAY error. */ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promise { const accounts = await monitoredAccounts([ MoneriumAccountStatus.Onboarding, @@ -365,46 +355,41 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi return; } const client = getPublicClient(); - const { factory } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); - const [minSwapFloor, triggerDelay, sweepDelay] = await Promise.all([ + 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" - }), - client.readContract({ - abi: forwarderMonitoringAbi, - address: accounts[0].forwarderAddress as Address, - functionName: "SWEEP_DELAY" }) ]); for (const account of accounts) { try { const forwarder = account.forwarderAddress as Address; - const { eure } = await getForwarderImmutables(forwarder); - const [balance, strandedSince] = await Promise.all([ + const { eure, usdc } = await getForwarderImmutables(forwarder); + const [eureBalance, usdcBalance, batchOpenedAt] = await Promise.all([ client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), - client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }) + client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }) ]); - if (balance < minSwapFloor) { + if (eureBalance < minSwapFloor && usdcBalance === 0n) { continue; } - const severity = classifyStranding(strandedSince, triggerDelay, now); + const severity = classifyStranding(batchOpenedAt, BigInt(recoveryDelaySeconds), triggerDelay, now); if (severity === "ok") { continue; } - const armedMs = now - Number(strandedSince) * 1000; - const hours = Math.floor(armedMs / 3_600_000); - const sweepInMs = Number(sweepDelay) * 1000 - armedMs; - const sweepNote = - sweepInMs <= SWEEP_IMMINENT_MS - ? `; dead-man sweep to the fallback ${sweepInMs <= 0 ? "is live" : `possible in ${Math.ceil(sweepInMs / 3_600_000)}h`}` - : ""; + const openMs = now - Number(batchOpenedAt) * 1000; + const hours = Math.floor(openMs / 3_600_000); const message = - `monerium-b2b: stranded EURe on forwarder ${forwarder} (account ${account.id}): balance=${balance}, ` + - `marker armed ${hours}h ago${severity === "error" ? " — past TRIGGER_DELAY, permissionless trigger is live" : ""}${sweepNote}`; + `monerium-b2b: stranded funds on forwarder ${forwarder} (account ${account.id}): eure=${eureBalance}, usdc=${usdcBalance}, ` + + `batch open for ${hours}h${ + severity === "error" + ? " — past TRIGGER_DELAY, permissionless trigger is live" + : " — past RECOVERY_DELAY, the promised window was missed: forward or recover (runbook §2.7)" + }`; if (severity === "error") { logger.error(message); } else { @@ -459,8 +444,8 @@ export async function runAssociationMonitor(): Promise { /** * Config reconciliation (manifest re-verification pass, R07): re-checks per-clone - * state against the DB. Owner-authorized destination/fallback changes are reconciled - * (DB update + configVersion bump), immutable violations are alarmed. + * state against the DB. Guardian fee-policy changes are reconciled (DB update + + * configVersion bump), immutable violations are alarmed. */ export async function runConfigReconciliation(): Promise { const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); @@ -497,9 +482,8 @@ export async function runConfigReconciliation(): Promise { implementationByFactory.set(trustedFactory.toLowerCase(), implementation); } - const [destination, fallbackAddress, targetPpm, floorPpm, isForwarder, code] = await Promise.all([ + const [destination, targetPpm, floorPpm, isForwarder, code] = await Promise.all([ client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "destination" }), - client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "fallbackAddress" }), client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "targetPpm" }), client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "floorPpm" }), client.readContract({ @@ -523,21 +507,15 @@ export async function runConfigReconciliation(): Promise { } const drift = detectConfigDrift( - { - destination: account.destination, - fallbackAddress: account.fallbackAddress, - floorPpm: account.floorPpm, - targetPpm: account.targetPpm - }, - { destination, fallbackAddress, floorPpm: Number(floorPpm), targetPpm: Number(targetPpm) } + { destination: account.destination, floorPpm: account.floorPpm, targetPpm: account.targetPpm }, + { destination, floorPpm: Number(floorPpm), targetPpm: Number(targetPpm) } ); for (const error of drift.errors) { logger.error(`monerium-b2b: config violation on forwarder ${forwarder} (account ${account.id}): ${error}`); } if (Object.keys(drift.ownerAuthorizedUpdates).length > 0) { - // Authorized transition: destination/fallback change only via the client's - // fallbackAddress (R07), the fee policy only via the guardian's timelocked setter - // (P11) — reconcile, do not alarm. + // Authorized transition: the fee policy changes only via the guardian's + // timelocked setter (P11) — reconcile, do not alarm. await account.update({ ...drift.ownerAuthorizedUpdates, configVersion: account.configVersion + 1 }); logger.warn( `monerium-b2b: reconciled owner-authorized config change on forwarder ${forwarder} (account ${account.id}): ` + diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts index 8c4e5514e..3743c34b5 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -12,7 +12,6 @@ import { advanceOnboardingAccounts, type OnboardingDeps } from "./onboarding"; const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; -const FALLBACK = "0x3333333333333333333333333333333333333333"; const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; const IBAN = "EE08 7224 5745 6244 9516"; const ETHEREUM_CHAIN = { getChainId: async () => 1 }; @@ -76,7 +75,6 @@ async function createMappedAccount(overrides: Partial { await createMappedAccount({ status: MoneriumAccountStatus.Active }); await MoneriumAccount.create({ destination: DESTINATION, - fallbackAddress: FALLBACK, forwarderAddress: "0x9999999999999999999999999999999999999999", profileId: crypto.randomUUID() // no vortexProfileId: pre-mapping row stays operator-managed diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 204ce6c61..fd5a2ec6d 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -4,7 +4,7 @@ import sequelize from "../../config/database"; import logger from "../../config/logger"; import { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; import { isKeeperChainConfigured } from "../services/monerium-b2b/chain"; -import { reconcileConfirmedExecutionAllocations, runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; +import { runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; import { processMoneriumWebhookInbox, pruneProcessedWebhookEvents } from "../services/monerium-b2b/deposit-processor"; import { runDormancyGate } from "../services/monerium-b2b/dormancy"; import { emitMoneriumDepositEvents } from "../services/monerium-b2b/manager-events"; @@ -60,7 +60,6 @@ class MoneriumB2bWorker { } } else { const mintedAccountIds = await runMintWatcher(); - await reconcileConfirmedExecutionAllocations(); const candidateIds = await this.conversionCandidates(mintedAccountIds); for (const accountId of candidateIds) { try { @@ -92,21 +91,28 @@ class MoneriumB2bWorker { /** * Accounts worth running the executor for: settled mints from this cycle and accounts - * with chain-indexed, minted-but-unallocated deposits. The executor never outruns the - * watcher's reorg-safety window merely because a live balance is visible. + * 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. */ private async conversionCandidates(mintedAccountIds: string[]): Promise { const candidates = new Set(mintedAccountIds); const outstanding = await sequelize.query<{ accountId: string }>( - `SELECT DISTINCT deposit.account_id AS "accountId" - FROM monerium_fiat_deposits AS deposit - LEFT JOIN monerium_deposit_allocations AS allocation ON allocation.deposit_id = deposit.id - WHERE deposit.status = :minted - AND deposit.block_number IS NOT NULL - GROUP BY deposit.id - HAVING COALESCE(SUM(allocation.eure_in_raw), 0) < deposit.amount_raw`, - { replacements: { minted: MoneriumFiatDepositStatus.Minted }, type: QueryTypes.SELECT } + `SELECT DISTINCT account_id AS "accountId" + FROM monerium_fiat_deposits + WHERE status IN (:settling) + AND block_number IS NOT NULL`, + { + replacements: { + settling: [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Converting, + MoneriumFiatDepositStatus.Recovering + ] + }, + type: QueryTypes.SELECT + } ); for (const row of outstanding) { candidates.add(row.accountId); diff --git a/apps/api/src/database/migrations/080-monerium-whole-deposit-settlement.ts b/apps/api/src/database/migrations/080-monerium-whole-deposit-settlement.ts new file mode 100644 index 000000000..7079969bb --- /dev/null +++ b/apps/api/src/database/migrations/080-monerium-whole-deposit-settlement.ts @@ -0,0 +1,83 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Whole-deposit settlement (docs/adr-0005-monerium-b2b-onramp.md, amendment 2026-09-17): +// a swap converts one chunk of one deposit, so an execution carries the deposit it serves +// (1 deposit : N executions) and the N:M allocation join has no job left; a forward and a +// recovery are keeper transactions of their own kind. The client-held fallback role left +// the contract, and the deposit lifecycle gains the settlement and refund states. +const DEPOSIT_STATUS_VALUES = ["converting", "forwarded", "recovering", "refunded", "recovery_failed"]; + +export async function up(queryInterface: QueryInterface): Promise { + // ADD VALUE runs outside a transaction (umzug does not wrap migrations); the values + // are usable by the statements below. + for (const value of DEPOSIT_STATUS_VALUES) { + await queryInterface.sequelize.query(`ALTER TYPE "enum_monerium_fiat_deposits_status" ADD VALUE IF NOT EXISTS '${value}'`); + } + + await queryInterface.addColumn("monerium_conversion_executions", "kind", { + allowNull: false, + defaultValue: "swap", + type: DataTypes.ENUM("swap", "forward", "recover") + }); + await queryInterface.addColumn("monerium_conversion_executions", "deposit_id", { + allowNull: true, + references: { key: "id", model: "monerium_fiat_deposits" }, + type: DataTypes.UUID + }); + await queryInterface.addIndex("monerium_conversion_executions", ["deposit_id"]); + + // Backfill from the allocation join: an execution that served exactly one deposit is + // that deposit's chunk. One that spanned several deposits cannot be represented in the + // 1:N model and must be reconciled by hand before this deploys. + const [spanning] = (await queryInterface.sequelize.query( + "SELECT execution_id FROM monerium_deposit_allocations GROUP BY execution_id HAVING COUNT(*) > 1 LIMIT 1" + )) as [unknown[], unknown]; + if (spanning.length > 0) { + throw new Error("A Monerium conversion execution spans several deposits; reconcile the allocations before deploying"); + } + await queryInterface.sequelize.query( + "UPDATE monerium_conversion_executions AS e SET deposit_id = a.deposit_id " + + "FROM monerium_deposit_allocations AS a WHERE a.execution_id = e.id" + ); + // The previous contract forwarded every chunk on the spot: a fully allocated deposit is + // already at the client's destination. + await queryInterface.sequelize.query( + "UPDATE monerium_fiat_deposits AS d SET status = 'forwarded' WHERE d.status = 'minted' AND " + + "(SELECT COALESCE(SUM(a.eure_in_raw), 0) FROM monerium_deposit_allocations AS a WHERE a.deposit_id = d.id) >= d.amount_raw" + ); + + await queryInterface.dropTable("monerium_deposit_allocations", {}); + await queryInterface.removeColumn("monerium_accounts", "fallback_address"); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_accounts", "fallback_address", { + allowNull: true, + type: DataTypes.STRING(42) + }); + await queryInterface.createTable("monerium_deposit_allocations", { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + deposit_id: { + allowNull: false, + onDelete: "CASCADE", + references: { key: "id", model: "monerium_fiat_deposits" }, + type: DataTypes.UUID + }, + eure_in_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + execution_id: { + allowNull: false, + onDelete: "CASCADE", + references: { key: "id", model: "monerium_conversion_executions" }, + type: DataTypes.UUID + }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + usdc_net_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) } + }); + await queryInterface.addIndex("monerium_deposit_allocations", ["deposit_id", "execution_id"], { unique: true }); + await queryInterface.addIndex("monerium_deposit_allocations", ["execution_id"]); + await queryInterface.removeColumn("monerium_conversion_executions", "deposit_id"); + await queryInterface.removeColumn("monerium_conversion_executions", "kind"); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_monerium_conversion_executions_kind"'); + // Postgres cannot drop enum values; the added deposit statuses stay in the type. +} diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 2e322ab07..899a0822a 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -13,7 +13,6 @@ import ManagedProfileManager from "./managedProfileManager.model"; import MoneriumAccount from "./moneriumAccount.model"; import MoneriumChainCursor from "./moneriumChainCursor.model"; import MoneriumConversionExecution from "./moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "./moneriumDepositAllocation.model"; import MoneriumFiatDeposit from "./moneriumFiatDeposit.model"; import MoneriumWebhookEvent from "./moneriumWebhookEvent.model"; import Notification from "./notification.model"; @@ -39,10 +38,8 @@ MoneriumAccount.hasMany(MoneriumFiatDeposit, { as: "fiatDeposits", foreignKey: " MoneriumFiatDeposit.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); MoneriumAccount.hasMany(MoneriumConversionExecution, { as: "conversionExecutions", foreignKey: "accountId" }); MoneriumConversionExecution.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); -MoneriumFiatDeposit.hasMany(MoneriumDepositAllocation, { as: "allocations", foreignKey: "depositId" }); -MoneriumDepositAllocation.belongsTo(MoneriumFiatDeposit, { as: "deposit", foreignKey: "depositId" }); -MoneriumConversionExecution.hasMany(MoneriumDepositAllocation, { as: "allocations", foreignKey: "executionId" }); -MoneriumDepositAllocation.belongsTo(MoneriumConversionExecution, { as: "execution", foreignKey: "executionId" }); +MoneriumFiatDeposit.hasMany(MoneriumConversionExecution, { as: "executions", foreignKey: "depositId" }); +MoneriumConversionExecution.belongsTo(MoneriumFiatDeposit, { as: "deposit", foreignKey: "depositId" }); MoneriumAccount.belongsTo(User, { as: "vortexProfile", foreignKey: "vortexProfileId" }); User.hasOne(MoneriumAccount, { as: "moneriumAccount", foreignKey: "vortexProfileId" }); Webhook.hasMany(WebhookDelivery, { as: "deliveries", foreignKey: "webhookId" }); @@ -146,7 +143,6 @@ const models = { MoneriumAccount, MoneriumChainCursor, MoneriumConversionExecution, - MoneriumDepositAllocation, MoneriumFiatDeposit, MoneriumWebhookEvent, Notification, diff --git a/apps/api/src/models/moneriumAccount.model.ts b/apps/api/src/models/moneriumAccount.model.ts index 74d46a1fc..bc177176f 100644 --- a/apps/api/src/models/moneriumAccount.model.ts +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -20,7 +20,6 @@ export interface MoneriumAccountAttributes { iban: string | null; forwarderAddress: string; destination: string; - fallbackAddress: string; targetPpm: number; floorPpm: number; configVersion: number; @@ -54,7 +53,6 @@ class MoneriumAccount declare iban: string | null; declare forwarderAddress: string; declare destination: string; - declare fallbackAddress: string; declare targetPpm: number; declare floorPpm: number; declare configVersion: number; @@ -87,11 +85,6 @@ MoneriumAccount.init( field: "dormant_since", type: DataTypes.DATE }, - fallbackAddress: { - allowNull: false, - field: "fallback_address", - type: DataTypes.STRING(42) - }, // Fee policy mirror (ppm below the reference rate) for accounting and drift // detection only; the clone's values are authoritative (P11 reconciliation). floorPpm: { diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index 1ebf65be5..9731ab4db 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -7,13 +7,26 @@ export enum MoneriumConversionExecutionStatus { Failed = "failed" } -// One row per swapAndForward execution (or intentional batch). Allocation to deposits -// is cursor-gated and snapshot-based (plan §3, R04): included deposits precede the -// execution's exact block/log position and are not yet allocated; pro-rata by amount, -// remainder to largest. +/** Which keeper transaction the row records (docs/architecture-monerium-b2b-onramp.md, keeper). */ +export enum MoneriumConversionExecutionKind { + /** `swap(reference, route, amountIn)`: one chunk of one deposit, USDC kept on the clone. */ + Swap = "swap", + /** `forward(amount)`: the whole converted deposit to the client's destination. */ + Forward = "forward", + /** `recover(eure, usdc)`: the deposit's unconverted EURe and converted USDC to the recovery wallet. */ + Recover = "recover" +} + +// One row per keeper transaction on a forwarder, bound to the deposit it serves +// (1 deposit : N executions). For a swap, eureInRaw is the chunk and usdcNetRaw the +// client's net for it; for a forward, usdcNetRaw is the amount pushed to the +// destination; for a recovery, eureInRaw and usdcNetRaw are the two amounts moved. export interface MoneriumConversionExecutionAttributes { id: string; accountId: string; + kind: MoneriumConversionExecutionKind; + /** The deposit this transaction serves; null only for rows that predate the 1:N model. */ + depositId: string | null; eureInRaw: string; // 18-decimal base units usdcGrossRaw: string | null; // 6-decimal base units feeRaw: string | null; @@ -46,6 +59,8 @@ export interface MoneriumConversionExecutionAttributes { type MoneriumConversionExecutionCreationAttributes = Optional< MoneriumConversionExecutionAttributes, | "id" + | "kind" + | "depositId" | "usdcGrossRaw" | "feeRaw" | "subsidyRaw" @@ -72,6 +87,8 @@ class MoneriumConversionExecution { declare id: string; declare accountId: string; + declare kind: MoneriumConversionExecutionKind; + declare depositId: string | null; declare eureInRaw: string; declare usdcGrossRaw: string | null; declare feeRaw: string | null; @@ -117,6 +134,11 @@ MoneriumConversionExecution.init( field: "created_at", type: DataTypes.DATE }, + depositId: { + allowNull: true, + field: "deposit_id", + type: DataTypes.UUID + }, destination: { allowNull: false, type: DataTypes.STRING(42) @@ -140,6 +162,11 @@ MoneriumConversionExecution.init( primaryKey: true, type: DataTypes.UUID }, + kind: { + allowNull: false, + defaultValue: MoneriumConversionExecutionKind.Swap, + type: DataTypes.ENUM(...Object.values(MoneriumConversionExecutionKind)) + }, nonce: { allowNull: true, type: DataTypes.INTEGER @@ -207,7 +234,7 @@ MoneriumConversionExecution.init( } }, { - indexes: [{ fields: ["account_id", "status"] }], + indexes: [{ fields: ["account_id", "status"] }, { fields: ["deposit_id"] }], modelName: "MoneriumConversionExecution", sequelize, tableName: "monerium_conversion_executions" diff --git a/apps/api/src/models/moneriumDepositAllocation.model.ts b/apps/api/src/models/moneriumDepositAllocation.model.ts deleted file mode 100644 index 2fb0f2138..000000000 --- a/apps/api/src/models/moneriumDepositAllocation.model.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { DataTypes, Model, Optional } from "sequelize"; -import sequelize from "../config/database"; - -export interface MoneriumDepositAllocationAttributes { - id: string; - depositId: string; - executionId: string; - /** Portion of the deposit consumed by this execution (EURe, 18 decimals). */ - eureInRaw: string; - /** Portion of this execution's net swap output attributed to this deposit (6 decimals). */ - usdcNetRaw: string; - createdAt: Date; - updatedAt: Date; -} - -type MoneriumDepositAllocationCreationAttributes = Optional< - MoneriumDepositAllocationAttributes, - "id" | "createdAt" | "updatedAt" ->; - -class MoneriumDepositAllocation - extends Model - implements MoneriumDepositAllocationAttributes -{ - declare id: string; - declare depositId: string; - declare executionId: string; - declare eureInRaw: string; - declare usdcNetRaw: string; - declare createdAt: Date; - declare updatedAt: Date; -} - -MoneriumDepositAllocation.init( - { - createdAt: { - allowNull: false, - defaultValue: DataTypes.NOW, - field: "created_at", - type: DataTypes.DATE - }, - depositId: { - allowNull: false, - field: "deposit_id", - type: DataTypes.UUID - }, - eureInRaw: { - allowNull: false, - field: "eure_in_raw", - type: DataTypes.DECIMAL(38, 0) - }, - executionId: { - allowNull: false, - field: "execution_id", - type: DataTypes.UUID - }, - id: { - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - type: DataTypes.UUID - }, - updatedAt: { - allowNull: false, - defaultValue: DataTypes.NOW, - field: "updated_at", - type: DataTypes.DATE - }, - usdcNetRaw: { - allowNull: false, - field: "usdc_net_raw", - type: DataTypes.DECIMAL(38, 0) - } - }, - { - indexes: [{ fields: ["deposit_id", "execution_id"], unique: true }, { fields: ["execution_id"] }], - modelName: "MoneriumDepositAllocation", - sequelize, - tableName: "monerium_deposit_allocations" - } -); - -export default MoneriumDepositAllocation; diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts index dfca1552d..294df8786 100644 --- a/apps/api/src/models/moneriumFiatDeposit.model.ts +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -2,15 +2,31 @@ import { DataTypes, Model, Op, Optional } from "sequelize"; import sequelize from "../config/database"; export enum MoneriumFiatDepositStatus { + /** Provider order placed, EURe not minted yet. */ Pending = "pending", + /** EURe minted to the forwarder; convertible once chain-indexed. */ Minted = "minted", + /** Provider compliance hold before the mint. */ Held = "held", - Returned = "returned" + /** Provider returned the payment before the mint. Terminal. */ + Returned = "returned", + /** At least one chunk swap was sent; USDC accumulates on the forwarder. */ + Converting = "converting", + /** The whole converted deposit reached the client's destination. Terminal. */ + Forwarded = "forwarded", + /** The promised window was missed (or an operator intervened): funds go to the recovery wallet for a bank refund. */ + Recovering = "recovering", + /** The exact EUR amount was redeemed to the payer's bank account. Terminal. */ + Refunded = "refunded", + /** A recovery step failed beyond retry; operator runbook. Terminal until reset by an operator. */ + RecoveryFailed = "recovery_failed" } // One row per Monerium issue order (SEPA deposit → EURe mint). Identity/idempotency: // monerium_order_id for accounting, (chain_id, tx_hash, log_index) for the on-chain -// mint. Status transitions are forward-only (plan §3, R06/R13). +// mint. Status transitions are forward-only (plan §3, R06/R13): the provider states +// first, then the settlement (converting → forwarded) or refund (recovering → refunded) +// branch; executions bound to the deposit carry the chain evidence for each step. export interface MoneriumFiatDepositAttributes { id: string; accountId: string; diff --git a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts index 779ad8f36..f19f9711b 100644 --- a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts +++ b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts @@ -2,8 +2,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test" import type { CorridorCountry } from "@vortexfi/shared"; import { config } from "../config/vars"; import ManagedProfileManager from "../models/managedProfileManager.model"; -import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../models/moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "../models/moneriumDepositAllocation.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../models/moneriumFiatDeposit.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestApiKey, createTestUser } from "../test-utils/factories"; @@ -13,7 +15,6 @@ import { provisionMoneriumB2bAccount } from "../api/services/monerium-b2b/accoun const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; -const FALLBACK = "0x3333333333333333333333333333333333333333"; const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; describe("monerium b2b account read surface", () => { @@ -58,7 +59,6 @@ describe("monerium b2b account read surface", () => { contactEmail: "ops@client.example.com", destination: DESTINATION, externalSubjectId: "client-1", - fallbackAddress: FALLBACK, forwarderAddress: FORWARDER, managerProfileId: manager.id, moneriumProfileId: MONERIUM_PROFILE @@ -78,36 +78,41 @@ describe("monerium b2b account read surface", () => { expect(account.body.account).toMatchObject({ accountId: mapped.accountId, destination: DESTINATION, - fallbackAddress: FALLBACK, floorPpm: 1500, forwarderAddress: FORWARDER, iban: null, status: "onboarding" }); + expect(account.body.account).not.toHaveProperty("fallbackAddress"); + const convertedDeposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + currency: "eur", + amountRaw: "100000000000000000000", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Forwarded, + txHash: "0xmint" + }); const execution = await MoneriumConversionExecution.create({ feeRaw: "8000000", referenceRateRaw: "114000000", subsidyRaw: "0", accountId: mapped.accountId, + depositId: convertedDeposit.id, destination: DESTINATION, eureInRaw: "100000000000000000000", status: MoneriumConversionExecutionStatus.Confirmed, txHash: "0xswap", usdcNetRaw: "108000000" }); - const convertedDeposit = await MoneriumFiatDeposit.create({ + await MoneriumConversionExecution.create({ accountId: mapped.accountId, - currency: "eur", - amountRaw: "100000000000000000000", - moneriumOrderId: "order-1", - status: MoneriumFiatDepositStatus.Minted, - txHash: "0xmint" - }); - await MoneriumDepositAllocation.create({ depositId: convertedDeposit.id, + destination: DESTINATION, eureInRaw: "100000000000000000000", - executionId: execution.id, + kind: MoneriumConversionExecutionKind.Forward, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xforward", usdcNetRaw: "108000000" }); await MoneriumFiatDeposit.create({ @@ -130,7 +135,7 @@ describe("monerium b2b account read surface", () => { expect(deposits.status).toBe(200); const rows = deposits.body.deposits as Array>; expect(rows).toHaveLength(2); - expect(rows.map(row => row.status)).toEqual(["pending", "minted"]); + expect(rows.map(row => row.status)).toEqual(["pending", "forwarded"]); expect(rows[1]).toMatchObject({ amountRaw: "100000000000000000000", conversions: [ @@ -143,10 +148,11 @@ describe("monerium b2b account read surface", () => { usdcNetRaw: "108000000" } ], + forwardTxHash: "0xforward", txHash: "0xmint", usdcNetRaw: "108000000" }); - expect(rows[0]).toMatchObject({ conversions: [], usdcNetRaw: "0" }); + expect(rows[0]).toMatchObject({ conversions: [], forwardTxHash: null, usdcNetRaw: "0" }); expect(deposits.body.pagination).toMatchObject({ total: 2 }); }); diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 22a065f27..6c192361a 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -448,7 +448,7 @@ DepositConvertedWebhookPayload: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; } & { conversions: Array<{ @@ -462,6 +462,7 @@ DepositConvertedWebhookPayload: { txHash: null | string; usdcNetRaw: string; }>; + forwardTxHash: null | string; usdcNetRaw: string; }; timestamp: string; @@ -476,13 +477,13 @@ DepositReceivedWebhookPayload: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; }; timestamp: string; } -DepositStatus: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" } +DepositStatus: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" } DepositWebhookPayloadBase: { accountId: string; @@ -490,7 +491,7 @@ DepositWebhookPayloadBase: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; } @@ -2442,7 +2443,7 @@ WebhookDeliveryAttempt: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; } & { conversions: Array<{ @@ -2456,6 +2457,7 @@ WebhookDeliveryAttempt: { txHash: null | string; usdcNetRaw: string; }>; + forwardTxHash: null | string; usdcNetRaw: string; }; timestamp: string; @@ -2468,7 +2470,7 @@ WebhookDeliveryAttempt: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; }; timestamp: string; @@ -2510,7 +2512,7 @@ WebhookPayload: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; } & { conversions: Array<{ @@ -2524,6 +2526,7 @@ WebhookPayload: { txHash: null | string; usdcNetRaw: string; }>; + forwardTxHash: null | string; usdcNetRaw: string; }; timestamp: string; @@ -2536,7 +2539,7 @@ WebhookPayload: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; }; timestamp: string; diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index cadbd8974..4b30ddc26 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -16,10 +16,24 @@ export enum WebhookEventType { export const ACCOUNT_WEBHOOK_EVENT_TYPES = [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED] as const; export enum DepositStatus { + /** Provider order placed, EURe not minted yet. */ PENDING = "pending", + /** EURe minted to the forwarder. */ MINTED = "minted", + /** Provider compliance hold before the mint. */ HELD = "held", - RETURNED = "returned" + /** The provider returned the payment before the mint. Terminal. */ + RETURNED = "returned", + /** Conversion started; chunks accumulate on the forwarder until the whole deposit is converted. */ + CONVERTING = "converting", + /** The whole converted deposit reached the destination in one transfer. Terminal. */ + FORWARDED = "forwarded", + /** The deposit could not be converted inside the promised window; Vortex is refunding the payer. */ + RECOVERING = "recovering", + /** The exact EUR amount was refunded to the payer's bank account. Terminal. */ + REFUNDED = "refunded", + /** The refund needs operator intervention. */ + RECOVERY_FAILED = "recovery_failed" } export enum TransactionStatus { @@ -133,7 +147,9 @@ export interface DepositConvertedWebhookPayload { /** Net USDC from this execution attributed to this deposit (6-decimal base units). */ usdcNetRaw: string; }>; - /** Aggregate net USDC attributed to the complete deposit (6-decimal base units). */ + /** The single transaction that pushed the whole converted deposit to the destination. */ + forwardTxHash: string | null; + /** Aggregate net USDC forwarded for the complete deposit (6-decimal base units). */ usdcNetRaw: string; }; } From 4ae14f60e245e0bcc5da6c5cd9e0a77d49cf91fd Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 22:22:01 +0200 Subject: [PATCH 31/44] docs(repo): document whole-deposit settlement and the refund path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second ADR-0005 amendment (chunks accumulate on the clone, one forward per payment, Vortex-held recovery wallet behind an on-chain 2 h delay, client fallback role removed, SLIPPAGE_BPS 60), architecture flow and data model, security-spec invariants and threat rows, runbook §2.7 refund procedure and triage rows, rollout terms and ledger, webhook page and OpenAPI deposit shape. --- docs/adr-0005-monerium-b2b-onramp.md | 124 +++++++--- docs/api/openapi/vortex.openapi.d.ts | 24 +- docs/api/openapi/vortex.openapi.json | 51 ++-- docs/api/pages/07-webhooks.md | 9 +- docs/architecture-monerium-b2b-onramp.md | 231 ++++++++++-------- docs/operations-monerium-b2b-rollout.md | 86 ++++--- docs/operations-monerium-b2b-runbook.md | 213 +++++++++------- ...al-monerium-b2b-settlement-and-recovery.md | 11 +- .../05-integrations/monerium-b2b.md | 46 ++-- 9 files changed, 488 insertions(+), 307 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 97c74e44b..d40c83545 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -2,7 +2,9 @@ **Status:** Accepted (selected 2026-07-17; parameters finalized and documents consolidated 2026-08-26; amended 2026-09-15 with reference-priced fee bands, the subsidy vault, the -route whitelist and the 7 day sweep — see the amendment section). This ADR is the +route whitelist and the 7 day sweep; amended 2026-09-17 with whole-deposit settlement, the +Vortex-held recovery path and the removal of the client fallback role — see the second +amendment). This ADR is the single source of truth for the *decisions and risk acceptances* of the B2B EUR → USDC onramp. How the system works lives in [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); security @@ -54,29 +56,25 @@ Supporting decisions, all in force: migrating clients clone-by-clone — never by mutating deployed code. The custody argument depends on it. Routes, the fee policy and the vault limits are bounded *data* the guardian may change within immutable validation, not code. -- **Mandatory self-custodied `fallbackAddress`** for every client (Tier C "no fallback" - dropped 2026-07-17 — condition of Monerium's acceptance; Tier B "partner-held - recovery key" rejected 2026-07-14 — the partner declines custody-like powers). All - emergency flows point to it: client `sweep`/config functions plus the permissionless - dead-man sweep. -- **Never send raw EURe to a CEX destination** — EURe recovery targets are the - fallback address only. -- **No on-contract redeem validator, and no payment bouncing** (reaffirmed 2026-09-15). - Redemption = withdraw to fallback, then redeem normally; Monerium's issuer recovery is - the break-glass backstop (see T1 below). Returning a deposit to its sender would be a - redeem order the forwarder must approve via EIP-1271; any such path hands whoever - holds the whitelabel credentials plus the signing key a fiat drain to an arbitrary - IBAN, and the safe variant (a return IBAN pinned per clone) still buys nothing for the - liquidity case it was asked for — swaps stop at the floor and funds wait safely. -- **No Vortex-triggered sweep to the fallback address** (decided 2026-09-15). Only the - client (`sweep`) and the permissionless dead-man sweep move EURe to the fallback; a - guardian shortcut would weaken "Vortex keys cannot move client funds". Instead the - dead-man delay is short (P3, 7 days), and because the sweep ignores pauses that is - also the longest hold Vortex can impose — token-level freezes are Monerium's lever. +- **No client key on the clone** (amended 2026-09-17; superseded the mandatory + self-custodied `fallbackAddress` of 2026-07-17 and its `sweep`/config functions and + dead-man sweep). The only exits are the client's fixed `destination` and, for a payment + the promised window was missed on, the Vortex recovery wallet — see the second + amendment. A destination change means a new clone (runbook §5). +- **Never send raw EURe to a CEX destination** — EURe leaves a clone only to the router + or the Vortex recovery wallet. +- **No on-contract redeem validator.** The forwarder's EIP-1271 still validates only the + link message. Returning a deposit to its sender happens off the clone: the keeper moves + the payment to the Vortex recovery wallet and Vortex redeems from there (amendment + 2026-09-17), so the whitelabel credentials plus the attestor key still cannot drain a + clone to an arbitrary IBAN. Monerium's issuer recovery stays the break-glass backstop + (see T1 below). - **EIP-191 hash only, chainid-bound** (the raw-keccak variant was removed after the G0 sandbox validation; chainid binding closes cross-chain replay — review r1). -- **Three distinct Vortex keys** (attestor / keeper / guardian), none able to move or - redirect funds; the keeper runs on exactly one backend (the mykobo flow variant). +- **Three distinct Vortex keys** (attestor / keeper / guardian), none able to redirect + funds; the keeper can move a payment only to the immutable recovery wallet and only + once the clone's batch has been open for `RECOVERY_DELAY` (amendment 2026-09-17); the + keeper runs on exactly one backend (the mykobo flow variant). - **Managed-profile integration:** each client is a managed child profile under the partner manager (KYB mirror, credentials, read API, webhook tenancy). The flow is quoteless and deliberately **not** part of `ramp_states` — evaluated and rejected @@ -102,7 +100,7 @@ not yet deployed, so this replaced the flat fee before launch with no migration) price × volume), widened to an hour when the five minutes carry no volume, so a single thin print on a weekend or outside business hours never becomes the reference (suggested in review, 2026-09-15). It records price, window and time on the - execution row and passes the rate into `swapAndForward`. The contract rejects a reference outside + execution row and passes the rate into `swap`. The contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink; on the permissionless path the argument is ignored and Chainlink is the reference. Reading the price from Vortex's own oracle on Base was rejected: it blends a forex rate on weekdays and lives on another chain. @@ -119,7 +117,7 @@ not yet deployed, so this replaced the flat fee before launch with no migration) its destination — a swap is never partially subsidized and a guardian-set vault cannot harm the client. The vault holds Vortex money only, so its limits bound Vortex's exposure, never the client's. -- **Floor on the net.** `SLIPPAGE_BPS` (now 40 bps) is enforced on fill − fee + subsidy, +- **Floor on the net.** `SLIPPAGE_BPS` (60 bps since the 2026-09-17 amendment; 40 at this amendment) is enforced on fill − fee + subsidy, not on the raw fill; the router minimum is zero and the forwarder's post-condition is the guard, so a subsidy can never paper over a depegged reference. - **Route whitelist.** The factory holds guardian-managed routes, validated on chain to @@ -140,6 +138,56 @@ not yet deployed, so this replaced the flat fee before launch with no migration) floor to floor plus the per-swap cap, paid by the vault; private orderflow and a modest cap are the mitigation, and the permissionless path keeps the plain floor. +## Amendment 2026-09-17: whole-deposit settlement and the refund path + +Product requirements from the partner (SulPayments): one USDC transfer per bank +payment, and an automatic refund of the exact EUR amount to the payer's bank account +when a payment cannot be converted inside the promised window. Vortex holding the funds +for that refund is agreed commercially. Decisions (the proposal that led here is +[`proposal-monerium-b2b-settlement-and-recovery.md`](proposal-monerium-b2b-settlement-and-recovery.md)): + +- **Chunks accumulate on the clone; one forward per payment.** `swap(reference, route, + amountIn)` converts an explicit chunk and keeps the USDC (subsidy included) on the + clone; `forward(amount)` pushes the whole converted payment to `destination`. The + keeper serves one deposit at a time (1 deposit : N swap executions), so deposits never + share a swap and the N:M attribution of 2026-08 is gone. Approach A of the proposal + (no escrow contract): smallest audit delta, per-client blast radius, USDC never + leaves the client's clone until it goes to the destination. +- **Vortex-held recovery wallet, on-chain delay.** `recover(eure, usdc)` is keeper-only, + pays only the immutable `RECOVERY_WALLET` — one wallet linked to a Vortex/SatoshiPay + company profile at Monerium — and only once the clone's batch marker has been open for + `RECOVERY_DELAY` = **2 hours** (immutable, P3). The clock starts when funds first + arrive on the clone; a chunk swap never re-times it; a forward or recovery re-times + whatever remains. The refund then leaves the Monerium profile that wallet belongs to: + the recovered USDC is swapped back to EURe, a separate float wallet covers the + slippage residue (the loss ledger), and a redeem order returns the exact issue amount + to the payer's IBAN (payer IBAN and name come from the issue order). Manual per the + runbook until automated. +- **Client fallback role removed.** `fallbackAddress`, `sweep`, `setDestination`, + `setClientPaused` and the dead-man sweep are gone (the client never held a key in + the pilot; the partner warrants the destination, B5). A destination change means a new + clone (runbook §5). The permissionless `swap`/`forwardAll` path after `TRIGGER_DELAY` + stays as the liveness guarantee, so a Vortex outage never traps converted funds. +- **Trust statement (replaces "Vortex keys cannot move client funds").** Vortex keys can + move a client's funds only to the Vortex recovery wallet, only after `RECOVERY_DELAY`, + and the contract can never send anywhere else. Consequences carried to G1 (Monerium + re-approval of the Vortex-held fallback and of one company profile refunding many + client corporates), G2 (custody scoping) and the partner terms (rollout §Terms 6). +- **Refund triggers.** Missed window (automated in a later phase; operator-triggered via + the admin endpoint until then), operator intervention, and remainders below + `minSwapAmount` (they cannot be swapped and are refunded). Chunk fees already taken on + a refunded payment stay in the treasury and are netted in the ledger. +- **`SLIPPAGE_BPS` 40 → 60.** With a 2 h promise a weekend Chainlink gap that defers a + swap turns into a refund, so the operating tolerance to a stale round (`SLIPPAGE_BPS − + floorPpm`) moves from ~25 to ~45 bps; the twelve-month replay of the Coinbase + EURC-USDC market against Chainlink shows ~80 h/year of floor-cause deferral at 40 bps + over ten weekends and two five-minute blips at 60. The keeper's worst-case pricing + power on the client widens by 20 bps in exchange. A genuine depeg beyond the 100 bps + band still defers and, past the window, refunds. +- **Dormancy.** A dormant or suspended account still recovers its marked deposits (the + refund path is for payments nobody converts), so a deposit into a dormant account is + refunded rather than parked. + ## Final parameters (decided 2026-08-26 unless noted) | ID | Parameter | Value | @@ -150,9 +198,9 @@ not yet deployed, so this replaced the flat fee before launch with no migration) | B4 | Pilot volume limits | **€50k/client/day, paper/contractual only** (no backend enforcement in the pilot; GA revisit) | | B5 | Partner liability | Tier A defaults: partner warrants destination correctness; rotation loss borne by the client; dormancy re-activation on written partner confirmation | | B6 | Redemption-limitation disclosure | Mandatory in client terms (committed to Monerium); draft in the rollout doc | -| P1 | `SLIPPAGE_BPS` | **40 bps on the client's net after fee and subsidy** (amended 2026-09-15; was 100 on the raw fill) | +| P1 | `SLIPPAGE_BPS` | **60 bps on the client's net after fee and subsidy** (amended 2026-09-17; 40 from 2026-09-15, 100 on the raw fill before) | | P2 | `MAX_FEE_PPM` | 10000 ppm (1%), immutable; caps both the fee and the floor policy (amended 2026-09-15; was `MAX_FEE_BPS` 100) | -| P3 | Dead-man sweep delay | **7 days** (amended 2026-09-15; was 60) — also the longest hold Vortex can impose | +| P3 | `RECOVERY_DELAY` | **2 hours** (amended 2026-09-17): the promised conversion window, enforced on chain as the earliest a payment may move to the recovery wallet. Replaces the dead-man sweep delay (7 days on 2026-09-15, 60 before), which had no target left once the fallback role was removed | | P4 | Permissionless trigger delay | 24 h | | P5 | Dormancy window | 60 days | | P6 | `minSwapAmount` | floor €25 (immutable) / operational **€250** | @@ -198,19 +246,28 @@ example (oversized-deposit allocation). - **CEX destination rotation.** Not verifiable on-chain; carried contractually (B5) with penny test, dormancy gate, and minimum-forward diligence. Silent-loss risk converts to a pause via the dormancy gate. -- **Fallback-key loss + broken destination** — ordinary self-custody residual, borne - by the client (terms; do not overpromise exits — R11). +- **Vortex custody on the refund path** (amendment 2026-09-17). A recovered payment + sits in Vortex's own wallet until the bank refund goes out; a compromised keeper plus + recovery key could divert a payment the window was missed on. Bounded by the immutable + wallet, the on-chain delay, explicit amounts, a dedicated linked address holding + nothing else, and the association monitor; accepted commercially by the partner and + carried to G1/G2. +- **Broken destination** — with no client key on the clone, a wrong destination is + caught by the penny test and the dormancy gate; a rotation loss is borne by the + client/partner (B5); a destination change is a new clone. - **Non-custody ≠ out of MiCA scope.** The constrained-attestor construction defeats the custody definition, but exchange/transfer-service scoping is a separate G2 question. Never present "no custody" as "no licence needed". - **Stuck-state table** (route death, feed retirement, depeg beyond bound, blacklisted destination, reference feed outage, exhausted subsidy budget): all fail-safe — swaps - revert or the keeper defers, funds accumulate as EURe, client exits keep working; - recovery is client-side sweep plus the issuer backstop. Accepted. + revert or the keeper defers, funds accumulate as EURe; past the promised window the + payment is refunded through the recovery wallet, past 24 h anyone may convert and + forward permissionlessly; the issuer backstop remains. Accepted. - **Bounded keeper pricing power.** A compromised keeper can pick any whitelisted route and any reference inside the Chainlink band: worst case the fee reaches `MAX_FEE_PPM` or the vault pays up to its caps. Bounded by the band, the fee cap, the vault limits - and the floor on the net; it can still never redirect funds. Accepted. + and the floor on the net; it can still never redirect funds — only, after the on-chain + delay, move them to the recovery wallet. Accepted. - **Subsidy exposure.** Up to the per-swap cap per swap and the daily budget per day, plus the widened sandwich band (amendment). Accepted; both limits are live-tunable. - **Operational residuals:** reorgs deeper than the watcher's 12-block lag; @@ -221,8 +278,9 @@ example (oversized-deposit allocation). ## Consequences Zero-touch onboarding works end to end (validated against the Monerium sandbox: link -accepted, IBAN issued, no client interaction). Clients keep unilateral exits that no -Vortex failure can block. The cost: every rescue path must be designed in upfront +accepted, IBAN issued, no client interaction). A Vortex outage can never trap converted +funds (the permissionless path), and a payment the promised window was missed on is +refunded rather than parked, at the price of Vortex custody on that path. The cost: every rescue path must be designed in upfront (no universal owner key), fee-policy increases are timelocked and venue changes are bounded by on-chain route validation rather than admin switches, the partner's rate guarantee is enforced by the contract at the cost of a treasury-funded subsidy budget, diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 577628afb..51842dd43 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2864,8 +2864,6 @@ export interface components { * @description Set while the account is dormancy-paused. */ dormantSince: string | null; - /** @description The client's self-custodied recovery address. */ - fallbackAddress: string; /** @description Fee policy floor in parts per million below the reference rate: the least the client receives on a keeper-executed swap. */ floorPpm: number; /** @description The account's on-chain forwarding contract. */ @@ -2883,17 +2881,17 @@ export interface components { MoneriumB2bDeposit: { /** @description Deposit amount in 18-decimal base units of the deposit currency. */ amountRaw: string; - /** @description Conversion portions allocated to this deposit, oldest first. Empty while the deposit awaits conversion; multiple entries are returned when a per-swap cap splits the deposit. */ + /** @description The chunk swaps of this deposit, oldest first. Empty while the deposit awaits conversion; a deposit larger than the per-swap cap is converted in several chunks that accumulate on the forwarding contract until one transfer delivers them all. Chunks are never shared between deposits. */ conversions: { - /** @description EURe from this deposit consumed by the execution in 18-decimal base units. */ + /** @description EURe of this deposit consumed by the chunk in 18-decimal base units. */ eureInRaw: string; - /** @description Execution-level pricing, identical on every deposit portion the execution consumed: the reference rate it was settled against, the fee taken above the target band, and the subsidy paid to reach the floor. Null values while the execution is not yet confirmed. */ + /** @description The chunk's pricing: the reference rate it was settled against, the fee taken above the target band, and the subsidy paid to reach the floor. Null values while the chunk is not yet confirmed. */ execution: { - /** @description Fee taken on the whole execution in 6-decimal base units. */ + /** @description Fee taken on the chunk in 6-decimal base units. */ feeRaw: string | null; /** @description Reference EUR/USD rate the execution was priced against: a volume-weighted average of the Coinbase Exchange EURC-USDC market over the five minutes before the swap (widened to sixty minutes when those five carry no trades), in the oracle's decimals (8). */ referenceRateRaw: string | null; - /** @description Subsidy paid by the vault straight to the destination for the whole execution in 6-decimal base units. */ + /** @description Subsidy paid by the vault onto the forwarding contract for the chunk, delivered with the deposit's transfer, in 6-decimal base units. */ subsidyRaw: string | null; }; executionId: string; @@ -2902,23 +2900,25 @@ export interface components { * @enum {string} */ status: "pending" | "confirmed" | "failed"; - /** @description The swap-and-forward transaction hash. */ + /** @description The chunk swap transaction hash. */ txHash: string | null; - /** @description Net USDC from this execution attributed to this deposit in 6-decimal base units. */ + /** @description Net USDC of the chunk (fill minus fee plus subsidy) in 6-decimal base units. */ usdcNetRaw: string; }[]; /** Format: date-time */ createdAt: string; currency: string; depositId: string; + /** @description The single transaction that delivered the whole converted deposit to the destination; null until the deposit is forwarded. */ + forwardTxHash: string | null; /** - * @description Deposit status (forward-only). + * @description Deposit status (forward-only): the provider states, then `converting` and `forwarded`, or - when the deposit could not be converted within the promised window - `recovering`, `refunded` and `recovery_failed`. * @enum {string} */ - status: "pending" | "minted" | "held" | "returned"; + status: "pending" | "minted" | "held" | "returned" | "converting" | "forwarded" | "recovering" | "refunded" | "recovery_failed"; /** @description The on-chain mint transaction, when observed. */ txHash: string | null; - /** @description Aggregate net USDC attributed to this deposit so far in 6-decimal base units. */ + /** @description Sum of the confirmed chunks' net USDC in 6-decimal base units: what the deposit's single transfer delivers once forwarded. */ usdcNetRaw: string; }; MoneriumB2bDepositsResponse: { diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 354577384..df7ecf0c6 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2517,10 +2517,6 @@ "format": "date-time", "type": ["string", "null"] }, - "fallbackAddress": { - "description": "The client's self-custodied recovery address.", - "type": "string" - }, "floorPpm": { "description": "Fee policy floor in parts per million below the reference rate: the least the client receives on a keeper-executed swap.", "type": "integer" @@ -2547,7 +2543,6 @@ "createdAt", "destination", "dormantSince", - "fallbackAddress", "floorPpm", "forwarderAddress", "iban", @@ -2572,18 +2567,18 @@ "type": "string" }, "conversions": { - "description": "Conversion portions allocated to this deposit, oldest first. Empty while the deposit awaits conversion; multiple entries are returned when a per-swap cap splits the deposit.", + "description": "The chunk swaps of this deposit, oldest first. Empty while the deposit awaits conversion; a deposit larger than the per-swap cap is converted in several chunks that accumulate on the forwarding contract until one transfer delivers them all. Chunks are never shared between deposits.", "items": { "properties": { "eureInRaw": { - "description": "EURe from this deposit consumed by the execution in 18-decimal base units.", + "description": "EURe of this deposit consumed by the chunk in 18-decimal base units.", "type": "string" }, "execution": { - "description": "Execution-level pricing, identical on every deposit portion the execution consumed: the reference rate it was settled against, the fee taken above the target band, and the subsidy paid to reach the floor. Null values while the execution is not yet confirmed.", + "description": "The chunk's pricing: the reference rate it was settled against, the fee taken above the target band, and the subsidy paid to reach the floor. Null values while the chunk is not yet confirmed.", "properties": { "feeRaw": { - "description": "Fee taken on the whole execution in 6-decimal base units.", + "description": "Fee taken on the chunk in 6-decimal base units.", "type": ["string", "null"] }, "referenceRateRaw": { @@ -2591,7 +2586,7 @@ "type": ["string", "null"] }, "subsidyRaw": { - "description": "Subsidy paid by the vault straight to the destination for the whole execution in 6-decimal base units.", + "description": "Subsidy paid by the vault onto the forwarding contract for the chunk, delivered with the deposit's transfer, in 6-decimal base units.", "type": ["string", "null"] } }, @@ -2607,11 +2602,11 @@ "type": "string" }, "txHash": { - "description": "The swap-and-forward transaction hash.", + "description": "The chunk swap transaction hash.", "type": ["string", "null"] }, "usdcNetRaw": { - "description": "Net USDC from this execution attributed to this deposit in 6-decimal base units.", + "description": "Net USDC of the chunk (fill minus fee plus subsidy) in 6-decimal base units.", "type": "string" } }, @@ -2630,9 +2625,23 @@ "depositId": { "type": "string" }, + "forwardTxHash": { + "description": "The single transaction that delivered the whole converted deposit to the destination; null until the deposit is forwarded.", + "type": ["string", "null"] + }, "status": { - "description": "Deposit status (forward-only).", - "enum": ["pending", "minted", "held", "returned"], + "description": "Deposit status (forward-only): the provider states, then `converting` and `forwarded`, or - when the deposit could not be converted within the promised window - `recovering`, `refunded` and `recovery_failed`.", + "enum": [ + "pending", + "minted", + "held", + "returned", + "converting", + "forwarded", + "recovering", + "refunded", + "recovery_failed" + ], "type": "string" }, "txHash": { @@ -2640,11 +2649,21 @@ "type": ["string", "null"] }, "usdcNetRaw": { - "description": "Aggregate net USDC attributed to this deposit so far in 6-decimal base units.", + "description": "Sum of the confirmed chunks' net USDC in 6-decimal base units: what the deposit's single transfer delivers once forwarded.", "type": "string" } }, - "required": ["amountRaw", "conversions", "createdAt", "currency", "depositId", "status", "txHash", "usdcNetRaw"], + "required": [ + "amountRaw", + "conversions", + "createdAt", + "currency", + "depositId", + "forwardTxHash", + "status", + "txHash", + "usdcNetRaw" + ], "type": "object" }, "MoneriumB2bDepositsResponse": { diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 28db4d317..c44b2e862 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -142,7 +142,7 @@ Fired once when a client's EUR deposit has been matched to the corresponding on- ### `DEPOSIT_CONVERTED` -Fired once per deposit after the full deposit has been converted and every contributing execution has reached a safe confirmation depth on chain. A deposit split by the per-swap cap still produces one final aggregate event. +Fired once per deposit after the whole deposit has been converted and forwarded to the destination in a single transfer, and that transfer has reached a safe confirmation depth on chain. A deposit larger than the per-swap cap is converted in several chunks that accumulate on the forwarding contract; the destination still receives one transfer and you receive one event. ```json { @@ -155,7 +155,7 @@ Fired once per deposit after the full deposit has been converted and every contr "depositId": "9f6f6a7e-...", "amountRaw": "100000000000000000000", "currency": "eur", - "status": "minted", + "status": "forwarded", "txHash": "0x...", "conversions": [ { @@ -173,12 +173,15 @@ Fired once per deposit after the full deposit has been converted and every contr "usdcNetRaw": "43200000" } ], + "forwardTxHash": "0x...", "usdcNetRaw": "108000000" } } ``` -Each `conversions[]` entry contains the EURe portion consumed and the net USDC attributed to this deposit by that execution. The payload-level `usdcNetRaw` is their aggregate. When one execution consumes several deposits, its output is divided proportionally by allocated EURe; floor dust goes to the largest allocation. +Each `conversions[]` entry is one chunk swap of this deposit: the EURe it consumed and its net USDC. Chunks are never shared between deposits. `forwardTxHash` is the transaction that pushed the whole converted deposit to the destination, and the payload-level `usdcNetRaw` is the amount that single transfer carried (the sum of the chunks' nets). + +Deposit `status` values: `pending`, `minted`, `held`, `returned` (provider states), then `converting`, `forwarded`, or — when a payment cannot be converted within the promised window — `recovering`, `refunded`, `recovery_failed`. `DEPOSIT_RECEIVED` may already report `converting` when conversion started within the same minute. The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against, a volume-weighted average of the Coinbase Exchange EURC-USDC market over the five minutes before the swap, widened to sixty minutes when those five carry no trades (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 25cedc670..0ea8d344b 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -16,13 +16,17 @@ deploys one `VortexForwarder` contract clone per client, links it to that profil attestor signature, and requests an IBAN **for the linked contract address** — the IBAN's default mint destination *is* the forwarder. From then on the flow is passive on Monerium's side: EUR received on the IBAN mints EURe to the forwarder, and Vortex's -keeper calls `swapAndForward(reference, route)` on the contract, which swaps EURe to -USDC over a whitelisted Uniswap v3 route, settles the fill against the partner reference -rate (surplus above the target is the fee, shortfall below the floor is topped up from -the subsidy vault, Chainlink bounds the net), and transfers the USDC to the client's -fixed destination wallet. The flow is -deliberately **not** a ramp: no quote, no `ramp_states` — the account is permanent and -repeatedly funded. Inside Vortex the client is a **managed child profile** under the +keeper converts each bank payment in `swap(reference, route, amountIn)` chunks on the +contract — each swaps EURe to USDC over a whitelisted Uniswap v3 route and settles the +fill against the partner reference rate (surplus above the target is the fee, shortfall +below the floor is topped up from the subsidy vault, Chainlink bounds the net) — the USDC +accumulates on the forwarder, and one `forward(amount)` pushes the whole converted +payment to the client's fixed destination wallet, so the client sees one transfer per +pay-in. A payment that cannot be converted inside the promised window is moved to the +Vortex recovery wallet (`recover`, keeper-only, contract-gated by `RECOVERY_DELAY`) and +refunded to the payer's bank account — see "Chunking, forwarding and the refund path". +The flow is deliberately **not** a ramp: no quote, no `ramp_states` — the account is +permanent and repeatedly funded. Inside Vortex the client is a **managed child profile** under the partner manager, which is what carries KYB records, API credentials, the read API, and webhook tenancy. @@ -54,6 +58,7 @@ flowchart LR VAULT["VortexSubsidyVault\n(shared, treasury-funded)"] DEST[Client wallet] TREAS[Treasury FEE_RECIPIENT] + RECOV["Vortex recovery wallet\n(refund to the payer's IBAN)"] end subgraph Reference["Reference rate"] @@ -78,13 +83,16 @@ flowchart LR MW -- "EURe Transfer logs" --> FWD CB -- "price before each swap" --> CE CE -- "quotes every route" --> UNI - CE -- "swapAndForward(reference, route)" --> FWD + CE -- "swap(reference, route, chunk) x N" --> FWD + CE -- "forward(whole payment)" --> FWD + CE -- "recover(eure, usdc) after RECOVERY_DELAY" --> FWD FWD --> UNI FWD -- "band + floor on the net" --> LINK - FWD -- "USDC - fee" --> DEST + FWD -- "one USDC transfer per payment" --> DEST FWD -- fee --> TREAS FWD -- "pay(shortfall)" --> VAULT - VAULT -- subsidy --> DEST + VAULT -- "subsidy (stays on the clone)" --> FWD + FWD -- "stuck payment" --> RECOV ONB -- "link address + request IBAN" --> MAPI MONI -- "association / config reads" --> MAPI OUTBOX -- "DEPOSIT_RECEIVED / DEPOSIT_CONVERTED" --> PAPI @@ -93,8 +101,10 @@ flowchart LR Trust boundaries worth holding onto: **Monerium controls where EURe mints** (the IBAN's linked default address — which is why the association monitor exists); **the contract -controls where funds can go** (fixed `destination`, fee to the immutable treasury, -fallback sweep — the keeper can only ever trigger, never redirect); **Vortex controls +controls where funds can go** (fixed `destination`, fee to the immutable treasury, and +the immutable Vortex recovery wallet, reachable only by the keeper and only once a batch +has been open for `RECOVERY_DELAY` — the keeper can trigger and, for a stuck payment, +recover, never redirect); **Vortex controls timing, route choice and the reference within on-chain bounds** (a validated route set, a Chainlink band, a fee cap, vault caps and a floor on the client's net), which can move the price inside those bounds but never where funds go; and **accounting**. @@ -110,7 +120,7 @@ sequenceDiagram participant C as Ethereum Note over M: Monerium onboards the corporate under partner reliance - profile "approved" - Op->>C: deployForwarder(destination, fallback, targetPpm, floorPpm) via factory + Op->>C: deployForwarder(destination, targetPpm, floorPpm) via factory Op->>Adm: POST /v1/admin/monerium-b2b/accounts Adm->>C: verify clone against configured trusted factory + config read-back Adm->>Adm: atomically commit managed child + KYB mirror + account @@ -126,9 +136,9 @@ Steps in prose: 1. **Monerium onboards the corporate** under the partner's reliance attestation; the profile arrives `approved`. (Vortex's KYB submission API is a deliberate 501 stub — registry T3.) -2. **Operator deploys the forwarder clone** with the client's `destination`, mandatory - self-custodied `fallbackAddress`, and the initial fee policy (`targetPpm`, - `floorPpm`); manifest generated and verified. +2. **Operator deploys the forwarder clone** with the client's `destination` (no setter: + a wallet change means a new clone, runbook §5) and the initial fee policy + (`targetPpm`, `floorPpm`); manifest generated and verified. 3. **Admin mapping** — one idempotent call provisions the managed child, mirrors the approved KYB into `provider_customers` + `kyc_cases`, verifies the clone against the configured trusted factory on chain, and creates the account row bound via @@ -155,18 +165,20 @@ sequenceDiagram M-->>V: order.created / order.updated webhook -> inbox -> deposit row V->>F: (watcher) sees the Transfer log -> stamps chain identity V->>V: DEPOSIT_RECEIVED -> outbox -> partner webhook - V->>CB: last hour of 1-min candles -> 5-min VWAP (reference, recorded on the execution row) - V->>V: quote every whitelisted route, project fee/subsidy, defer if the vault cannot cover - V->>F: swapAndForward(reference, bestRoute) [execution row committed first] - F->>F: swap min(balance, perSwapCap) on the route; fee above target, floor on the net - F->>S: pay(shortfall) when the fill is below the floor - S->>P: subsidy USDC to client wallet - F->>P: USDC - fee to client wallet (fee to treasury) - V->>V: finalize from SwapExecuted event (fee, subsidy, reference, route) - Note over V: mint cursor reaches the swap block - V->>V: R04 attribution through the exact swap log position + loop one chunk per keeper cycle (at most perSwapCap) until the deposit is converted + V->>CB: last hour of 1-min candles -> 5-min VWAP (reference, recorded on the execution row) + V->>V: quote every whitelisted route, project fee/subsidy, defer if the vault cannot cover + V->>F: swap(reference, bestRoute, chunk) [execution row bound to the deposit, committed first] + F->>F: swap the chunk on the route; fee above target (to treasury), floor on the net; USDC stays here + F->>S: pay(shortfall) when the fill is below the floor + S->>F: subsidy USDC onto the clone + V->>V: finalize from SwapExecuted (fee, subsidy, reference, route) + end + V->>F: forward(sum of the chunks' net) [execution row committed first] + F->>P: the whole payment's USDC to the client wallet in one transfer + V->>V: finalize from Forwarded (amount must equal the plan) -> deposit forwarded Note over V: 32 blocks later - V->>P: DEPOSIT_CONVERTED -> outbox -> partner webhook + V->>P: DEPOSIT_CONVERTED (chunks + forward tx) -> outbox -> partner webhook ``` Vortex learns about a deposit through two complementary channels, which converge on the @@ -223,8 +235,16 @@ stateDiagram-v2 pending --> returned held --> minted held --> returned - minted --> [*] + minted --> converting : first chunk sent + minted --> recovering + converting --> forwarded : forward confirmed + converting --> recovering + recovering --> refunded + recovering --> recovery_failed + recovery_failed --> recovering : operator retry + forwarded --> [*] returned --> [*] + refunded --> [*] } ``` @@ -232,8 +252,8 @@ stateDiagram-v2 stateDiagram-v2 direction LR state "Execution (monerium_conversion_executions)" as exe { - [*] --> pending2 : row committed BEFORE broadcast - pending2 --> confirmed : receipt + SwapExecuted + [*] --> pending2 : row committed BEFORE broadcast (kind swap / forward / recover, bound to its deposit) + pending2 --> confirmed : receipt + the kind's event (amounts must match the plan) pending2 --> failed : revert / never sent / stale confirmed --> [*] failed --> [*] : retried via a NEW row (backoff) @@ -254,48 +274,58 @@ stateDiagram-v2 ``` Deposit statuses are **forward-only** (a delayed or replayed webhook can never regress a -row). Account statuses follow only the arrows above; `closed` is terminal and a repeated +row): the provider states first, then the keeper's settlement branch or the refund branch +(`recovering` is entered by the operator through the admin endpoint until the missed +window triggers it automatically; `refunded` and `recovery_failed` are set by whoever +completes the bank refund). Account statuses follow only the arrows above; `closed` is terminal and a repeated write of the current status is idempotent. A nonce-less execution row is a five-minute pre-send reservation; expiry uses a compare-and-set so its original owner can no longer broadcast. Once the swap nonce is persisted, time alone never fails the execution. Recovery scans bounded 2,000-block pages from the pre-broadcast block and adopts only -one transaction matching the keeper sender, nonce, forwarder target, the exact -`swapAndForward(reference, route)` calldata rebuilt from the reference and route -persisted before broadcast, and emitted event; incomplete or ambiguous evidence stays -pending for manual reconciliation. The account additionally carries a `dormant_since` +one transaction matching the keeper sender, nonce, forwarder target, the exact calldata +of its kind — `swap(reference, route, amountIn)`, `forward(amount)` or +`recover(eure, usdc)` — rebuilt from what was persisted before broadcast, and the kind's +emitted event; incomplete or ambiguous evidence stays pending for manual reconciliation. The account additionally carries a `dormant_since` marker (guardian-paused after 60 days without a conversion; conversion stops, the protective stranding marker still arms). -## Batching and large deposits - -Batching happens in both directions, automatically: - -- **A large deposit is chunked.** One `swapAndForward()` call converts at most - `perSwapCap`; the remainder stays on the forwarder and the keeper converts it on - subsequent cycles (one execution row per chunk) until the balance is below - `minSwapAmount`. A €120k deposit at a €25k cap becomes five executions a few minutes - apart. The cap is an availability/price-impact parameter, not a safety bound — the - oracle `minOut` is the safety bound. -- **Several small deposits merge.** The contract swaps the balance, not a deposit: two - €5k deposits sitting on the forwarder convert in a single execution, and R04 - attribution splits the USDC back across both deposit rows pro-rata. A deposit that - only partially fits under the cap is split into an allocation for this execution and - an outstanding remainder for the next. Partners receive one `DEPOSIT_CONVERTED` - event only after the whole deposit is allocated and every contributing execution is - deep enough; its `conversions[]` lists each portion and `usdcNetRaw` is the aggregate - of each swap's `usdcOut - fee`. It excludes unsolicited USDC that the contract sweeps - to the same destination alongside a swap. - -Allocation is intentionally deferred after the swap receipt. The mint watcher must -first advance through the execution block; the reconciler then includes deposits from -earlier blocks and only deposits whose `Transfer` log precedes `SwapExecuted` in the -same block. This exact boundary also captures a mint that lands between the executor's -balance read and its swap transaction without attributing a later mint to that swap. - -In normal operation merging is rare: the keeper runs every minute, so deposits share an -execution only when they arrive within about a minute of each other or during downtime. -And batching only ever merges deposits of the **same client** — every client has their -own forwarder, so cross-client funds never mix. +## 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: + +- **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 + remainder when the last two chunks can share it (`planChunk`). A €120k deposit at a + €25k cap becomes five swap executions a few minutes apart, each bound to the deposit; + their USDC (fee already skimmed, subsidy already added) waits on the forwarder. Once + the chunks' EURe sum to the deposit, one `forward(amount)` execution pushes the sum of + their nets to the destination, and the deposit is `forwarded`. The cap is an + availability/price-impact parameter, not a safety bound — the oracle floor is. +- **Deposits never share a swap.** Two deposits sitting on the forwarder convert one + after the other; the second waits for the first's forward only if they compete for + the same cycle. There is no pro-rata attribution any more: an execution belongs to + exactly one deposit by construction. The partner receives one `DEPOSIT_CONVERTED` + 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.** 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 + immutable `RECOVERY_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 + 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 the refund is manual for + now (runbook §2.7): the USDC is swapped back to EURe, a float wallet covers the + slippage residue, and a Monerium redeem order from the recovery wallet's company + profile returns the exact issue amount to the payer's IBAN; the operator then marks the + deposit `refunded`. +- **Liveness without Vortex.** Past `TRIGGER_DELAY` (24 h) anyone may `swap` (Chainlink + reference, no subsidy) and `forwardAll` the clone's USDC to the destination; payments + may merge on that path, and the keeper reconciles what it did not send by hand. ## Fees, reference rate and subsidy @@ -309,7 +339,7 @@ settles every fill into three bands against that reference (decisions: price `(low + high + close) / 3` weighted by volume; widened to an hour when the five minutes carry no volume, so a single thin weekend print never becomes the reference), stores price, window and time on the execution row, and passes the rate into - `swapAndForward`. The contract rejects a reference outside + `swap`. The contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink EUR/USD; a permissionless caller's value is ignored and Chainlink is the reference. No reference means the keeper defers. - **Fee policy (`targetPpm`, `floorPpm`)**: per clone, in ppm below the reference, @@ -322,19 +352,21 @@ settles every fill into three bands against that reference (decisions: lowering is immediate (registry P11). Swaps always use the currently applied policy. - **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 - destination the clone passes (its own immutable one), within a guardian-settable + clone itself (the subsidy is forwarded with the payment), within a guardian-settable per-swap cap (ppm of the swap's reference value) and a UTC-daily budget; it can be paused and withdraws only to the treasury. A vault that cannot cover the shortfall - reverts the whole swap, and the clone reverts unless exactly the shortfall arrived at - its destination — a swap is never partially subsidized, and the guardian cannot harm - a swap by pointing the factory at a bad vault. The vault holds Vortex money only. + reverts the whole swap, and the clone reverts unless exactly the shortfall arrived on + it — a swap is never partially subsidized, and the guardian cannot harm a swap by + pointing the factory at a bad vault. The vault holds Vortex money only. - **Floor on the net**: `SLIPPAGE_BPS` bounds fill − fee + subsidy against Chainlink, not the raw fill. The router minimum is zero and the forwarder's post-condition is the guard, so a subsidy can never paper over a depegged reference and the whole call, subsidy transfer included, reverts when the floor fails. Because the client's floor is - 15 bps under the reference and the oracle floor 40 bps under Chainlink, a reference - more than ~25 bps below Chainlink fails the floor for every normal fill: that margin, - not the 100 bps band, is the operating tolerance against a stale Chainlink round. + 15 bps under the reference and the oracle floor 60 bps under Chainlink, a reference + more than ~45 bps below Chainlink fails the floor for every normal fill: that margin, + not the 100 bps band, is the operating tolerance against a stale Chainlink round — + sized so ordinary weekend drift defers (and, under the 2 h window, refunds) about + nothing, while a genuine depeg still does. - **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 @@ -372,17 +404,21 @@ read-only — no keys, no transactions: 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. -3. **Stranded-balance monitor.** Forwarders holding EURe with the stranding marker - armed too long — a keeper-outage or deferral signal (past the trigger delay, the - permissionless fallback is live; within two days of the 7 day sweep the alert says - the dead-man sweep to the fallback is imminent; funds are never at risk). -4. **Config reconciliation.** Re-reads per-clone config and bytecode: client-authorized - changes (destination/fallback) and guardian-authorized changes (fee policy, - timelocked) are reconciled into the DB with a version bump; bytecode or registration - drift is a should-be-impossible 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). +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 + should-be-impossible incident. 5. **Subsidy-vault monitor.** Balance, daily budget, spend and pause state of the shared vault: paused or empty is an error (every below-floor swap defers), less than a day of budget or an exhausted day is a refill warning. +6. **Reference-venue monitor.** Probes the Coinbase product the reference VWAP reads: + a delisted or halted product keeps answering the candles endpoint with stale data + and would make every keeper swap defer silently, so its status is an error line + rather than an assumption. ## Data model — the Monerium B2B tables @@ -395,8 +431,7 @@ erDiagram profiles ||--o| monerium_accounts : "vortex_profile_id (managed child)" monerium_accounts ||--o{ monerium_fiat_deposits : "account_id" monerium_accounts ||--o{ monerium_conversion_executions : "account_id" - monerium_fiat_deposits ||--o{ monerium_deposit_allocations : "deposit_id" - monerium_conversion_executions ||--o{ monerium_deposit_allocations : "execution_id (R04)" + monerium_fiat_deposits ||--o{ monerium_conversion_executions : "deposit_id (1 deposit : N executions)" webhooks ||--o{ webhook_deliveries : "webhook_id (deposit events)" monerium_accounts { @@ -405,7 +440,6 @@ erDiagram string iban string forwarder_address UK string destination - string fallback_address int target_ppm int floor_ppm enum status @@ -418,6 +452,8 @@ erDiagram int log_index } monerium_conversion_executions { + enum kind + uuid deposit_id FK decimal eure_in_raw decimal usdc_net_raw decimal subsidy_raw @@ -429,20 +465,13 @@ erDiagram int swap_log_index enum status } - monerium_deposit_allocations { - uuid deposit_id FK - uuid execution_id FK - decimal eure_in_raw - decimal usdc_net_raw - } ``` | Table | Purpose | |---|---| -| `monerium_accounts` (069, 071, 078) | One row per client account: Monerium profile UUID, IBAN, forwarder/destination/fallback addresses, fee policy mirror (`target_ppm`, `floor_ppm`), lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | -| `monerium_fiat_deposits` (069, 070, 073, 076) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status, on-chain mint identity, and two webhook-emission markers | -| `monerium_conversion_executions` (069, 074, 075, 077, 079) | One row per `swapAndForward()`, created before broadcast with the reference (rate, source, averaging window, time) and route it will send: EURe in, USDC gross + fee + subsidy from the event, conversion net (`usdcOut - fee + subsidy`, excluding unrelated USDC swept by `forwarded`), tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and `SwapExecuted` log index (allocation boundary), status | -| `monerium_deposit_allocations` (076) | N:M accounting join: the EURe portion and attributed net USDC for each deposit/execution pair | +| `monerium_accounts` (069, 071, 078, 080) | One row per client account: Monerium profile UUID, IBAN, forwarder and destination addresses, fee policy mirror (`target_ppm`, `floor_ppm`), lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | +| `monerium_fiat_deposits` (069, 070, 073, 076, 080) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status through settlement (`converting`, `forwarded`) or refund (`recovering`, `refunded`, `recovery_failed`), on-chain mint identity, and two webhook-emission markers | +| `monerium_conversion_executions` (069, 074, 075, 077, 079, 080) | One row per keeper transaction, bound to the deposit it serves (`deposit_id`) and typed by `kind`: a `swap` row is created before broadcast with the chunk, the reference (rate, source, averaging window, time) and route, then filled from `SwapExecuted` (USDC gross, fee, subsidy, net `usdcOut - fee + subsidy`); a `forward` row carries the amount pushed to the destination; a `recover` row the EURe and USDC moved to the recovery wallet. All carry tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and event log index, status | | `monerium_webhook_events` (069) | Durable persist-before-200 inbox for Monerium deliveries, dedup by event id, 30-day retention after processing | | `monerium_chain_cursors` (070) | Persisted block cursors for the mint watcher | | `webhook_deliveries` (072) | Generic durable outbox for the deposit-event webhook family: one row per (webhook, event), claim-based dispatch with backoff, 30-day retention after settling | @@ -458,14 +487,16 @@ the exactly-once link/IBAN calls, and — registered by the partner — a user-o Webhook deliveries survive crashes (persist-before-200 inbox); a late provider webhook reconciles the exact same-account unattributed mint into the provider order, including -when that order row already exists, without duplicating chain identity or allocations; +when that order row already exists, without duplicating chain identity or executions; provider onboarding calls are exactly-once (`financial_operations`) and their reads are bound to the configured profile and chain; a broadcast whose hash was lost is recovered -from its persisted nonce/block plus an exact transaction-and-event match (including the -persisted reference and route in the calldata) rather than re-sent; a swap the vault +from its persisted nonce/block plus an exact transaction-and-event match (the calldata +of its kind rebuilt from what was persisted) rather than re-sent; a swap the vault could not cover, a reference that is unavailable or out of band, or a fill below the floor is deferred by the keeper, never forced; all per-account writes serialize on one -advisory lock; and the client always has two exits -that no operator failure can block — the fallback-address sweep and, past the trigger -delay, permissionless swap execution. Full invariants and threat model: +advisory lock; a Vortex outage can never trap converted funds on chain (past the +trigger delay anyone may swap and forward permissionlessly); and a payment the promised +window was missed on leaves the clone only through the keeper's delay-gated recovery to +the immutable Vortex wallet, which the refund completes off chain. Full invariants and +threat model: [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md). diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index a58860033..8eb64e02b 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -11,6 +11,10 @@ procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-run verbal/Telegram statements; consolidate into the MSA or a side letter: 1. Attestor-pattern acceptance (verbally accepted, conditional on fallback capability — + **re-approval needed (2026-09-17):** the fallback is now a Vortex-held recovery + wallet linked to a Vortex/SatoshiPay company profile, and that one profile refunds + many client corporates by SEPA; ask alongside whether `supportingDocumentId` is + required for a return-to-originator above EUR 15,000 and what outgoing limits apply — mandatory by design, so the condition is met). 2. Redemption-limitation disclosure obligation (their request; our commitment — §Terms 1). 3. Issuer recovery backstop: burn from a linked address, payout only to the customer's @@ -62,7 +66,9 @@ fee policy 12.5 bps target / 15 bps floor (B1). executes within floor plus the per-swap subsidy cap, and decide whether a second route (direct EURe→USDC or other tiers) is worth whitelisting from day one. 4. Deploy implementation + factory with the final parameters (ADR table: 52 h oracle - age, 40 bps floor on the net, 1% fee cap, 100 bps reference band, 7 d/24 h delays, + age, 60 bps floor on the net, 1% fee cap, 100 bps reference band, 2 h recovery / 24 h + trigger delays, the recovery wallet address (a dedicated linked address on the Vortex + company profile — onboard that profile in the whitelabel app first), €25 floor/€50k ceiling, initial 5 bps/5 bps route); set operational `minSwapAmount` €250 and `perSwapCap` €25k; register the keeper key. 4a. Deploy `VortexSubsidyVault` (USDC, the fee Safe as treasury, the factory, 50 bps per @@ -97,11 +103,12 @@ fee policy 12.5 bps target / 15 bps floor (B1). ## Terms & disclosure inputs (engineering-accurate; G2/partner own final wording) 1. **Redemption limitation (B6 — mandatory, committed to Monerium).** Draft: - > EURe received at your dedicated forwarding address cannot be redeemed directly - > with Monerium from that address. If you need to redeem EURe (rather than receive - > the automatic USDC conversion), you must first withdraw it to your fallback - > address — from which you can redeem normally — or use Monerium's recovery - > process, which pays out only to your own verified bank account. + > EURe received at your dedicated forwarding address cannot be redeemed with Monerium + > from that address and cannot be withdrawn by you. It is converted to USDC and sent + > to your payout address as one transfer per payment; a payment that cannot be + > converted within the promised window is refunded by Vortex, in EUR and in full, to + > the bank account it was sent from. Monerium's recovery process, which pays out only + > to your own verified bank account, remains available as a backstop. (The recovery backstop is functional as built — T1 resolved — but keep it framed as Monerium's process, subject to their verification.) @@ -111,15 +118,16 @@ fee policy 12.5 bps target / 15 bps floor (B1). mis-crediting losses; CEX destinations carry an explicit rotation/minimum-deposit attestation. Vortex's diligence consideration: 5 USDC penny test before activation, the 60-day dormancy gate, minimum forward at or above the destination's minimum - deposit, and never sending unconverted EURe to the destination. Destination changes - are client-only (fallback key); Vortex cannot redirect funds. + deposit, and never sending unconverted EURe to the destination. The destination is + fixed per account: a change means a new forwarding account (and IBAN move) set up by + Vortex on the partner's written instruction; Vortex cannot redirect funds. 3. **Dormancy re-confirmation (P5/B5).** Draft: > If no conversion completes for 60 days, forwarding pauses automatically and > resumes only after you (or the partner on your behalf, in writing) re-confirm your > payout address. Deposits made while paused remain in your forwarding account and - > convert after re-confirmation; your fallback-address rights are unaffected. Once - > a balance has waited 7 days without conversion, anyone may return it to your - > fallback address instead. + > convert after re-confirmation. A payment received while paused that cannot be + > converted within the promised window is refunded in full to the bank account it + > came from. 4. **Rate, fee and subsidy (B1/P1/P2/P12/P13)** — disclose the guarantee, the fee and the hard bound separately: - Reference rate: a five-minute volume-weighted average of the Coinbase Exchange @@ -141,31 +149,40 @@ fee policy 12.5 bps target / 15 bps floor (B1). unsubsidized Chainlink-bounded terms below; the guarantee applies to conversions Vortex's keeper executes. - Hard bound (not a fee): no conversion ever delivers less than the Chainlink - EUR/USD rate minus 0.4% after fee and subsidy, or it does not execute. Enforced by + EUR/USD rate minus 0.6% after fee and subsidy, or it does not execute. Enforced by the contract assuming an honest oracle; not a principal guarantee under oracle failure or a stablecoin collapse beyond the bound. - - Batching never changes a client's effective rate: co-converted deposits split fee, - subsidy and output pro-rata by amount. + - Each payment converts on its own, in chunks when it exceeds the per-conversion cap, + and reaches the payout address as a single transfer once every chunk is done; the + chunks' rates, fees and subsidies are reported per chunk. 5. **Processing SLA (B3 — decided: same business day).** Draft: - > Deposits at or above the minimum convert the same business day under normal - > market conditions. Conversions also execute on weekends; the EUR/USD reference - > rate updates less frequently outside FX market hours (staleness ceiling 52 h), so - > weekend conversions may execute at a rate up to that age — always within the - > conversion bound. Deposits below the minimum accumulate until it is reached. + > A payment is converted and delivered within two hours of its arrival under normal + > market conditions, on weekends as well. A payment that cannot be converted within + > that window — a market move beyond the conversion bound, a liquidity or subsidy + > shortfall, or an operational fault — is not held: Vortex refunds the full EUR amount + > to the bank account it was sent from. Payments below the minimum are refunded the + > same way. The EUR/USD reference rate updates less frequently outside FX market + > hours (staleness ceiling 52 h), so weekend conversions may execute at a rate up to + > that age — always within the conversion bound. - Include: the SLA is a service target, not a guarantee; keeper outages beyond 24 h - open a permissionless execution path, so conversion does not depend on Vortex; a - conversion deliberately held back by the subsidy limits is disclosed as waiting, not - failed. -6. **Vortex powers & self-custody disclosure.** What Vortex can do: deploy the account, - run the conversion, pause it, tune bounded parameters, adjust the fee policy within - the disclosed cap and timelock, choose the swap route among an on-chain validated - set, and fund or limit its own subsidy budget. What Vortex cannot do: move, redeem, or redirect funds — - every exit target is client-controlled, and pauses never block the fallback rights - or the delayed automatic sweep. Exit guarantees are scoped to the client's continued - control of their fallback key (loss of that key plus a broken destination is an - ordinary self-custody residual, borne by the client). Vortex cannot prevent inbound - SEPA to an issued IBAN; deposits during a pause accumulate safely as EURe. + Include: the window is enforced on chain (funds cannot move to Vortex's refund wallet + before it elapses); keeper outages beyond 24 h open a permissionless execution path, + so conversion does not depend on Vortex; the refund is automated in a later phase and + operator-run until then (runbook §2.7); a refund reverses the fee (none is kept on a + refunded payment's delivered amount — chunk fees already taken are Vortex's cost). +6. **Vortex powers & custody disclosure (amended 2026-09-17).** What Vortex can do: + deploy the account, run the conversion, pause it, tune bounded parameters, adjust the + fee policy within the disclosed cap and timelock, choose the swap route among an + on-chain validated set, fund or limit its own subsidy budget, and — for a payment + the promised window was missed on, and only then — move that payment to its own + recovery wallet in order to refund it. What Vortex cannot do: redirect funds. The + contract can pay only the client's payout address, Vortex's fee treasury, and the + fixed Vortex recovery wallet, and it refuses a recovery before the window has + elapsed. Vortex holds custody of a client's funds only on that refund path; the + client has no key of their own and no unilateral exit — the partner accepts this + (written confirmation, G1). Should Vortex disappear, anyone may complete conversions + permissionlessly after 24 hours. Vortex cannot prevent inbound SEPA to an issued IBAN; + deposits during a pause accumulate safely as EURe until converted or refunded. ## Open items ledger @@ -178,6 +195,9 @@ fee policy 12.5 bps target / 15 bps floor (B1). | Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering (needs Marcel's sandbox login) | Open — only remaining engineering unknown | | Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | | Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses a five-minute VWAP of Coinbase Exchange EURC-USDC candles — confirm that is what was meant | -| Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 25 bps, not the band: a market more than ~25 bps under a stale Chainlink round defers every swap until the round updates — decide whether that is acceptable or whether `SLIPPAGE_BPS`/`floorPpm` move before the deploy | +| Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 45 bps after the 2026-09-17 move to 60 bps: the twelve-month replay shows ~0.2 h/year of floor-cause deferral at that margin, so ordinary weekends no longer refund | +| Recovery wallet + float wallet | Ops ↔ Monerium | Onboard a Vortex/SatoshiPay company profile in the whitelabel app; link one dedicated address as `RECOVERY_WALLET` (immutable at implementation deploy) and one as the EURe float; fund the float; keys into the keeper's KMS before recovery is automated | +| Refund automation (proposal phase 2) | Engineering | Deadline trigger, reverse swap, float top-up and redeem orchestration; until then runbook §2.7 by hand | +| Sandbox SEPA simulation: payer counterpart | Engineering (needs Marcel's sandbox login) | Capture one real issue-order webhook to confirm `counterpart.identifier.iban` / `details.name` arrive as the spec says (the refund target) | | Subsidy vault funding and refill cadence | Ops | Before first activation; runbook §2.6 | | GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 67ce29903..855a1272b 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -9,22 +9,25 @@ security invariants: Ground rules that shape every procedure here: -- **Vortex powers are delay-only.** Guardian/keeper can pause and execute the policy — - never move or redirect funds. There is no Vortex-side rescue path by design. -- **Pauses never trap client funds.** `fallbackAddress` functions (`sweep`, - `setDestination`, `setFallbackAddress`, `setClientPaused`) and the permissionless - dead-man sweep (`sweepStrandedEure`, after 7 days) work while paused. Do not promise - otherwise in comms — and remember the sweep makes 7 days the longest any Vortex-side - hold can last. -- **The subsidy vault holds Vortex money only.** It pays a client's fixed destination - up to the floor, within its caps, and withdraws only to the treasury; funding, limits - and pause are ordinary operations (§2.6), never a client-funds question. -- **Never send raw EURe to a CEX destination.** EURe recovery targets are - `fallbackAddress` only. -- **Treat allocation migrations as forward-only after use.** Run migrations from one - deployment instance only. Migration 076 refuses rollback when any - `monerium_deposit_allocations` row exists; take a database backup and roll forward - rather than deleting financial attribution records. +- **Vortex powers are bounded, not custodial by default.** Guardian/keeper can pause, + execute the policy (chunk swaps, one forward per payment) and — only for a payment + whose batch has been open for `RECOVERY_DELAY` (2 h) — move that payment to the + immutable Vortex recovery wallet for a bank refund (§2.7). Nothing else can move + funds, and nothing can redirect them: the clone pays the client's destination, the + fee treasury and the recovery wallet, full stop. +- **Pauses block swaps and forwards, never a recovery.** Pause-then-recover is the + incident sequence. Past 24 h anyone may swap and forward permissionlessly, so a + pause plus a dead keeper still cannot trap converted funds. There is no client key + on the clone any more (ADR amendment 2026-09-17). +- **The subsidy vault holds Vortex money only.** It tops a chunk up to the floor on the + clone (the top-up is forwarded with the payment), within its caps, and withdraws only + to the treasury; funding, limits and pause are ordinary operations (§2.6), never a + client-funds question. +- **Never send raw EURe to a CEX destination.** EURe leaves a clone only to the router + or the Vortex recovery wallet. +- **Run migrations from one deployment instance only.** Migration 080 refuses to run + while an execution from the former allocation model spans several deposits; reconcile + such rows by hand rather than guessing an attribution. ## 1. Client onboarding @@ -45,9 +48,9 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag EIP-55 checksum, not zero/dead/precompile/token/router (the contract re-rejects token/router/self at init), warn-and-attest for contract addresses and CEX addresses (rotation risk — terms). -- `fallbackAddress` — client's **self-custodied** recovery address. Mandatory, no - exceptions (Monerium acceptance condition). Must be distinct from custodial/CEX - addresses. +- (No client recovery address: the recovery wallet is Vortex's, immutable in the + implementation. The destination has no setter — a client wallet change is a new clone, + §5 — so get it right and penny-test it.) - `targetPpm` / `floorPpm` — the client's fee policy in ppm below the reference rate; launch policy 1250 / 1500 (12.5 / 15 bps, ADR B1). Adjustable later via the guardian's timelocked `setFeePolicy` (raising either value waits 24 h). @@ -58,8 +61,8 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag ```bash # predict, then deploy (guardian-only); salt = any unused bytes32, convention: client index cast call $FACTORY "predictAddress(bytes32)(address)" $SALT --rpc-url $RPC -cast send $FACTORY "deployForwarder(address,address,uint32,uint32,bytes32)" \ - $DESTINATION $FALLBACK $TARGET_PPM $FLOOR_PPM $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $FACTORY "deployForwarder(address,uint32,uint32,bytes32)" \ + $DESTINATION $TARGET_PPM $FLOOR_PPM $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY ``` The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). @@ -95,7 +98,6 @@ POST /v1/admin/monerium-b2b/accounts (Authorization: Bearer $ADMIN_SECRET "moneriumProfileId": "", "forwarderAddress": "", "destination": "", - "fallbackAddress": "", "targetPpm": 1250, "floorPpm": 1500 } @@ -161,8 +163,9 @@ cast send $FACTORY "setRouteEnabled(uint256,bool)" false --rpc-url $RPC cast send $VAULT "setPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY ``` -Both pauses block `swapAndForward` only; unpause = same call with `false`. Pausing the -vault pauses nothing on the forwarders: swaps that need no subsidy keep executing. +Both pauses block `swap`, `forward` and `forwardAll` only — never `recover`; unpause = +same call with `false`. Pausing the vault pauses nothing on the forwarders: swaps that +need no subsidy keep executing. ### 2.2 Monerium IBAN suspension ask @@ -177,8 +180,9 @@ negotiation record. While unsuspended, inbound SEPA keeps minting EURe to the fo Clients have no Vortex UI; comms run through the partner plus direct email: notify the partner ops contact first; email affected clients (**stop sending EUR to your IBAN until -further notice**; deposits already sent convert after resolution or are recoverable via -the fallback address — nothing is lost by pausing); status page entry if global. +further notice**; deposits already sent convert after resolution or are refunded to the +sending bank account through the recovery path — nothing is lost by pausing); status +page entry if global. ### 2.4 Critical-vulnerability sequence (the 02:00-UTC drill) @@ -190,14 +194,14 @@ Suspected vulnerability in `VortexForwarder`/factory: 4. **Assess.** Funds at risk = EURe balances on forwarders (stranded-balance monitor output, or `cast call "balanceOf(address)" `); run the manifest verifier against the live deployment. -5. **If funds must move: only clients can move them.** Instruct clients (via partner) - to sweep EURe with their fallback key: `sweep(EURE, )` from - `fallbackAddress` — provide exact calldata and a verification walkthrough. The +5. **If funds must move: the refund path.** Mark every open deposit for recovery + (§2.7); once each clone's batch is 2 h old the keeper moves the funds to the + recovery wallet and the payments are refunded to the payers' bank accounts. The issuer recovery backstop (burn + payout to the client's own bank account; validates the already-whitelisted ownership message) is the last resort. 6. **Ship the fix as a migration** (§5): new implementation + factory (new audit), new clones, re-link, move IBANs, penny-test, republish the manifest. Old clones stay - paused; residual balances leave via fallback or dead-man sweep. + paused; residual balances leave through the refund path. 7. **Unpause / decommission** only contracts confirmed unaffected. ### 2.5 Whitelabel-credential compromise (S1) @@ -238,25 +242,67 @@ daily budget covers roughly one and a half such swaps per day across all clients the keeper starts deferring. Raise the budget or lower `perSwapCap` if deferrals become routine; both are instant. +### 2.7 Refund (recovery) procedure — manual until automated + +Trigger: a deposit the promised window (2 h) was missed on, a remainder below +`minSwapAmount`, a compliance decision, or a critical incident (§2.4). Prerequisites: the +recovery wallet (`RECOVERY_WALLET()` on the implementation) is a linked address of the +Vortex company profile at Monerium, its key and the EURe float wallet's key are in the +operator's custody, and the float holds EURe. + +1. **Mark the deposit.** `POST /v1/admin/monerium-b2b/deposits//recover` + (`Authorization: Bearer $ADMIN_SECRET`). Refused (409) while a keeper transaction for + the deposit is pending — retry once it settled — or when the deposit is not + `minted`/`converting`. The deposit becomes `recovering`; the keeper stops chunking it. +2. **Wait for the keeper's `recover`.** It sends `recover(eureRemaining, usdcConverted)` + once the clone's `batchOpenedAt` is `RECOVERY_DELAY` old (the contract refuses + earlier; younger deposits keep converting meanwhile). Verify the `recover` execution + row is `confirmed` and the `Recovered(eure, usdc)` event amounts match: + + ```sql + SELECT kind, eure_in_raw, usdc_net_raw, tx_hash, status, error + FROM monerium_conversion_executions WHERE deposit_id = '' ORDER BY created_at; + ``` + +3. **Swap the USDC back** from the recovery wallet over the reverse whitelisted route + (USDC → EURC → EURe on the same pools; `exactInput` on the router with a + Chainlink-derived minimum, 60 bps tolerance), or leave the USDC in the recovery + wallet and let the float cover the whole difference when the market is thin. +4. **Top up from the float:** transfer `issueAmount − EURe on the recovery wallet` EURe + from the float wallet to the recovery wallet. Book that amount as the refund's + subsidy; book any EURe surplus from step 3 to the treasury. +5. **Redeem the exact amount.** `POST /orders` from the recovery wallet: `kind: redeem`, + `amount` = the issue order's `amount` string, `counterpart.identifier.iban` = the issue + order's `counterpart.identifier.iban`, `details.companyName` = its `details.name` + (individual payers: `firstName`/`lastName`), `country` from the IBAN prefix, `memo` + naming the original payment, the message `Send EUR to at ` + signed by the recovery key; attach `supportingDocumentId` above EUR 15,000 (G1 item + 1 asks whether returns are exempt). Watch `order.updated` for `processed`. +6. **Close the deposit.** `PATCH /v1/admin/monerium-b2b/deposits//status` + with `{"status": "refunded"}`; use `recovery_failed` when a step cannot complete (and + `recovering` again to retry later). Record deposit id, recover tx, reverse-swap tx, + float top-up, redeem order id and payer IBAN (masked) in the ops ledger. + ## 3. Alert triage (monitoring log lines → action) Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium-b2b:`. | Log line contains | Meaning | Action | |---|---|---| -| `DEPTH BELOW FLOOR — raw quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Even minimum-size fills land below Chainlink − 40 bps on every route before settlement. The floor is enforced on the client's net, so the keeper still executes while the vault covers the shortfall (up to the per-swap cap; beyond it the keeper defers and logs `deferring conversion`), but every swap of that size now costs a subsidy and the unsubsidized permissionless path would revert | Investigate pool state (LP exit, depeg) and watch the vault spend (§2.6); whitelist a better route or lower `perSwapCap`; global pause (§2.1) if it is a depeg or the vault is being drained; re-run the liquidity-baseline methodology before trusting the route again | +| `DEPTH BELOW FLOOR — raw quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Even minimum-size fills land below Chainlink − 60 bps on every route before settlement. The floor is enforced on the client's net, so the keeper still executes while the vault covers the shortfall (up to the per-swap cap; beyond it the keeper defers and logs `deferring conversion`), but every swap of that size now costs a subsidy and the unsubsidized permissionless path would revert | Investigate pool state (LP exit, depeg) and watch the vault spend (§2.6); whitelist a better route or lower `perSwapCap`; global pause (§2.1) if it is a depeg or the vault is being drained; re-run the liquidity-baseline methodology before trusting the route again | | `raw quote impact at perSwapCap exceeds SLIPPAGE_BPS` | Cap-sized swaps would need a vault subsidy; availability and vault spend, not fund risk | Lower `perSwapCap`, add a route, or accept the subsidies; watch for escalation | -| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable — check egress), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (depeg — do not force), `no enabled swap route could be quoted` or `the factory has no enabled swap route` (§2.1 route lever) | Funds wait with the marker armed; after 24 h the permissionless path can execute unsubsidized, after 7 days the fallback sweep is live — communicate if the deferral persists | +| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable — check egress), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (depeg — do not force), `no enabled swap route could be quoted` or `the factory has no enabled swap route` (§2.1 route lever) | Funds wait with the batch marker open; a deferral that outlives the 2 h window means the payment is refunded (§2.7) rather than converted late — communicate; after 24 h the permissionless path can execute unsubsidized | | `SUBSIDY VAULT —` (error) | Vault paused or empty: every below-floor swap defers | §2.6: fund or unpause; check why it emptied (budget too high for the market?) | | `subsidy vault ... refill before below-floor swaps start deferring` | Less than a day of budget left, or today's budget spent | §2.6 refill; consider the budget vs. observed spreads | | `no subsidy vault is configured on the factory` | `setSubsidyVault` never ran; below-floor swaps defer | §2.6 | | `route ... could not be quoted` | One whitelisted route's pool is unquotable (drained, removed) | Disable it (§2.1) so the keeper stops trying; keep at least one healthy route | | `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, address linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | -| `stranded EURe on forwarder` (warn ≥12h) | Keeper is not converting | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts) | -| `stranded EURe ... past TRIGGER_DELAY` | Permissionless trigger now live; SLA long broken (keeper outage or a persistent deferral) | Escalate; anyone may call `swapAndForward(reference, route)` — that path prices against Chainlink and pays no subsidy; communicate the delay | -| `dead-man sweep to the fallback possible in` / `is live` | The balance has waited close to or past the 7 day sweep delay; anyone can move it to the client's fallback | Decide with the partner whether to convert now (unpause / fund the vault) or let the sweep return the EURe; tell the client either way | +| `stranded funds on forwarder ... past RECOVERY_DELAY` (warn) | A batch has been open longer than the promised 2 h window and is neither forwarded nor recovering | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts), `deferring conversion` lines; if the payment cannot complete, mark it for recovery (§2.7) | +| `stranded funds ... past TRIGGER_DELAY` (error) | Permissionless path now live; SLA long broken (keeper outage or a persistent deferral) | Escalate; anyone may call `swap(reference, route, amountIn)` and `forwardAll()` — that path prices against Chainlink and pays no subsidy; communicate the delay | +| `REFERENCE VENUE —` (error) | The Coinbase product the reference reads is delisted or halted; every keeper swap defers silently | Change `COINBASE_REFERENCE_PRODUCT` (a live EURC market), redeploy the backend; the venue is an operational, not an on-chain, setting | | `untrusted factory` / `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on trusted factory` | Should-be-impossible state | Full incident: global pause, verify `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, run the manifest verifier, compare against manifest history | -| `reconciled owner-authorized config change` | Client rotated destination/fallback, or a guardian fee-policy change applied — expected, DB updated | No incident. Unexpected destination change → confirm with the partner; a surprise suggests a compromised fallback key (client should `setClientPaused(true)` and rotate) | +| `reconciled guardian-authorized fee policy change` | A timelocked fee-policy change applied — expected, DB updated | No incident; confirm it matches the announced change | +| `config violation ... destination changed on chain` | Should be impossible: the clone has no destination setter | Full incident (see the row above) | | `onboarding advance failed` (repeating for one account) | Link/IBAN automation stuck | Check the `financial_operations` row: `failed` retries itself; `unknown` needs manual reconciliation (compare Monerium-side state, then update the row) | | `delivery ... abandoned after N attempts` | Partner webhook endpoint down > backoff horizon | Contact partner; deliveries are not retried after abandonment — partner should poll `GET /v1/monerium-b2b/deposits` to catch up | | `MONERIUM_B2B_PRIVATE_RPC_URL is not set` | Keeper writes in the public mempool | Set the private orderflow RPC (operational finding on mainnet) | @@ -269,17 +315,17 @@ address the client no longer controls. The gate converts that silent loss into a **Automatic:** an `active` account with no confirmed conversion for 60 days is paused (`setGuardianPaused(true)` with the guardian key; log-only if the key is unset) and -`dormant_since` is recorded; the conversion executor skips it (the stranding marker -still arms — the dead-man sweep clock is unaffected). EURe arriving during dormancy -accumulates safely; past the sweep delay (7 days, registry P3) anyone can move it to -`fallbackAddress` — so a deposit into a dormant account gives the partner about a week -to re-confirm before the money goes to the client's self-custody address instead of -being converted. +`dormant_since` is recorded; the conversion executor stops swapping and forwarding for +it but still recovers deposits marked for the refund path (`recover` ignores the pause). +EURe arriving during dormancy accumulates safely; a deposit into a dormant account is +therefore refunded through §2.7 once the window is missed, unless re-confirmation +arrives first. **Re-confirmation (manual, via partner):** partner re-confirms in writing that the -destination is valid and client-controlled (ADR B5). If the destination changed, the -**client** updates it via their fallback key (`setDestination`) — Vortex cannot and must -not — and CEX destinations re-run the penny test. Archive the confirmation. +destination is valid and client-controlled (ADR B5). If the destination changed, deploy +a new clone with the new destination and migrate (§5) — the clone has no setter and +Vortex must never redirect — and CEX destinations re-run the penny test. Archive the +confirmation. **Un-pause (both steps, always):** @@ -304,7 +350,7 @@ monitor's alerts are expected, then: 1. Deploy the new clone (§1.2) and verify it (`isForwarder` + config read-back — Vortex tooling only ever targets factory clones). -2. Let the keeper drain the old clone (or client sweeps the remainder via fallback). +2. Let the keeper drain the old clone (forward every open deposit; refund a remainder below the minimum via §2.7). 3. Link the new clone to the same Monerium profile (attestor flow — automated once the account row's forwarder is repointed, or manual `POST /addresses`). 4. Move the IBAN: `PATCH /ibans/{iban}` with the new address — this is the @@ -320,12 +366,12 @@ the IBAN's current default address; the old clone stays linked but inert. | Key | Blast radius | Response | |---|---|---| | Attestor | Can link addresses to profiles; never move funds (recovery payouts go only to the client's own bank account) | Rotate key; new forwarders need a new implementation (ATTESTOR is immutable); existing links unaffected | -| Keeper | `poke`/`swapAndForward` only; can pick any whitelisted route and any reference inside the Chainlink band — worst case the fee reaches the 1% cap or the vault pays up to its caps, plus gas theft; never a redirect | Rotate; `setKeeper(old,false)` + `setKeeper(new,true)`; pause the vault while rotating; reconcile executions against Coinbase history; refund gas | +| Keeper | `poke`/`swap`/`forward`/`recover`: can pick any whitelisted route and any reference inside the Chainlink band — worst case the fee reaches the 1% cap or the vault pays up to its caps, plus gas theft — and can move a payment whose batch is 2 h old to the Vortex recovery wallet (never anywhere else, never a redirect) | Rotate; `setKeeper(old,false)` + `setKeeper(new,true)`; pause the vault while rotating; reconcile executions against Coinbase history; audit `Recovered` events against marked deposits; refund gas | +| Recovery wallet | Holds recovered payments between `recover` and the bank refund; can redeem EURe from the Vortex company profile to any IBAN | Move any balance to a fresh linked address, rotate the key, redeploy the implementation (the address is immutable) before the next recovery; reconcile open recoveries against the ops ledger | | Guardian | Pause/unpause, bounded params, timelocked fee policy, route whitelist (validated), vault limits and withdrawal to treasury — delay-only griefing plus Vortex-money exposure | Two-step `transferGuardian`/`acceptGuardian`; audit pause, pending-policy, route and vault state after | | Whitelabel API credentials | Control-plane: can re-link/move IBANs (future mints only) — S1 | §2.5 full sequence | | `ADMIN_SECRET` | Map/suspend accounts (mapping is bounded by on-chain clone verification) | Rotate; audit recent admin mutations | | Webhook HMAC secret | Fabricated inbound order events (accounting noise; forward-only lattice + mint watcher bound the damage) | Rotate at both ends; reconcile deposits against chain | -| Client fallback key (client-side) | Full control of that client's funds/config | Client's own responsibility (terms); assist via partner: pause the account; client rotates `setFallbackAddress` if still in control | ## 7. Local mainnet-fork integration exercise @@ -358,7 +404,7 @@ standard public Anvil development keys for the local roles. | Forwarder | `0xe06103c9E374a1CD78f17417d1eA3AE4eBaC7CFD` | | Forwarder deployment tx | `0x7d4f667d4de9b7a5d5aced2203a3f11dcd0b477e5d405d5b8a2317f2b56b7c4c` | | Destination | `0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc` | -| Fallback address | `0x976EA74026E726554dB657fA54763abd0C3a0aa9` | +| Recovery wallet | (reference run predates the recovery wallet; use any local EOA) | | Local account id | `5ebca15c-dadf-4eeb-aabb-e9c7462ff6b3` | | Mock Monerium profile id | `f436dbeb-6012-4688-ab3b-d2446980c835` | | Managed profile id | `c419d077-3e2b-488a-b228-359311c63324` | @@ -460,10 +506,11 @@ fixtures: | Constructor field | Value | |---|---:| | `MAX_ORACLE_AGE` | 52 hours | -| `SLIPPAGE_BPS` | 40 (on the client's net after fee and subsidy) | +| `SLIPPAGE_BPS` | 60 (on the client's net after fee and subsidy) | | `MAX_FEE_PPM` | 10000 | | `MAX_REFERENCE_DEVIATION_BPS` | 100 | -| `SWEEP_DELAY` | 7 days | +| `RECOVERY_DELAY` | 2 hours | +| `RECOVERY_WALLET` | a local EOA (immutable; a zero address is refused) | | `TRIGGER_DELAY` | 24 hours | | Initial route | EURe → EURC → USDC, 500 / 500 (packed path constructor argument) | | `RECOVERY_HASH` | `bytes32(0)` | @@ -490,7 +537,7 @@ a below-floor fill makes the keeper defer, which is also a valid outcome to obse Deploy a client clone with the launch policy (1250 / 1500) as in §1.2. Use a fresh salt and record the predicted address and receipt. Read back `destination()`, -`fallbackAddress()`, `targetPpm()`, `floorPpm()`, and `FACTORY()`, then require +`targetPpm()`, `floorPpm()`, and `FACTORY()`, then require `factory.isForwarder(forwarder) == true` before continuing. The keeper computes its reference from live Coinbase candles before each swap, so the backend needs outbound HTTPS during the run. @@ -509,7 +556,6 @@ managed-profile manager: "moneriumProfileId": "", "forwarderAddress": "", "destination": "", - "fallbackAddress": "", "targetPpm": 1250, "floorPpm": 1500 } @@ -579,8 +625,11 @@ cast rpc anvil_mine 0xd --rpc-url http://127.0.0.1:8545 ``` Wait for the next worker cycle. A direct transfer has no matching Monerium order, so the -expected path is deliberately `unattr:` rather than an attributed customer deposit. The -log should show an unattributed EURe mint followed by an execution allocation. +expected path is deliberately `unattr:` rather than an attributed customer deposit — and +since 2026-09-17 the keeper never converts `unattr:` rows, so to exercise a conversion +insert the matching order row by hand (or send the mint through the sandbox webhook) +before the watcher records it; the log should then show the mint, one `swap` execution +and one `forward` execution. Verify the durable records: @@ -589,34 +638,30 @@ SELECT monerium_order_id, amount_raw, status, tx_hash, log_index, block_number FROM monerium_fiat_deposits WHERE account_id = ''; -SELECT eure_in_raw, usdc_gross_raw, fee_raw, subsidy_raw, usdc_net_raw, destination, +SELECT kind, deposit_id, eure_in_raw, usdc_gross_raw, fee_raw, subsidy_raw, usdc_net_raw, destination, reference_rate_raw, reference_source, reference_window_seconds, route_index, tx_hash, nonce, broadcast_block_number, block_number, swap_log_index, status, error FROM monerium_conversion_executions -WHERE account_id = ''; - -SELECT deposit_id, execution_id, eure_in_raw, usdc_net_raw -FROM monerium_deposit_allocations -WHERE deposit_id IN ( - SELECT id FROM monerium_fiat_deposits WHERE account_id = '' -); +WHERE account_id = '' ORDER BY created_at; ``` Required results: -- One `minted` deposit with an `unattr:` order id and the real transfer hash and log index. -- One allocation joining that deposit and execution with the 25 EURe input and the - attributed net USDC. -- One `confirmed` execution with the 25 EURe input, a recorded reference (rate, source, - averaging window) and route index 0, a fee or subsidy consistent with the fill's position - against the reference bands (`usdc_net_raw = usdc_gross_raw - fee_raw + - subsidy_raw`), non-null nonce/hash/block/swap-log-index, destination matching the - clone, and `error IS NULL`. If the vault was left empty and the fill sat below the - floor, expect a `deferring conversion` log line and no execution row instead. -- The forwarder's EURe balance is zero. -- The destination's USDC balance increased by `usdc_net_raw`. -- The conversion receipt contains `SwapExecuted` from the clone and a USDC `Transfer` - from the clone to the configured destination. +- One `forwarded` deposit with the real transfer hash and log index. +- One `confirmed` `swap` execution bound to it with the 25 EURe input, a recorded + reference (rate, source, averaging window) and route index 0, a fee or subsidy + consistent with the fill's position against the reference bands (`usdc_net_raw = + usdc_gross_raw - fee_raw + subsidy_raw`), non-null nonce/hash/block/swap-log-index, + destination matching the clone, and `error IS NULL`. If the vault was left empty and + the fill sat below the floor, expect a `deferring conversion` log line and no + execution row instead. +- One `confirmed` `forward` execution bound to the same deposit whose `usdc_net_raw` + equals the swap's net. +- The forwarder's EURe and USDC balances are zero. +- The destination's USDC balance increased by the forward's `usdc_net_raw` in one transfer. +- The swap receipt contains `SwapExecuted` from the clone and no transfer to the + destination; the forward receipt contains `Forwarded` and the USDC `Transfer` from the + clone to the configured destination. ### 7.7 What this exercise validates @@ -632,17 +677,15 @@ Required results: safety depth. - A direct transfer to a known forwarder is durably recorded as an unattributed mint, not silently presented as a Monerium customer order. -- The executor's durable path leaves a confirmed execution with its nonce, transaction - hash, block number, swap log index, amounts, and destination recorded; allocation is - added only after the mint cursor covers that execution block. +- The executor's durable path leaves a confirmed `swap` execution bound to the deposit + with its nonce, transaction hash, block number, log index, amounts and destination + recorded, followed by a confirmed `forward` execution for the summed net. - The real contract accepts the current Chainlink EUR/USD answer, the keeper's live Coinbase reference inside the band, and swaps successfully through whitelisted route 0 (EURe -> EURC -> USDC on the 5-bps tiers). - Keeper authorization, the 25 EURe minimum, allowance reset, full EURe consumption, - fee-band accounting against the recorded reference, and forwarding to the immutable - per-client destination work together. -- Cursor-gated snapshot allocation links the observed deposit to the confirmed execution - at the exact `SwapExecuted` log boundary and assigns the full USDC output. + fee-band accounting against the recorded reference, USDC accumulating on the clone, + and one forward to the immutable per-client destination work together. ### 7.8 What this exercise does not validate @@ -664,8 +707,8 @@ Required results: deploys only the canonical valid parameter set. - It does not test fee-policy timelocks, route selection among several routes, a funded vault's top-up (unless you fund it), the reference band rejection, per-swap-cap - batching, sub-minimum accumulation, pause controls, dormancy, permissionless - triggering, stranded-fund sweeping, fallback-key recovery, or client config rotation. + chunking of one deposit, sub-minimum remainders, pause controls, dormancy, + permissionless triggering, or the recovery path (§2.7). - It does not test stale/invalid oracle answers, insufficient liquidity, excess price impact, slippage reverts, router failure, token transfer failure, or depeg behavior. - It does not test reorg replacement, duplicate-log replay, concurrent executors, diff --git a/docs/proposal-monerium-b2b-settlement-and-recovery.md b/docs/proposal-monerium-b2b-settlement-and-recovery.md index 34752fb98..8777a772b 100644 --- a/docs/proposal-monerium-b2b-settlement-and-recovery.md +++ b/docs/proposal-monerium-b2b-settlement-and-recovery.md @@ -1,9 +1,12 @@ # Proposal: whole-deposit settlement and automatic refund recovery (Monerium B2B onramp) -**Status:** draft plan, 2026-09-17. Nothing here is implemented. Targets the design on -PR #1375 (`feat/monerium-forwarder-fee-subsidy`); the contracts are not deployed, so -every change below is a pre-launch design change, not a migration. Accepted parts move -into a second amendment of [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md). +**Status:** accepted 2026-09-17 with every recommendation in §8; phases 0 and 1 are +implemented on PR #1375 (`feat/monerium-forwarder-fee-subsidy`), phases 2 and 3 are in +progress. The decisions live in the second amendment of +[`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); the behaviour in +[`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md). This document +stays as the design rationale (the approaches compared, the feasibility findings) until the +remaining phases land, then it is deleted. ## 1. What product asked for diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 14499d903..c5c29c780 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -19,29 +19,29 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 4. **Webhook HMAC follows Monerium's current v1 protocol** — `webhook-signature` must contain exactly `v1,`. The HMAC-SHA256 input is `..` and the key is the decoded 24–64-byte payload of the configured `whsec_` secret. The raw bytes are captured by a route-scoped body-parser hook and compared with `crypto.timingSafeEqual`; malformed or unverified requests are rejected 401 before any database write. 5. **Durable persist before 200 (R06)** — every verified delivery is inserted into `monerium_webhook_events` before the 200 response is sent. Processing happens strictly after the response; a crash between insert and processing loses nothing because the inbox row survives. 6. **Delivery dedup is enforced by the database** — inserts use `ON CONFLICT DO NOTHING` on the unique `event_id`, populated from the signed `webhook-id` header, so retries of a delivery can never double-create or double-apply a deposit event. -7. **Deposit status transitions are forward-only** — `pending → {minted, held, returned}`, `held → {minted, returned}`; `minted` and `returned` are terminal. Out-of-order or replayed webhook events can never regress a deposit status; regressive transitions are logged and ignored. Guarded by `isForwardTransition` (unit-tested). +7. **Deposit status transitions are forward-only** — provider states first: `pending → {minted, held, returned}`, `held → {minted, returned}`; then the keeper's settlement branch `minted → converting → forwarded` or the refund branch `{minted, converting} → recovering → {refunded, recovery_failed}` (`recovery_failed → recovering` is the operator's retry). `forwarded`, `returned` and `refunded` are terminal. Out-of-order or replayed webhook events can never regress a deposit status: regressive transitions are logged and ignored, and a provider `processed` replay for a deposit already past the mint is a silent no-op. Guarded by `isForwardTransition` (unit-tested). Only the keeper (a confirmed `forward`) and the admin endpoints move a deposit along the settlement and refund branches. 8. **Per-forwarder serialization via advisory lock** — all deposit writes for one forwarder happen inside a transaction holding `pg_advisory_xact_lock(hashtextextended('monerium-b2b:' || lower(forwarderAddress), 0))`, so concurrent processors (multiple API instances, webhook-triggered plus scheduled runs) apply events for an account strictly one at a time. This is the same serialization point the execution/attribution logic (R04) will use. 9. **Deposit identity and scope are verified** — authenticated payloads must pass the shared Monerium wire schema. Only EUR issue orders on the configured chain and the mapped account's Monerium profile are accepted; `meta.txHashes` is used only when it contains exactly one hash. `monerium_order_id` is unique and cannot move between accounts; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are positive 18-decimal base-unit strings converted from provider decimals, never floats. An amount-only mint match is accepted only when exactly one same-account candidate exists. A late real order in a minted provider state reconciles its unique exact same-account unattributed mint by amount and transaction hash: a missing provider row adopts the synthetic row, while an existing provider row receives the chain identity and allocations atomically before the synthetic row is removed. Pending or terminal provider states never adopt a synthetic mint. Ambiguity is quarantined and alerted, never guessed. Malformed authenticated deliveries are terminally discarded so they cannot poison the inbox. 10. **Client credentials are env-only and requests are bounded** — all provider calls go through the shared white-label client ([monerium.md](./monerium.md)): credentials come from env (`MONERIUM_WHITELABEL_CLIENT_ID/SECRET`), every call carries an explicit timeout, HTTPS base URLs only, successful responses are validated against the consumed wire schemas, and upstream failures surface with redacted response bodies. The B2B adapter (`monerium-api.ts`) adds no transport of its own. 11. **No KYB submission path exists** — the whitelabel KYB mechanism is contractually unsettled (adr-0005 registry T3), so no identity-data submission code path exists in the B2B module or the shared client. Pilot corporates do not need one: they are onboarded and approved by Monerium under the partner's KYC reliance, and the admin mapping imports that outcome as an approved `kyc_cases` row. -12. **Account mapping is admin-only, atomic, and rooted in a trusted factory** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) verifies that the forwarder's immutable `FACTORY()` equals `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, queries `isForwarder` on that configured factory (never a self-reported address), and reads back destination/fallbackAddress/targetPpm/floorPpm before persistence (the fee policy is validated as `0 <= targetPpm <= floorPpm <= 10000`, mirroring the contract). The managed child, customer entity, approved KYB mirror, and account then commit in one database transaction, so a late uniqueness conflict leaves no orphan identity records. Identical replay returns existing records; any divergence is 409, never an overwrite. A Monerium profile, forwarder, and managed profile can each back at most one account (migrations 069/071). +12. **Account mapping is admin-only, atomic, and rooted in a trusted factory** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) verifies that the forwarder's immutable `FACTORY()` equals `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, queries `isForwarder` on that configured factory (never a self-reported address), and reads back destination/targetPpm/floorPpm before persistence (the fee policy is validated as `0 <= targetPpm <= floorPpm <= 10000`, mirroring the contract; the clone has no fallback role and no destination setter). The managed child, customer entity, approved KYB mirror, and account then commit in one database transaction, so a late uniqueness conflict leaves no orphan identity records. Identical replay returns existing records; any divergence is 409, never an overwrite. A Monerium profile, forwarder, and managed profile can each back at most one account (migrations 069/071). 13. **Onboarding provider writes are exactly-once and provider reads are scoped** — the automated link (`POST /addresses`) and IBAN request (`POST /ibans`) run through the profile-scoped `financial_operations` ledger (flow `monerium-b2b-onboarding`), so crashes and retries never repeat a claimed provider call; interrupted calls reconcile by re-reading the linked addresses / issued IBANs. Every linked-address and IBAN selection requires the exact mapped profile, configured Monerium chain, and forwarder address; multiple exact IBAN matches are rejected rather than selected arbitrarily. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. 15. **The read surface is effective-user scoped and accepts no selectors** — `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` resolve the account strictly from the acting profile (manager delegation via `X-Managed-Profile-Id` under the standard managed-profile authorization with EU corridor + business policy, or the child's own credential); no caller-supplied account, profile, or IBAN identifier is accepted, a foreign manager gets the uniform managed-profile 403, and R09 `unattr:` synthetic deposit rows are never returned (`monerium-b2b-account-read.integration.test.ts`). -16. **Manager deposit events are final, chain-backed, and manager-only** — `DEPOSIT_RECEIVED` requires the real chain id, transaction hash, log index, and block number, so a provider order alone cannot claim that funds landed. `DEPOSIT_CONVERTED` fires once only after allocations cover the deposit's full EURe amount and every contributing execution is `NOTIFY_CONFIRMATION_DEPTH` blocks deep; its `conversions[]` contains per-execution EURe/USDC portions plus each execution's reference rate, fee and subsidy (`execution` block), and payload `usdcNetRaw` is the aggregate. Per-deposit markers prevent replay to late subscribers. Deliveries go only to the controlling manager's webhooks through the durable outbox; `unattr:` rows never emit (`manager-events.test.ts`). +16. **Manager deposit events are final, chain-backed, and manager-only** — `DEPOSIT_RECEIVED` requires the real chain id, transaction hash, log index, and block number, so a provider order alone cannot claim that funds landed. `DEPOSIT_RECEIVED` fires for any state past the mint (the keeper may already have started converting within the cycle). `DEPOSIT_CONVERTED` fires once only after the deposit is `forwarded` and its confirmed `forward` execution is `NOTIFY_CONFIRMATION_DEPTH` blocks deep; its `conversions[]` lists the deposit's confirmed chunk swaps (EURe in, net USDC, reference rate, fee and subsidy per chunk in the `execution` block), `forwardTxHash` is the single transfer to the destination and payload `usdcNetRaw` is the amount that transfer carried. Per-deposit markers prevent replay to late subscribers. Deliveries go only to the controlling manager's webhooks through the durable outbox; `unattr:` rows never emit (`manager-events.test.ts`). 17. **Account lifecycle transitions are explicit** — `onboarding → active`, `active → {suspended, closed}`, and `suspended → {active, closed}` are the only state changes; `closed` is terminal. Repeating the current status is idempotent. The admin controller returns 409 for every invalid edge, including reopening a closed account or moving an active account back to onboarding (`moneriumB2b.controller.test.ts`). ## Keeper The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox → mint watcher → per-account conversion executor → dormancy gate) holds signing keys and submits transactions; its invariants: -1. **Three-way key separation** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`/`swapAndForward()`), 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 move funds: `swapAndForward` only executes a factory-whitelisted, oracle-floored swap to the client's own `destination`, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `setGuardianPaused` is protective-only by contract invariant; the attestor signs the fixed link statement. All three are env-only and never logged. +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 immutable `RECOVERY_WALLET`, 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. Vortex therefore holds custody of a client's funds only on the refund path, only in its own recovery wallet, and only for a payment the promised window was missed on (adr-0005, amendment 2026-09-17). All three 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. 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 `swapAndForward(referenceRate, routeIndex)` rebuilt from the reference and route persisted on the row before broadcast (`expectedSwapCalldata`), and receipt emits `SwapExecuted` from the forwarder. 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, execution slot check/creation, finalization, R04 allocation) 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. -5. **Attribution is N:M, cursor-gated, exact-snapshot, and idempotent (R04)** — a confirmed execution records the block and block-global `SwapExecuted` log index but is not allocated immediately. Reconciliation starts only after the persisted mint cursor has processed that block, then consumes outstanding portions of deposits minted in earlier blocks or earlier log positions in the same block, oldest-first up to `eureInRaw`. This covers a mint that lands between the executor's balance read and swap without assigning a later same-block mint to the execution. A cap-cut deposit receives a partial `monerium_deposit_allocations` row and its remainder participates in the next execution; one execution may likewise allocate across many deposits. Each row records its EURe portion and proportional net USDC; execution net is computed as `usdcOut - fee + subsidy` (the vault pays the subsidy straight to the destination), never the event's `forwarded` full-balance sweep, so pre-existing unsolicited USDC is not misreported as this deposit's yield. Floor dust goes to the largest allocation only when indexed deposits cover the whole execution, so missing inflow cannot inflate a customer's share. Mint identity is `(chain_id, tx_hash, log_index)` and the watcher scans 12-deep blocks. Only chain-indexed deposits make an account a conversion candidate; a raw forwarder balance never bypasses the watcher. Non-Monerium inflows become `unattr:` rows and never surface as customer claims. 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, 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 or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. -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 computes a five-minute volume-weighted average of Coinbase Exchange EURC-USDC one-minute candles (`reference-rate.ts`; widened to an hour when the five minutes carry no volume, so one thin print cannot set the reference), records price, window and time on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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 at its destination, 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. +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. 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. +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, 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 pending partner re-confirmation mechanics (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. +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 computes a five-minute volume-weighted average of Coinbase Exchange EURC-USDC one-minute candles (`reference-rate.ts`; widened to an hour when the five minutes carry no volume, so one thin print cannot set the reference), records price, window and time on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. ## Monitoring @@ -49,10 +49,11 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per `docs/operations-monerium-b2b-runbook.md`). 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 with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal), and within two days of `SWEEP_DELAY` (7 days, registry P3) the error names the imminent dead-man sweep to the fallback. +3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe or any USDC whose on-chain batch marker has been open longer than `RECOVERY_DELAY` (2 h, registry P3) warn — the promised window was missed and the deposit should be forwarded or recovering; past `TRIGGER_DELAY` they error (the permissionless swap/forward path is then live — a keeper-outage signal, not a fund-risk signal). 4. **Association monitor (S1 detective control)** — per active account, re-reads linked addresses and IBANs scoped to the exact mapped profile and configured chain, then error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). 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, destination/fallback and timelocked fee changes are authorized transitions reconciled with a version bump; proxy bytecode or registration drift is an incident. The standalone manifest verifier remains consistency evidence, not the trust root. +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. +7. **Reference-venue monitor** — probes the status of the Coinbase Exchange product the reference VWAP reads (`fetchCoinbaseProductStatus`, `classifyReferenceVenue`, unit-tested); anything but an online product with trading enabled is an error, because a delisted product keeps answering the candles endpoint with stale data and every keeper swap would defer silently (EURC-USD did exactly that after its 2024-08-29 delisting). ## Threat Vectors & Mitigations @@ -61,8 +62,10 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | **Webhook spoofing** | Attacker posts fabricated order events to `/v1/monerium-b2b/webhook` to invent or advance deposits | Monerium v1 HMAC over signed id + timestamp + raw bytes, constant-time compare; 401 before persistence; enabled startup refuses a missing secret | | **Cross-account/provider poisoning** | A valid provider event names another chain/profile, or a claimed mint hash carries a different amount | Strict wire/chain/profile/currency checks; hash+amount must both match before chain identity or `DEPOSIT_RECEIVED`; conflicting chain logs are isolated as `unattr:` | | **Lost or replaced keeper transaction** | A slow/hidden transaction is declared stale and a second swap sends the same funds | Compare-and-set pre-send reservation; no time-based failure after nonce persistence; fail-closed nonce state; bounded complete persisted-block scan plus exact sender/nonce/target/calldata/event identity before adopt/fail | -| **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving attribution permanently incomplete | Conversion candidates require chain-indexed deposits; allocation waits until the mint cursor covers the swap's exact block/log boundary | -| **Unsolicited USDC inflates deposit reporting** | The contract sweeps a pre-existing USDC balance with a later swap and the backend credits the whole transfer to that deposit | Execution net and allocations use `SwapExecuted.usdcOut - fee`; `forwarded` is deliberately excluded from conversion accounting | +| **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving a swap with no deposit to belong to | Conversion candidates require chain-indexed deposits; every swap is bound to one deposit before it is sent, with an explicit `amountIn` | +| **Unsolicited USDC inflates deposit reporting** | USDC that arrived outside a swap is credited to a deposit | Execution net is `SwapExecuted.usdcOut - fee + subsidy`; a deposit's forward moves exactly the sum of its chunks' nets; unsolicited USDC leaves only through the keeper's `forwardAll` and is never reported as a conversion | +| **Premature or misdirected recovery** | A compromised keeper moves a fresh payment off the clone, or to a wallet of its choosing | `recover` pays only the immutable `RECOVERY_WALLET`, only after the clone's batch has been open for `RECOVERY_DELAY`, with explicit amounts bounded by the balances; a deposit enters the refund path only through the admin endpoint (operator) or, once automated, the deadline; the invariant suite proves no early recovery and no other exit | +| **Recovery wallet compromise** | The Vortex wallet that receives recovered funds is drained before the refund | Funds sit there only during a recovery; the wallet is a dedicated linked address on a Vortex company profile at Monerium holding nothing else; refund steps and their ledger are the runbook's §2.7 until automated | | **Manipulated reference rate** | A compromised keeper (or Coinbase response) supplies a reference that inflates the fee or triggers a subsidy | Contract band check against Chainlink; fee capped at `MAX_FEE_PPM`; subsidy bounded by the vault's per-swap cap and daily budget; the floor on the client's net; a permissionless caller's reference argument is ignored in favour of Chainlink | | **Subsidy-widened sandwich** | A searcher moves the pool so the fill lands just above floor minus cap and the vault pays the difference | Private orderflow for keeper swaps; modest per-swap cap and daily budget; the vault, not the client, absorbs the loss; no subsidy on the permissionless path | | **Subsidy vault drain** | A contract or key tries to pull vault USDC | `pay` accepts only factory-registered clones, within cap and budget; the clone passes its own immutable destination and verifies the exact delivery; withdrawals go to the immutable treasury only; guardian setters bound Vortex's own money, never client funds | @@ -84,7 +87,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] `attestor.test.ts` pins the signature layout against `VortexForwarder.isValidSignature` (65 bytes, v in 27/28, low-s, bound to forwarder address) - [ ] Webhook HMAC fixture covers signed `webhook-id`, `webhook-timestamp`, raw bytes, decoded `whsec_` key, and `v1,` constant-time comparison - [ ] Inbox insert (`ON CONFLICT DO NOTHING` on `event_id`) happens before the 200 response in `monerium-b2b.controller.ts` -- [ ] Forward-only transition guard covers all four statuses; regressive events are dropped, not applied +- [ ] Forward-only transition guard covers all nine statuses incl. the settlement and refund branches; regressive events are dropped, not applied; a provider `processed` replay past the mint is a no-op - [ ] All deposit writes run under `pg_advisory_xact_lock` keyed by lower-cased forwarder address - [ ] `monerium_order_id` unique constraint present; mint-log partial unique index present (migration 069) - [ ] `monerium_accounts.vortex_profile_id` partial unique index present (migration 071); admin mapping rejects divergence with 409 (`moneriumB2b.controller.test.ts`) @@ -94,13 +97,14 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] HTTPS enforcement, timeouts, and wire-schema validation on every provider call are delivered by the shared client ([monerium.md](./monerium.md)); `monerium-api.ts` adds no transport of its own - [ ] Current webhook signature/id protocol and upstream order-state vocabulary re-verified from a production delivery before first mainnet deposit (registry T4) - [ ] Foundry suite covers the three fee bands, the fee cap, the reference band, the floor on the net after subsidy, vault cap/budget/pause/treasury-only withdrawal, route validation and the permissionless no-subsidy path; `projectSwap` mirrors the same numbers (`conversion-executor.test.ts`) -- [ ] Recovery calldata identity includes the persisted reference and route (`expectedSwapCalldata`); a row without them stays pending +- [ ] Recovery calldata identity is rebuilt per kind — `swap(reference, route, amountIn)`, `forward(amount)`, `recover(eure, usdc)` — from what was persisted before broadcast (`expectedCalldata`); a row without it stays pending; forward/recover confirmations require matching event amounts - [ ] Keeper, guardian, and attestor private keys are three distinct keys in production; none logged - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) -- [ ] Conversion execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the swap; no elapsed-time failure exists after nonce persistence; exact recovery identity, bounded paging, and R04 N:M allocation math are covered by `conversion-executor.test.ts` -- [ ] Confirmed executions carry `swap_log_index` (migration 077); `conversion-allocation.test.ts` proves allocation waits for the mint cursor and applies the exact same-block log boundary idempotently -- [ ] Execution/allocation `usdcNetRaw` is `SwapExecuted.usdcOut - fee`, never the full-balance `forwarded` field (`conversion-executor.test.ts`) -- [ ] Migration 076 refuses a lossy rollback once any deposit allocation exists (`monerium-deposit-allocation-migration.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; no elapsed-time failure exists after nonce persistence; 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 `RECOVERY_WALLET`, USDC only to `destination`, `FEE_RECIPIENT` or `RECOVERY_WALLET`, 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 -- [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 owner-authorized config reconciliation; quote-impact, stranding, association-diff and drift classification covered by `monitoring.test.ts` -- [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; owner-authorized config changes (R07) are warn-level reconciliations, never incidents +- [ ] `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` +- [ ] 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 and refuse a deposit with a pending execution (`moneriumB2b.controller.test.ts`) From 49c309fbb08633bfe7e189bf92d613d95037d8a6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 22:35:18 +0200 Subject: [PATCH 32/44] feat(api): record each deposit's mint time and payer for the refund path The promised conversion window counts from the mint block, and a refund needs the payer's IBAN and name; both come from data the flow already sees (the mint log's block, the issue order's counterpart) and were not kept. Adds the monerium_recoveries table the automated refund drives. --- .../monerium-b2b/deposit-processor.test.ts | 16 +++ .../monerium-b2b/deposit-processor.ts | 17 ++- .../api/services/monerium-b2b/mint-watcher.ts | 12 ++ .../081-monerium-recovery-automation.ts | 49 +++++++ apps/api/src/models/index.ts | 4 + .../src/models/moneriumFiatDeposit.model.ts | 26 ++++ apps/api/src/models/moneriumRecovery.model.ts | 121 ++++++++++++++++++ 7 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/database/migrations/081-monerium-recovery-automation.ts create mode 100644 apps/api/src/models/moneriumRecovery.model.ts 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 0a372e750..2bddfb883 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 @@ -116,10 +116,26 @@ describe("parseOrderEvent", () => { orderId: ORDER_ID, profileId: PROFILE_ID, state: "processed", + payerIban: null, + payerName: null, txHash: "0xabc" }); }); + it("captures the payer's IBAN and name from an IBAN counterpart as the refund target", () => { + const payload = { + ...validPayload, + data: { + ...validPayload.data, + counterpart: { + details: { name: " Payer GmbH " }, + identifier: { iban: "de89 3704 0044 0532 0130 00", standard: "iban" } + } + } + }; + expect(parseOrderEvent(payload)).toMatchObject({ payerIban: "DE89370400440532013000", payerName: "Payer GmbH" }); + }); + it("ignores redeem orders, non-order events, and malformed payloads", () => { expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, kind: "redeem" } })).toBeNull(); expect(parseOrderEvent({ ...validPayload, type: "profile.updated" })).toBeNull(); 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 4a0965546..4f996251b 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -104,6 +104,9 @@ interface ParsedOrderEvent { profileId: string; state: string; txHash: string | null; + /** The payer's IBAN and name from the order's counterpart (Monerium "Issue orders" details), when present. */ + payerIban: string | null; + payerName: string | null; } export interface ParsedIbanEvent { @@ -177,12 +180,19 @@ export function parseOrderEvent(payload: unknown): ParsedOrderEvent | null { if (!event || (event.type !== "order.created" && event.type !== "order.updated")) return null; const data = event.data; if (data.kind !== "issue" || data.currency !== "eur") return null; + const identifier = data.counterpart.identifier as { iban?: unknown; standard: string }; + const details = (data.counterpart.details ?? {}) as { name?: unknown }; return { amount: data.amount, chain: data.chain, currency: data.currency, forwarderAddress: data.address, orderId: data.id, + payerIban: + identifier.standard === "iban" && typeof identifier.iban === "string" + ? identifier.iban.replace(/\s+/g, "").toUpperCase() + : null, + payerName: typeof details.name === "string" && details.name.trim() ? details.name.trim().slice(0, 140) : null, profileId: data.profile, state: data.state, txHash: data.meta.txHashes?.length === 1 ? data.meta.txHashes[0] : null @@ -353,13 +363,18 @@ async function processInboxRow(row: MoneriumWebhookEvent, deps: DepositProcessor amountRaw, currency: event.currency, moneriumOrderId: event.orderId, + payerIban: event.payerIban, + payerName: event.payerName, status: targetStatus ?? MoneriumFiatDepositStatus.Pending, txHash: event.txHash }, { transaction } ); } else { - const updates: { status?: MoneriumFiatDepositStatus; txHash?: string } = {}; + const updates: { payerIban?: string; payerName?: string; 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; 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/mint-watcher.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts index c5c793f3a..ed5e2c87b 100644 --- a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts @@ -70,6 +70,8 @@ export function matchMintLogToDeposit(log: MintLogFields, candidates: MatchableD interface ObservedMint { blockHash: string; blockNumber: number; + /** Block timestamp: the promised conversion window counts from here. */ + mintedAt: Date; logIndex: number; to: Address; txHash: string; @@ -120,6 +122,7 @@ async function recordMint( blockNumber: mint.blockNumber, chainId, logIndex: mint.logIndex, + mintedAt: mint.mintedAt, // Forward-only: pending -> minted; a webhook-minted row just gains chain fields. ...(deposit.status === MoneriumFiatDepositStatus.Pending ? { status: MoneriumFiatDepositStatus.Minted } : {}), txHash: mint.txHash @@ -148,6 +151,7 @@ async function recordMint( chainId, currency: "eur", logIndex: mint.logIndex, + mintedAt: mint.mintedAt, moneriumOrderId: syntheticUnattributedOrderId(chainId, mint.txHash, mint.logIndex), status: MoneriumFiatDepositStatus.Minted, txHash: mint.txHash @@ -205,15 +209,23 @@ export async function runMintWatcher(): Promise { }); const touchedAccounts = new Set(); + const blockTimestamps = new Map(); for (const log of logs) { if (log.blockHash === null || log.blockNumber === null || log.transactionHash === null || log.logIndex === null) { continue; // pending log — will be picked up once mined (cursor only advances over mined ranges) } + let mintedAt = blockTimestamps.get(log.blockNumber); + if (!mintedAt) { + const block = await client.getBlock({ blockNumber: log.blockNumber }); + mintedAt = new Date(Number(block.timestamp) * 1000); + blockTimestamps.set(log.blockNumber, mintedAt); + } const accountId = await recordMint( { blockHash: log.blockHash, blockNumber: Number(log.blockNumber), logIndex: log.logIndex, + mintedAt, to: log.args.to as Address, txHash: log.transactionHash, valueRaw: log.args.value as bigint diff --git a/apps/api/src/database/migrations/081-monerium-recovery-automation.ts b/apps/api/src/database/migrations/081-monerium-recovery-automation.ts new file mode 100644 index 000000000..b8d376b2c --- /dev/null +++ b/apps/api/src/database/migrations/081-monerium-recovery-automation.ts @@ -0,0 +1,49 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Automated refund path (docs/architecture-monerium-b2b-onramp.md, "the refund path"): +// a deposit records when its EURe was minted (the promised window counts from there) +// and who paid it (the refund target, from the issue order's counterpart); a recovery +// row drives one deposit from the keeper's `recover` through the reverse swap, the +// float top-up and the Monerium redeem order, one recovery at a time. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_fiat_deposits", "minted_at", { allowNull: true, type: DataTypes.DATE }); + await queryInterface.addColumn("monerium_fiat_deposits", "payer_iban", { allowNull: true, type: DataTypes.STRING(34) }); + await queryInterface.addColumn("monerium_fiat_deposits", "payer_name", { allowNull: true, type: DataTypes.STRING(140) }); + + await queryInterface.createTable("monerium_recoveries", { + attempts: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + deposit_id: { + allowNull: false, + references: { key: "id", model: "monerium_fiat_deposits" }, + type: DataTypes.UUID, + unique: true + }, + error: { allowNull: true, type: DataTypes.TEXT }, + eure_from_swap_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + eure_recovered_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + float_topup_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + float_topup_tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + phase: { + allowNull: false, + type: DataTypes.ENUM("moved", "swapping", "swapped", "topping_up", "topped_up", "redeeming", "redeemed") + }, + redeem_order_id: { allowNull: true, type: DataTypes.STRING(64) }, + refund_amount: { allowNull: true, type: DataTypes.STRING(32) }, + reverse_swap_tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + surplus_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + surplus_tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + usdc_recovered_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) } + }); + await queryInterface.addIndex("monerium_recoveries", ["phase"]); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("monerium_recoveries", {}); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_monerium_recoveries_phase"'); + await queryInterface.removeColumn("monerium_fiat_deposits", "payer_name"); + await queryInterface.removeColumn("monerium_fiat_deposits", "payer_iban"); + await queryInterface.removeColumn("monerium_fiat_deposits", "minted_at"); +} diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 899a0822a..a052bd8fc 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -14,6 +14,7 @@ import MoneriumAccount from "./moneriumAccount.model"; import MoneriumChainCursor from "./moneriumChainCursor.model"; import MoneriumConversionExecution from "./moneriumConversionExecution.model"; import MoneriumFiatDeposit from "./moneriumFiatDeposit.model"; +import MoneriumRecovery from "./moneriumRecovery.model"; import MoneriumWebhookEvent from "./moneriumWebhookEvent.model"; import Notification from "./notification.model"; import NotificationPreference from "./notificationPreference.model"; @@ -40,6 +41,8 @@ MoneriumAccount.hasMany(MoneriumConversionExecution, { as: "conversionExecutions MoneriumConversionExecution.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); MoneriumFiatDeposit.hasMany(MoneriumConversionExecution, { as: "executions", foreignKey: "depositId" }); MoneriumConversionExecution.belongsTo(MoneriumFiatDeposit, { as: "deposit", foreignKey: "depositId" }); +MoneriumFiatDeposit.hasOne(MoneriumRecovery, { as: "recovery", foreignKey: "depositId" }); +MoneriumRecovery.belongsTo(MoneriumFiatDeposit, { as: "deposit", foreignKey: "depositId" }); MoneriumAccount.belongsTo(User, { as: "vortexProfile", foreignKey: "vortexProfileId" }); User.hasOne(MoneriumAccount, { as: "moneriumAccount", foreignKey: "vortexProfileId" }); Webhook.hasMany(WebhookDelivery, { as: "deliveries", foreignKey: "webhookId" }); @@ -144,6 +147,7 @@ const models = { MoneriumChainCursor, MoneriumConversionExecution, MoneriumFiatDeposit, + MoneriumRecovery, MoneriumWebhookEvent, Notification, NotificationPreference, diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts index 294df8786..345f02caa 100644 --- a/apps/api/src/models/moneriumFiatDeposit.model.ts +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -39,6 +39,11 @@ export interface MoneriumFiatDepositAttributes { logIndex: number | null; blockHash: string | null; blockNumber: number | null; + /** Timestamp of the mint block: the promised conversion window counts from here. */ + mintedAt: Date | null; + /** The payer's bank account and name from the issue order's counterpart: the refund target. */ + payerIban: string | null; + payerName: string | null; receivedEventAt: Date | null; convertedEventAt: Date | null; createdAt: Date; @@ -54,6 +59,9 @@ type MoneriumFiatDepositCreationAttributes = Optional< | "logIndex" | "blockHash" | "blockNumber" + | "mintedAt" + | "payerIban" + | "payerName" | "receivedEventAt" | "convertedEventAt" | "createdAt" @@ -75,6 +83,9 @@ class MoneriumFiatDeposit declare logIndex: number | null; declare blockHash: string | null; declare blockNumber: number | null; + declare mintedAt: Date | null; + declare payerIban: string | null; + declare payerName: string | null; declare receivedEventAt: Date | null; declare convertedEventAt: Date | null; declare createdAt: Date; @@ -136,12 +147,27 @@ MoneriumFiatDeposit.init( field: "log_index", type: DataTypes.INTEGER }, + mintedAt: { + allowNull: true, + field: "minted_at", + type: DataTypes.DATE + }, moneriumOrderId: { allowNull: false, field: "monerium_order_id", type: DataTypes.STRING(64), unique: true }, + payerIban: { + allowNull: true, + field: "payer_iban", + type: DataTypes.STRING(34) + }, + payerName: { + allowNull: true, + field: "payer_name", + type: DataTypes.STRING(140) + }, receivedEventAt: { allowNull: true, field: "received_event_at", diff --git a/apps/api/src/models/moneriumRecovery.model.ts b/apps/api/src/models/moneriumRecovery.model.ts new file mode 100644 index 000000000..f74152f78 --- /dev/null +++ b/apps/api/src/models/moneriumRecovery.model.ts @@ -0,0 +1,121 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +/** + * Where a refund stands (docs/architecture-monerium-b2b-onramp.md, "the refund path"). + * A failure keeps the phase it failed in and marks the deposit `recovery_failed`; an + * operator retry (deposit back to `recovering`) resumes from that phase. + */ +export enum MoneriumRecoveryPhase { + /** The keeper's `recover` is confirmed: the payment sits on the recovery wallet. */ + Moved = "moved", + /** The reverse swap (USDC -> EURe) was sent. */ + Swapping = "swapping", + /** The recovery wallet holds only EURe for this payment. */ + Swapped = "swapped", + /** The float top-up (or the surplus sweep) was sent. */ + ToppingUp = "topping_up", + /** The recovery wallet holds exactly the refund amount. */ + ToppedUp = "topped_up", + /** The Monerium redeem order was placed. */ + Redeeming = "redeeming", + /** Monerium processed the redeem order: the payer was refunded. Terminal. */ + Redeemed = "redeemed" +} + +// One row per refunded deposit; at most one row is active (not redeemed) at a time, +// because every step reasons about the dedicated recovery wallet's balances. +export interface MoneriumRecoveryAttributes { + id: string; + depositId: string; + phase: MoneriumRecoveryPhase; + /** Moved off the clone by `recover` (18 / 6 decimals). */ + eureRecoveredRaw: string; + usdcRecoveredRaw: string; + reverseSwapTxHash: string | null; + /** EURe the reverse swap produced (18 decimals). */ + eureFromSwapRaw: string | null; + floatTopupTxHash: string | null; + /** EURe the float paid to reach the refund amount: the refund's subsidy (18 decimals). */ + floatTopupRaw: string | null; + /** EURe the reverse swap produced beyond the refund amount, swept to the float (18 decimals). */ + surplusRaw: string | null; + surplusTxHash: string | null; + /** The EUR amount redeemed, as Monerium expects it ("1234.56"). */ + refundAmount: string | null; + redeemOrderId: string | null; + attempts: number; + error: string | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumRecoveryCreationAttributes = Optional< + MoneriumRecoveryAttributes, + | "id" + | "reverseSwapTxHash" + | "eureFromSwapRaw" + | "floatTopupTxHash" + | "floatTopupRaw" + | "surplusRaw" + | "surplusTxHash" + | "refundAmount" + | "redeemOrderId" + | "attempts" + | "error" + | "createdAt" + | "updatedAt" +>; + +class MoneriumRecovery + extends Model + implements MoneriumRecoveryAttributes +{ + declare id: string; + declare depositId: string; + declare phase: MoneriumRecoveryPhase; + declare eureRecoveredRaw: string; + declare usdcRecoveredRaw: string; + declare reverseSwapTxHash: string | null; + declare eureFromSwapRaw: string | null; + declare floatTopupTxHash: string | null; + declare floatTopupRaw: string | null; + declare surplusRaw: string | null; + declare surplusTxHash: string | null; + declare refundAmount: string | null; + declare redeemOrderId: string | null; + declare attempts: number; + declare error: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumRecovery.init( + { + attempts: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + depositId: { allowNull: false, field: "deposit_id", type: DataTypes.UUID, unique: true }, + error: { allowNull: true, type: DataTypes.TEXT }, + eureFromSwapRaw: { allowNull: true, field: "eure_from_swap_raw", type: DataTypes.DECIMAL(38, 0) }, + eureRecoveredRaw: { allowNull: false, field: "eure_recovered_raw", type: DataTypes.DECIMAL(38, 0) }, + floatTopupRaw: { allowNull: true, field: "float_topup_raw", type: DataTypes.DECIMAL(38, 0) }, + floatTopupTxHash: { allowNull: true, field: "float_topup_tx_hash", type: DataTypes.STRING(66) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + phase: { allowNull: false, type: DataTypes.ENUM(...Object.values(MoneriumRecoveryPhase)) }, + redeemOrderId: { allowNull: true, field: "redeem_order_id", type: DataTypes.STRING(64) }, + refundAmount: { allowNull: true, field: "refund_amount", type: DataTypes.STRING(32) }, + reverseSwapTxHash: { allowNull: true, field: "reverse_swap_tx_hash", type: DataTypes.STRING(66) }, + surplusRaw: { allowNull: true, field: "surplus_raw", type: DataTypes.DECIMAL(38, 0) }, + surplusTxHash: { allowNull: true, field: "surplus_tx_hash", type: DataTypes.STRING(66) }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE }, + usdcRecoveredRaw: { allowNull: false, field: "usdc_recovered_raw", type: DataTypes.DECIMAL(38, 0) } + }, + { + indexes: [{ fields: ["phase"] }], + modelName: "MoneriumRecovery", + sequelize, + tableName: "monerium_recoveries" + } +); + +export default MoneriumRecovery; From 96431195dcffddd2e9b8143a8d75c2c822743c5c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 22:35:19 +0200 Subject: [PATCH 33/44] feat(api): automate the refund of deposits that miss the promised window MONERIUM_B2B_AUTO_RECOVERY=alert reports deposits past the deadline, auto marks them and drives the refund: once the keeper's recover is confirmed, one recovery at a time swaps the USDC back on the reversed whitelisted route, tops the dedicated recovery wallet up from the EURe float to the exact issue amount (or sweeps a surplus back), places the Monerium redeem order to the payer's IBAN with a memo that makes placement exactly-once, and marks the deposit refunded when Monerium processes it. Every step re-derives its work from the wallet's balances so a lost hash never repeats a send; the executor refuses a second recover while a refund is in flight. Large amounts, a missing payer, rejected orders and exhausted retries park the deposit for the operator with the phase preserved. A refund monitor watches the queue and the float. --- .../src/api/services/monerium-b2b/chain.ts | 79 ++- .../monerium-b2b/conversion-executor.test.ts | 10 +- .../monerium-b2b/conversion-executor.ts | 29 +- .../services/monerium-b2b/monitoring.test.ts | 14 + .../api/services/monerium-b2b/monitoring.ts | 64 +- .../services/monerium-b2b/recovery.test.ts | 461 +++++++++++++ .../src/api/services/monerium-b2b/recovery.ts | 620 ++++++++++++++++++ .../src/api/workers/monerium-b2b.worker.ts | 12 + apps/api/src/config/vars.ts | 33 +- 9 files changed, 1313 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/api/services/monerium-b2b/recovery.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/recovery.ts diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index e7f4bd50c..5eac177db 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -69,9 +69,44 @@ export const erc20Abi = [ outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" + }, + { + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" } + ], + name: "allowance", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" } + ], + name: "approve", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" + }, + { + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" } + ], + name: "transfer", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" } ] as const; +/** Uniswap V3 SwapRouter02 `exactInput`, the same call the forwarder makes — used by the refund's reverse swap. */ +export const swapRouter02Abi = parseAbi([ + "function exactInput((bytes path, address recipient, uint256 amountIn, uint256 amountOutMinimum) params) payable returns (uint256 amountOut)" +]); + export const forwarderAbi = [ { inputs: [], name: "poke", outputs: [], stateMutability: "nonpayable", type: "function" }, { @@ -115,6 +150,7 @@ export const forwarderAbi = [ { inputs: [], name: "RECOVERY_WALLET", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, { inputs: [], name: "guardianPaused", outputs: [{ name: "", type: "bool" }], stateMutability: "view", type: "function" }, { inputs: [], name: "EURE", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "ROUTER", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, { inputs: [], name: "FACTORY", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, { inputs: [], name: "USDC", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, { inputs: [], name: "ORACLE", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, @@ -222,6 +258,8 @@ export type KeeperWalletClient = WalletClient; let publicClientCache: PublicClient | null = null; let keeperClientCache: KeeperWalletClient | null = null; let guardianClientCache: KeeperWalletClient | null = null; +let recoveryClientCache: KeeperWalletClient | null = null; +let floatClientCache: KeeperWalletClient | null = null; let privateRpcWarned = false; export function isKeeperChainConfigured(): boolean { @@ -298,6 +336,38 @@ export function getGuardianWalletClient(): KeeperWalletClient | null { return guardianClientCache; } +/** + * Recovery-wallet client (MONERIUM_B2B_RECOVERY_PRIVATE_KEY): the immutable + * RECOVERY_WALLET's key, which signs the refund's reverse swap and the Monerium redeem + * message. Null when unset — the refund path then runs manually per the runbook. + */ +export function getRecoveryWalletClient(): KeeperWalletClient | null { + if (!config.moneriumB2b.recoveryPrivateKey) { + return null; + } + if (!recoveryClientCache) { + recoveryClientCache = createWalletClient({ + account: privateKeyToAccount(config.moneriumB2b.recoveryPrivateKey as Hex), + transport: http(submissionRpcUrl()) + }); + } + return recoveryClientCache; +} + +/** Float-wallet client (MONERIUM_B2B_FLOAT_PRIVATE_KEY): the EURe float that tops a refund up to the exact amount. */ +export function getFloatWalletClient(): KeeperWalletClient | null { + if (!config.moneriumB2b.floatPrivateKey) { + return null; + } + if (!floatClientCache) { + floatClientCache = createWalletClient({ + account: privateKeyToAccount(config.moneriumB2b.floatPrivateKey as Hex), + transport: http(submissionRpcUrl()) + }); + } + return floatClientCache; +} + // ------------------------------------------------------------------ cached chain lookups let chainIdCache: number | null = null; @@ -312,6 +382,7 @@ export async function getChainId(): Promise { export interface ForwarderImmutables { eure: Address; factory: Address; + router: Address; maxFeePpm: number; maxReferenceDeviationBps: number; oracle: Address; @@ -346,6 +417,7 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise | "MAX_REFERENCE_DEVIATION_BPS" | "RECOVERY_DELAY" | "RECOVERY_WALLET" + | "ROUTER" >( functionName: T ) => client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName }); @@ -359,7 +431,8 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise maxFeePpm, maxReferenceDeviationBps, recoveryDelay, - recoveryWallet + recoveryWallet, + router ] = await Promise.all([ read("EURE"), read("FACTORY"), @@ -370,7 +443,8 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise read("MAX_FEE_PPM"), read("MAX_REFERENCE_DEVIATION_BPS"), read("RECOVERY_DELAY"), - read("RECOVERY_WALLET") + read("RECOVERY_WALLET"), + read("ROUTER") ]); const immutables: ForwarderImmutables = { eure, @@ -381,6 +455,7 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise oracleDecimals: Number(oracleDecimals), recoveryDelaySeconds: Number(recoveryDelay), recoveryWallet, + router, slippageBps: Number(slippageBps), usdc }; 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 f77f94c74..05af7c3ae 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 @@ -90,7 +90,8 @@ describe("planAction", () => { minSwapAmount: 25n * EUR, nowMs: 1_000_000 + 3 * 60 * 60 * 1000, // three hours after the batch opened perSwapCap: 10_000n * EUR, - recoveryDelaySeconds: 2 * 60 * 60 + recoveryDelaySeconds: 2 * 60 * 60, + recoveryInFlight: false }; const deposit = (id: string, status: MoneriumFiatDepositStatus, amount: bigint) => ({ amountRaw: amount.toString(), id, status }) as MoneriumFiatDeposit; @@ -128,6 +129,12 @@ describe("planAction", () => { expect(planAction([stuck, young], { ...base, batchOpenedAtSec: 0n })).toMatchObject({ kind: "swap" }); }); + it("never sends a second recover while a refund is still on the recovery wallet", () => { + const stuck = withSwaps(deposit("old", MoneriumFiatDepositStatus.Recovering, 500n * EUR), []); + const young = withSwaps(deposit("young", MoneriumFiatDepositStatus.Minted, 500n * EUR), []); + expect(planAction([stuck, young], { ...base, recoveryInFlight: true })).toMatchObject({ kind: "swap" }); + }); + it("still recovers on an account that may not convert", () => { const stuck = withSwaps(deposit("old", MoneriumFiatDepositStatus.Recovering, 500n * EUR), []); const young = withSwaps(deposit("young", MoneriumFiatDepositStatus.Minted, 500n * EUR), []); @@ -481,6 +488,7 @@ describe("pricePlannedSwap", () => { oracleDecimals: 8, recoveryDelaySeconds: 7_200, recoveryWallet: "0x7777777777777777777777777777777777777777", + router: "0x8888888888888888888888888888888888888888", slippageBps: 60, usdc: "0x6666666666666666666666666666666666666666" }; 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 3cfe4ef24..cce8d314e 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -28,6 +28,7 @@ import { } from "./chain"; import { isForwardTransition, withForwarderLock } from "./deposit-processor"; import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; +import { activeRecoveryExists } from "./recovery"; import { fetchCoinbaseReference, isWithinReferenceBand, ReferenceQuote } from "./reference-rate"; /** @@ -778,13 +779,16 @@ export interface ActionPlanningInput { nowMs: number; perSwapCap: bigint; recoveryDelaySeconds: number; + /** A recovered payment is still on the recovery wallet: no second `recover` may land there. */ + recoveryInFlight: boolean; } /** * What the keeper should do next for an account, given its settling deposits (oldest * mint first) and their confirmed chunks. A deposit marked `recovering` goes first, once - * the clone's batch has been open for RECOVERY_DELAY (else it waits without blocking - * younger deposits); then the oldest convertible deposit is forwarded when all of its + * the clone's batch has been open for RECOVERY_DELAY and no other refund is in flight + * (the recovery wallet takes one payment at a time); else it waits without blocking + * younger deposits. Then the oldest convertible deposit is forwarded when all of its * EURe is converted, or swapped in its next chunk. */ export function planAction( @@ -802,6 +806,9 @@ export function planAction( if (input.batchOpenedAtSec === 0n || input.nowMs < recoveryEligibleAtMs) { continue; // the contract would revert DelayNotElapsed; younger deposits keep converting } + if (input.recoveryInFlight) { + continue; // the previous refund must leave the recovery wallet first + } return { deposit, eureRaw: state.remainingEureRaw, kind: "recover", usdcRaw: state.usdcNetRaw }; } if (!input.convertible) { @@ -886,11 +893,24 @@ export async function runConversionExecutor(accountId: string): Promise { // is currently possible. const pokeNeeded = batchOpenedAt === 0n && (eureBalance >= minSwapFloor || usdcBalance > 0n); + const recoveryInFlight = await activeRecoveryExists(); const planned = await withForwarderLock(account.forwarderAddress, async transaction => { const deposits = await settlingDeposits(account.id, transaction); const withState = []; for (const deposit of deposits) { - withState.push({ deposit, state: await loadSettlementState(deposit, transaction) }); + // A deposit whose `recover` already confirmed is the orchestrator's; it never + // recovers twice. + const state = await loadSettlementState(deposit, transaction); + const recovered = await MoneriumConversionExecution.count({ + transaction, + where: { + depositId: deposit.id, + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed + } + }); + if (recovered > 0) continue; + withState.push({ deposit, state }); } return planAction(withState, { batchOpenedAtSec: batchOpenedAt, @@ -898,7 +918,8 @@ export async function runConversionExecutor(accountId: string): Promise { minSwapAmount, nowMs: Date.now(), perSwapCap, - recoveryDelaySeconds: immutables.recoveryDelaySeconds + recoveryDelaySeconds: immutables.recoveryDelaySeconds, + recoveryInFlight }); }); if (planned.kind === "none") { 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 43645111e..a3a5b9f44 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { classifyExecutableDepth, + classifyRefundQueue, classifyStranding, classifyVaultRunway, computeQuoteImpactBps, @@ -99,6 +100,19 @@ describe("classifyStranding", () => { }); }); +describe("classifyRefundQueue", () => { + const now = 1_800_000_000_000; + it("is ok without an active refund or with a young one, warns after an hour, errors after four", () => { + expect(classifyRefundQueue(null, false, now)).toBe("ok"); + expect(classifyRefundQueue(new Date(now - 10 * 60_000), false, now)).toBe("ok"); + expect(classifyRefundQueue(new Date(now - 61 * 60_000), false, now)).toBe("warn"); + expect(classifyRefundQueue(new Date(now - 5 * 60 * 60_000), false, now)).toBe("error"); + }); + it("always errors on a failed refund", () => { + expect(classifyRefundQueue(new Date(now - 60_000), true, now)).toBe("error"); + }); +}); + describe("classifyVaultRunway", () => { const healthy = { balance: 1_000n * USDC, dailyBudget: 200n * USDC, paused: false, spentToday: 0n }; diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 47e3a4374..f0691e32f 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -1,14 +1,16 @@ import { Op } from "sequelize"; -import { Address, Hex, parseAbi } from "viem"; +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 MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; import { chainlinkAbi, erc20Abi, factoryAbi, forwarderAbi, getChainId, + getFloatWalletClient, getForwarderImmutables, getPublicClient, moneriumChainForChainId, @@ -49,6 +51,8 @@ import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProduc * 6. Reference-venue monitor: the Coinbase product the reference VWAP reads. A delisted * or halted product keeps answering the candles endpoint with stale data, so every * keeper swap would defer silently; its status is probed instead of assumed. + * 7. Refund monitor (automated refunds only): the active recovery must not linger, and + * the EURe float that tops refunds up must not run dry. * * None of these monitors hold keys or send transactions; they are detection-only. */ @@ -554,6 +558,61 @@ export async function runSubsidyVaultMonitor(): Promise { } } +/** An active refund older than this warns; older than four times it errors. */ +export const RECOVERY_LINGER_MS = 60 * 60 * 1000; +/** The EURe float warns below this balance (18 decimals). */ +export const FLOAT_WARN_EURE = 1_000n * 10n ** 18n; + +export type RefundQueueSeverity = "error" | "ok" | "warn"; + +/** Severity of the oldest active refund by its age; a failed one is always an error. */ +export function classifyRefundQueue(activeCreatedAt: Date | null, failed: boolean, nowMs: number): RefundQueueSeverity { + if (failed) return "error"; + if (!activeCreatedAt) return "ok"; + const age = nowMs - activeCreatedAt.getTime(); + if (age >= 4 * RECOVERY_LINGER_MS) return "error"; + if (age >= RECOVERY_LINGER_MS) return "warn"; + return "ok"; +} + +/** + * Refund monitor: the one active recovery and the float. Runs only with automated + * refunds configured; the manual procedure has the runbook. + */ +export async function runRefundMonitor(now: number = Date.now()): Promise { + const active = await MoneriumRecovery.findOne({ + order: [["created_at", "ASC"]], + where: { phase: { [Op.ne]: MoneriumRecoveryPhase.Redeemed } } + }); + const severity = classifyRefundQueue(active?.createdAt ?? null, Boolean(active?.error), now); + if (active) { + const message = + `monerium-b2b: refund of deposit ${active.depositId} in phase ${active.phase} since ${active.createdAt.toISOString()}` + + `${active.error ? ` — FAILED: ${active.error}` : ""}`; + if (severity === "error") logger.error(`${message} (runbook §2.7)`); + else if (severity === "warn") logger.warn(message); + } + + const float = getFloatWalletClient(); + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (!float || accounts.length === 0) return; + const { eure } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const balance = await getPublicClient().readContract({ + abi: erc20Abi, + address: eure, + args: [float.account.address], + functionName: "balanceOf" + }); + const detail = `float ${float.account.address} holds ${formatUnits(balance, 18)} EURe`; + if (balance === 0n) { + logger.error(`monerium-b2b: FLOAT EMPTY — every refund top-up waits; ${detail} (runbook §2.7)`); + } else if (balance < FLOAT_WARN_EURE) { + logger.warn(`monerium-b2b: float running low; ${detail}`); + } else { + logger.info(`monerium-b2b: ${detail}`); + } +} + /** Reference-venue monitor: a product that is not online makes every keeper swap defer. */ export async function runReferenceVenueMonitor(): Promise { const product = await fetchCoinbaseProductStatus(); @@ -597,6 +656,9 @@ export async function runMonitoringPass(now: number = Date.now()): Promise await guarded("stranded-balance monitor", () => runStrandedBalanceMonitor(now)); await guarded("subsidy-vault monitor", runSubsidyVaultMonitor); await guarded("config reconciliation", runConfigReconciliation); + if (config.moneriumB2b.autoRecovery === "auto") { + await guarded("refund monitor", () => runRefundMonitor(now)); + } } if (isWhitelabelConfigured()) { await guarded("association monitor", runAssociationMonitor); diff --git a/apps/api/src/api/services/monerium-b2b/recovery.test.ts b/apps/api/src/api/services/monerium-b2b/recovery.test.ts new file mode 100644 index 000000000..afa7f60d6 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/recovery.test.ts @@ -0,0 +1,461 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type { MoneriumRedeemOrderRequest } from "@vortexfi/shared"; +import { Address, Hex } from "viem"; +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 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 { + activeRecoveryExists, + driveRecovery, + isPastDeadline, + RecoveryDeps, + refundEurAmount, + refundMemo, + refundNeed, + reversePath, + reverseSwapMinOut, + runRecoveryDeadlines, + runRecoveryOrchestrator +} from "./recovery"; + +const EUR = 10n ** 18n; +const USDC = 10n ** 6n; +const RECOVERY = "0x7777777777777777777777777777777777777777" as Address; +const FLOAT = "0x8888888888888888888888888888888888888888" as Address; +const EURE = "0x1111111111111111111111111111111111111111"; +const EURC = "0x2222222222222222222222222222222222222222"; +const USDC_TOKEN = "0x3333333333333333333333333333333333333333"; + +describe("reversePath", () => { + it("reverses a two-hop packed path so the same pools run the other way", () => { + const forward = `0x${EURE.slice(2)}0001f4${EURC.slice(2)}0001f4${USDC_TOKEN.slice(2)}` as Hex; + expect(reversePath(forward)).toBe(`0x${USDC_TOKEN.slice(2)}0001f4${EURC.slice(2)}0001f4${EURE.slice(2)}`); + }); + + it("reverses a one-hop path and rejects malformed lengths", () => { + const forward = `0x${EURE.slice(2)}000bb8${USDC_TOKEN.slice(2)}` as Hex; + expect(reversePath(forward)).toBe(`0x${USDC_TOKEN.slice(2)}000bb8${EURE.slice(2)}`); + expect(() => reversePath("0xabcd")).toThrow("packed path length"); + }); +}); + +describe("refund arithmetic", () => { + it("renders the issue amount to the cent and refuses sub-cent deposits", () => { + expect(refundEurAmount((100n * EUR).toString())).toBe("100.00"); + expect(refundEurAmount("50000000000000000")).toBe("0.05"); + expect(refundEurAmount("1234570000000000000000")).toBe("1234.57"); + expect(() => refundEurAmount("1234567000000000000000")).toThrow("whole number of cents"); + }); + + it("splits the difference between wallet balance and refund into top-up or surplus", () => { + expect(refundNeed(100n * EUR, 98n * EUR)).toEqual({ surplus: 0n, topUp: 2n * EUR }); + expect(refundNeed(100n * EUR, 103n * EUR)).toEqual({ surplus: 3n * EUR, topUp: 0n }); + expect(refundNeed(100n * EUR, 100n * EUR)).toEqual({ surplus: 0n, topUp: 0n }); + }); + + it("floors the reverse swap at the Chainlink rate less the slippage tolerance", () => { + // 1140 USDC at 1.14 is 1000 EURe fair; 60 bps below is 994 EURe. + expect(reverseSwapMinOut(1_140n * USDC, 114_000_000n, 8, 60)).toBe(994n * EUR); + }); + + it("counts the promised window from the mint, falling back to the row's creation", () => { + const now = 1_800_000_000_000; + const twoHours = 2 * 60 * 60 * 1000; + const old = new Date(now - twoHours - 1); + const fresh = new Date(now - 60_000); + expect(isPastDeadline({ createdAt: fresh, mintedAt: old }, twoHours, now)).toBe(true); + expect(isPastDeadline({ createdAt: old, mintedAt: fresh }, twoHours, now)).toBe(false); + expect(isPastDeadline({ createdAt: old, mintedAt: null }, twoHours, now)).toBe(true); + }); +}); + +// ------------------------------------------------------------------ state machine with fakes + +interface Ledger { + eure: Map; + usdc: Map; +} + +function fakeDeps( + ledger: Ledger, + overrides: Partial & { + orders?: Array<{ id: string; memo: string; rejectedReason?: string; state: string }>; + receipts?: Record; + swapOut?: bigint; + } = {} +): RecoveryDeps & { calls: string[]; orders: Array<{ id: string; memo: string; rejectedReason?: string; state: string }> } { + const calls: string[] = []; + const orders = overrides.orders ?? []; + const receipts = overrides.receipts ?? {}; + const get = (map: Map, address: string) => map.get(address.toLowerCase()) ?? 0n; + const add = (map: Map, address: string, delta: bigint) => + map.set(address.toLowerCase(), get(map, address) + delta); + const deps: RecoveryDeps = { + createRedeemOrder: async request => { + calls.push(`redeem:${request.amount}:${request.counterpart.identifier.iban}:${request.memo}`); + orders.push({ id: "order-1", memo: request.memo as string, state: "placed" }); + return { id: "order-1" }; + }, + eureBalance: async address => get(ledger.eure, address), + floatWallet: FLOAT, + getOrder: async id => { + const order = orders.find(entry => entry.id === id); + if (!order) throw new Error("unknown order"); + return { rejectedReason: order.rejectedReason, state: order.state }; + }, + listOrdersByMemo: async (_address, memo) => orders.filter(order => order.memo === memo), + moneriumChain: async () => "ethereum", + now: () => new Date("2026-09-17T12:00:00Z"), + oracle: async () => ({ decimals: 8, raw: 114_000_000n, slippageBps: 60 }), + recoveryWallet: RECOVERY, + reverseRoute: async () => "0xpath" as Hex, + sendEure: async (from, to, amount) => { + calls.push(`eure:${from}->${to.toLowerCase()}:${amount}`); + const source = from === "float" ? FLOAT : RECOVERY; + add(ledger.eure, source, -amount); + add(ledger.eure, to, amount); + return `0x${from}tx` as Hex; + }, + sendReverseSwap: async (amountIn, minOut) => { + calls.push(`swap:${amountIn}:${minOut}`); + add(ledger.usdc, RECOVERY, -amountIn); + add(ledger.eure, RECOVERY, overrides.swapOut ?? (amountIn * EUR) / (114n * USDC / 100n)); + return "0xswaptx" as Hex; + }, + setDepositStatus: async (deposit, status) => { + calls.push(`deposit:${status}`); + (deposit as { status: MoneriumFiatDepositStatus }).status = status; + }, + signMessage: async message => { + calls.push(`sign:${message}`); + return "0xsig"; + }, + usdcBalance: async address => get(ledger.usdc, address), + waitReceipt: async hash => receipts[hash] ?? "success", + ...overrides + }; + return { ...deps, calls, orders }; +} + +function recoveryRow(fields: Partial = {}): MoneriumRecovery { + const row = { + attempts: 0, + error: null, + eureFromSwapRaw: null, + eureRecoveredRaw: (40n * EUR).toString(), + floatTopupRaw: null, + floatTopupTxHash: null, + phase: MoneriumRecoveryPhase.Moved, + redeemOrderId: null, + refundAmount: null, + reverseSwapTxHash: null, + surplusRaw: null, + surplusTxHash: null, + usdcRecoveredRaw: (68n * USDC).toString(), + ...fields, + async update(values: Record) { + Object.assign(row, values); + } + }; + return row as unknown as MoneriumRecovery; +} + +function depositRow(fields: Partial = {}): MoneriumFiatDeposit { + return { + amountRaw: (100n * EUR).toString(), + id: "deposit-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status: MoneriumFiatDepositStatus.Recovering, + ...fields + } as unknown as MoneriumFiatDeposit; +} + +describe("driveRecovery", () => { + it("walks a chunked payment from the recovery wallet to a processed redeem order", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 40n * EUR], [FLOAT, 1_000n * EUR]]), usdc: new Map([[RECOVERY, 68n * USDC]]) }; + const deps = fakeDeps(ledger, { swapOut: 59n * EUR }); // 68 USDC back to 59 EURe: 1 EURe of slippage + const recovery = recoveryRow(); + const deposit = depositRow(); + + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Swapping); + expect(deps.calls[0]).toBe(`swap:${68n * USDC}:${reverseSwapMinOut(68n * USDC, 114_000_000n, 8, 60)}`); + + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ eureFromSwapRaw: (59n * EUR).toString(), phase: MoneriumRecoveryPhase.Swapped }); + + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ floatTopupRaw: (1n * EUR).toString(), phase: MoneriumRecoveryPhase.ToppingUp }); + expect(deps.calls.at(-1)).toBe(`eure:float->${RECOVERY.toLowerCase()}:${1n * EUR}`); + + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppedUp); + + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: "order-1", refundAmount: "100.00" }); + expect(deps.calls).toContain("sign:Send EUR 100.00 to DE89370400440532013000 at 2026-09-17T12:00Z"); + expect(deps.calls).toContain(`redeem:100.00:DE89370400440532013000:${refundMemo("deposit-1")}`); + + await driveRecovery(recovery, deposit, deps); // still placed + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeeming); + deps.orders[0].state = "processed"; + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeemed); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.Refunded); + }); + + it("sweeps a surplus to the float and skips the swap when nothing was converted", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 103n * EUR]]), usdc: new Map() }; + const deps = fakeDeps(ledger); + const recovery = recoveryRow({ eureRecoveredRaw: (103n * EUR).toString(), usdcRecoveredRaw: "0" }); + const deposit = depositRow(); + + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ eureFromSwapRaw: "0", phase: MoneriumRecoveryPhase.Swapped }); + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ phase: MoneriumRecoveryPhase.ToppingUp, surplusRaw: (3n * EUR).toString() }); + expect(deps.calls.at(-1)).toBe(`eure:recovery->${FLOAT.toLowerCase()}:${3n * EUR}`); + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppedUp); + }); + + it("re-derives a lost swap from balances instead of swapping twice", async () => { + // The swap landed (USDC gone, EURe up) but the hash never persisted. + const ledger: Ledger = { eure: new Map([[RECOVERY, 99n * EUR]]), usdc: new Map([[RECOVERY, 0n]]) }; + const deps = fakeDeps(ledger); + const recovery = recoveryRow({ phase: MoneriumRecoveryPhase.Swapping, reverseSwapTxHash: null }); + await driveRecovery(recovery, depositRow(), deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Moved); + await driveRecovery(recovery, depositRow(), deps); + expect(recovery).toMatchObject({ eureFromSwapRaw: (59n * EUR).toString(), phase: MoneriumRecoveryPhase.Swapped }); + expect(deps.calls.filter(call => call.startsWith("swap:"))).toHaveLength(0); + }); + + it("waits, without failing, while the float cannot cover the top-up", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 99n * EUR], [FLOAT, 0n]]), usdc: new Map() }; + const deps = fakeDeps(ledger); + const recovery = recoveryRow({ phase: MoneriumRecoveryPhase.Swapped }); + const deposit = depositRow(); + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Swapped); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.Recovering); + expect(deps.calls).toEqual([]); + }); + + it("adopts an already placed order by memo instead of placing a second one", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 100n * EUR]]), usdc: new Map() }; + const deps = fakeDeps(ledger, { orders: [{ id: "order-9", memo: refundMemo("deposit-1"), state: "pending" }] }); + const recovery = recoveryRow({ phase: MoneriumRecoveryPhase.ToppedUp }); + await driveRecovery(recovery, depositRow(), deps); + expect(recovery).toMatchObject({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: "order-9" }); + expect(deps.calls.some(call => call.startsWith("redeem:"))).toBe(false); + }); + + it("parks the deposit as recovery_failed when the refund cannot be automated", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 100n * EUR]]), usdc: new Map() }; + const noPayer = depositRow({ payerIban: null }); + const recovery = recoveryRow({ phase: MoneriumRecoveryPhase.ToppedUp }); + await driveRecovery(recovery, noPayer, fakeDeps(ledger)); + expect(noPayer.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + expect(recovery).toMatchObject({ error: expect.stringContaining("no payer IBAN"), phase: MoneriumRecoveryPhase.ToppedUp }); + + const large = depositRow({ amountRaw: (20_000n * EUR).toString() }); + const bigRecovery = recoveryRow({ phase: MoneriumRecoveryPhase.ToppedUp }); + await driveRecovery(bigRecovery, large, fakeDeps({ eure: new Map([[RECOVERY, 20_000n * EUR]]), usdc: new Map() })); + expect(large.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + expect(bigRecovery.error).toContain("supporting document"); + + const rejected = fakeDeps(ledger, { orders: [{ id: "o", memo: refundMemo("deposit-1"), rejectedReason: "compliance", state: "rejected" }] }); + const redeeming = recoveryRow({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: "o" }); + const deposit = depositRow(); + await driveRecovery(redeeming, deposit, rejected); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + expect(redeeming.error).toContain("compliance"); + }); + + it("retries a reverted reverse swap and fails after the fifth attempt", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 40n * EUR]]), usdc: new Map([[RECOVERY, 68n * USDC]]) }; + const deps = fakeDeps(ledger, { + sendReverseSwap: async () => { + throw new Error("STF"); + } + }); + const recovery = recoveryRow(); + const deposit = depositRow(); + for (let attempt = 1; attempt <= 4; attempt++) { + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ attempts: attempt, phase: MoneriumRecoveryPhase.Moved }); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.Recovering); + } + await driveRecovery(recovery, deposit, deps); + expect(recovery.attempts).toBe(5); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + }); +}); + +// ------------------------------------------------------------------ deadlines + orchestrator (database) + +describe("refund deadlines and orchestration", () => { + const FORWARDER = "0x4444444444444444444444444444444444444444"; + const DESTINATION = "0x5555555555555555555555555555555555555555"; + let originalRpcUrl: string | undefined; + let originalMode: typeof config.moneriumB2b.autoRecovery; + + beforeAll(async () => { + originalRpcUrl = config.moneriumB2b.rpcUrl; + originalMode = config.moneriumB2b.autoRecovery; + config.moneriumB2b.rpcUrl = undefined; + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = originalRpcUrl; + config.moneriumB2b.autoRecovery = originalMode; + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.moneriumB2b.autoRecovery = "auto"; + }); + + async function mappedAccount() { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + return provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: DESTINATION, + externalSubjectId: "client-1", + forwarderAddress: FORWARDER, + managerProfileId: manager.id, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + } + + function minted(accountId: string, orderId: string, mintedAt: Date, status = MoneriumFiatDepositStatus.Minted) { + return MoneriumFiatDeposit.create({ + accountId, + amountRaw: (100n * EUR).toString(), + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + mintedAt, + moneriumOrderId: orderId, + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status, + txHash: `0x${orderId}` + }); + } + + it("marks deposits past the promised window in auto mode and only reports them in alert mode", async () => { + const { accountId } = await mappedAccount(); + const now = Date.now(); + const late = await minted(accountId, "late", new Date(now - 121 * 60_000)); + const fresh = await minted(accountId, "fresh", new Date(now - 10 * 60_000)); + + config.moneriumB2b.autoRecovery = "alert"; + await runRecoveryDeadlines(now); + await late.reload(); + expect(late.status).toBe(MoneriumFiatDepositStatus.Minted); + + config.moneriumB2b.autoRecovery = "auto"; + await runRecoveryDeadlines(now); + await late.reload(); + await fresh.reload(); + expect(late.status).toBe(MoneriumFiatDepositStatus.Recovering); + expect(fresh.status).toBe(MoneriumFiatDepositStatus.Minted); + }); + + it("opens one recovery per confirmed recover, drives it to the refund, and blocks a second recover meanwhile", async () => { + const { accountId } = await mappedAccount(); + const deposit = await minted(accountId, "stuck", new Date(), MoneriumFiatDepositStatus.Recovering); + await MoneriumConversionExecution.create({ + accountId, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xrecover", + usdcNetRaw: "0" + }); + expect(await activeRecoveryExists()).toBe(true); + + const ledger: Ledger = { eure: new Map([[RECOVERY, 100n * EUR], [FLOAT, 10n * EUR]]), usdc: new Map() }; + const deps = fakeDeps(ledger, { + setDepositStatus: async (row, status) => { + await row.update({ status }); + } + }); + const depsFor = async () => deps; + + await runRecoveryOrchestrator(depsFor); // opens the row: moved -> swapped (nothing to swap) + const recovery = (await MoneriumRecovery.findOne({ where: { depositId: deposit.id } })) as MoneriumRecovery; + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Swapped); + await runRecoveryOrchestrator(depsFor); // exact balance: topped up + await runRecoveryOrchestrator(depsFor); // order placed + await recovery.reload(); + expect(recovery).toMatchObject({ phase: MoneriumRecoveryPhase.Redeeming, refundAmount: "100.00" }); + expect(await activeRecoveryExists()).toBe(true); + + deps.orders[0].state = "processed"; + await runRecoveryOrchestrator(depsFor); + await recovery.reload(); + await deposit.reload(); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeemed); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.Refunded); + expect(await activeRecoveryExists()).toBe(false); + }); + + it("holds the queue on a failed refund until the operator retries it", async () => { + const { accountId } = await mappedAccount(); + const deposit = await minted(accountId, "stuck", new Date(), MoneriumFiatDepositStatus.Recovering); + await deposit.update({ payerIban: null }); + await MoneriumConversionExecution.create({ + accountId, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xrecover", + usdcNetRaw: "0" + }); + const ledger: Ledger = { eure: new Map([[RECOVERY, 100n * EUR]]), usdc: new Map() }; + const deps = fakeDeps(ledger, { + setDepositStatus: async (row, status) => { + await row.update({ status }); + } + }); + const depsFor = async () => deps; + for (let i = 0; i < 3; i++) await runRecoveryOrchestrator(depsFor); + await deposit.reload(); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + const recovery = (await MoneriumRecovery.findOne({ where: { depositId: deposit.id } })) as MoneriumRecovery; + expect(recovery.error).toContain("payer IBAN"); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppedUp); + expect(await activeRecoveryExists()).toBe(true); + + // Operator fixes the payer and retries: the run resumes from the preserved phase. + await deposit.update({ payerIban: "DE89370400440532013000", status: MoneriumFiatDepositStatus.Recovering }); + await runRecoveryOrchestrator(depsFor); + await recovery.reload(); + expect(recovery.error).toBeNull(); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeeming); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts new file mode 100644 index 000000000..1ef4f8a80 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -0,0 +1,620 @@ +import { buildMoneriumSepaRedemptionMessage, MoneriumApiService, type MoneriumRedeemOrderRequest } from "@vortexfi/shared"; +import { Op, QueryTypes } from "sequelize"; +import { Address, formatUnits, Hex } from "viem"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import MoneriumAccount 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, + erc20Abi, + getChainId, + getFloatWalletClient, + getForwarderImmutables, + getPublicClient, + getRecoveryWalletClient, + KeeperWalletClient, + moneriumChainForChainId, + readEnabledRoutes, + swapRouter02Abi +} from "./chain"; +import { markDepositForRecovery } from "./conversion-executor"; +import { isForwardTransition, withForwarderLock } from "./deposit-processor"; +import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; + +/** + * The refund path (docs/architecture-monerium-b2b-onramp.md, "the refund path"): + * + * 1. `runRecoveryDeadlines` marks a settling deposit `recovering` once its mint is older + * than the promised window (MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES), or only alerts, + * depending on MONERIUM_B2B_AUTO_RECOVERY. The keeper then sends `recover` once the + * clone's batch is RECOVERY_DELAY old (conversion-executor.ts). + * 2. `runRecoveryOrchestrator` drives ONE recovery at a time from the confirmed `recover` + * to the bank refund: reverse-swap the USDC on the dedicated recovery wallet, top the + * wallet up from the EURe float to exactly the refund amount (or sweep a surplus back + * to the float), place the Monerium redeem order to the payer's IBAN, and mark the + * deposit `refunded` when Monerium processed it. + * + * Crash safety rests on the recovery wallet being dedicated and empty between refunds: + * every step re-derives what is still to do from the wallet's balances, so a lost + * transaction hash never repeats a value-moving send (a top-up already on chain makes the + * remaining need zero). One recovery at a time is what keeps those balances meaningful; + * the executor refuses a second `recover` while one is in flight (`activeRecoveryExists`). + * A step that fails beyond its retries parks the deposit in `recovery_failed` with the + * phase preserved; an operator retry (deposit back to `recovering`) resumes there. + */ + +export const REFUND_MEMO_PREFIX = "vortex-refund:"; +/** Monerium requires a supporting document above this amount; such refunds stay manual (rollout G1). */ +export const SUPPORTING_DOCUMENT_THRESHOLD_EUR = 15_000; +const MAX_ATTEMPTS = 5; +const RECEIPT_TIMEOUT_MS = 3 * 60_000; +const EURE_DECIMALS = 18; +const USDC_DECIMALS = 6; +const BPS = 10_000n; + +// ------------------------------------------------------------------ pure helpers + +/** Reverses a packed Uniswap V3 path (token, fee, token[, fee, token]) so the same pools run the other way. */ +export function reversePath(path: Hex): Hex { + const bytes = path.slice(2); + if (bytes.length !== 86 && bytes.length !== 132) { + throw new Error(`unexpected packed path length ${bytes.length / 2}`); + } + const tokens: string[] = []; + const fees: string[] = []; + let offset = 0; + while (offset < bytes.length) { + tokens.push(bytes.slice(offset, offset + 40)); + offset += 40; + if (offset < bytes.length) { + fees.push(bytes.slice(offset, offset + 6)); + offset += 6; + } + } + tokens.reverse(); + fees.reverse(); + let out = "0x"; + tokens.forEach((token, index) => { + out += token; + if (index < fees.length) out += fees[index]; + }); + return out as Hex; +} + +/** + * The EUR amount Monerium expects for the refund: the issue amount to the cent. Monerium + * issues whole cents, so anything finer is a mis-recorded deposit, not a rounding case. + */ +export function refundEurAmount(amountRaw: string): string { + const raw = BigInt(amountRaw); + const cent = 10n ** BigInt(EURE_DECIMALS - 2); + if (raw % cent !== 0n) { + throw new Error(`deposit amount ${amountRaw} is not a whole number of cents`); + } + const cents = raw / cent; + return `${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`; +} + +/** What the float must add (or what the reverse swap left over) for the wallet to hold exactly the refund. */ +export function refundNeed(refundRaw: bigint, walletEureRaw: bigint): { surplus: bigint; topUp: bigint } { + const diff = refundRaw - walletEureRaw; + return diff >= 0n ? { surplus: 0n, topUp: diff } : { surplus: -diff, topUp: 0n }; +} + +/** Least EURe the reverse swap may return for `usdcIn` at the Chainlink EUR/USD rate, less `slippageBps`. */ +export function reverseSwapMinOut(usdcIn: bigint, oracleRaw: bigint, oracleDecimals: number, slippageBps: number): bigint { + const fair = (usdcIn * 10n ** BigInt(EURE_DECIMALS - USDC_DECIMALS + oracleDecimals)) / oracleRaw; + return (fair * (BPS - BigInt(slippageBps))) / BPS; +} + +/** Whether a settling deposit has outlived the promised window, counted from its mint. */ +export function isPastDeadline( + deposit: Pick, + deadlineMs: number, + nowMs: number +): boolean { + const startedAt = deposit.mintedAt ?? deposit.createdAt; + return nowMs - startedAt.getTime() >= deadlineMs; +} + +export function refundMemo(depositId: string): string { + return `${REFUND_MEMO_PREFIX}${depositId}`; +} + +// ------------------------------------------------------------------ dependencies + +export interface RecoveryDeps { + floatWallet: Address; + recoveryWallet: Address; + eureBalance(address: Address): Promise; + usdcBalance(address: Address): Promise; + oracle(): Promise<{ decimals: number; raw: bigint; slippageBps: number }>; + /** Packed USDC -> ... -> EURe path (the first enabled route, reversed). */ + reverseRoute(): Promise; + /** Sends the reverse swap from the recovery wallet; returns the swap tx hash. */ + sendReverseSwap(amountIn: bigint, minOut: bigint, path: Hex): Promise; + sendEure(from: "float" | "recovery", to: Address, amount: bigint): Promise; + waitReceipt(hash: Hex): Promise<"reverted" | "success">; + moneriumChain(): Promise; + listOrdersByMemo(address: Address, memo: string): Promise>; + createRedeemOrder(request: MoneriumRedeemOrderRequest): Promise<{ id: string | null }>; + getOrder(orderId: string): Promise<{ rejectedReason?: string; state: string }>; + signMessage(message: string): Promise; + /** Forward-only deposit transition under the forwarder lock (a no-op for an illegal edge). */ + setDepositStatus(deposit: MoneriumFiatDeposit, status: MoneriumFiatDepositStatus): Promise; + now(): Date; +} + +function requireClient(client: KeeperWalletClient | null, name: string): KeeperWalletClient { + if (!client) throw new Error(`${name} is not configured`); + return client; +} + +/** Live dependencies: chain clients from ./chain, the shared Monerium client, the two wallet keys. */ +export async function liveRecoveryDeps(forwarder: Address): Promise { + const client = getPublicClient(); + const immutables = await getForwarderImmutables(forwarder); + const recovery = requireClient(getRecoveryWalletClient(), "MONERIUM_B2B_RECOVERY_PRIVATE_KEY"); + const float = requireClient(getFloatWalletClient(), "MONERIUM_B2B_FLOAT_PRIVATE_KEY"); + if (recovery.account.address.toLowerCase() !== immutables.recoveryWallet.toLowerCase()) { + throw new Error("MONERIUM_B2B_RECOVERY_PRIVATE_KEY does not control the implementation's RECOVERY_WALLET"); + } + const balance = (token: Address, address: Address) => + client.readContract({ abi: erc20Abi, address: token, args: [address], functionName: "balanceOf" }); + const wallets = { float, recovery }; + return { + async createRedeemOrder(request) { + const result = await MoneriumApiService.getInstance().createRedemptionOrder(request); + return { id: result.httpStatus === 200 ? result.order.id : null }; + }, + eureBalance: address => balance(immutables.eure, address), + floatWallet: float.account.address, + async getOrder(orderId) { + const order = await MoneriumApiService.getInstance().getOrder(orderId); + return { rejectedReason: order.meta.rejectedReason, state: order.state }; + }, + async listOrdersByMemo(address, memo) { + const { orders } = await MoneriumApiService.getInstance().listOrders({ address, memo }); + return orders + .filter(order => order.kind === "redeem" && order.memo === memo) + .map(order => ({ id: order.id, rejectedReason: order.meta.rejectedReason, state: order.state })); + }, + async moneriumChain() { + const chain = moneriumChainForChainId(await getChainId()); + if (!chain) throw new Error("no Monerium chain name for the configured chain id"); + return chain; + }, + now: () => new Date(), + async oracle() { + const [, answer] = await client.readContract({ + abi: chainlinkAbi, + address: immutables.oracle, + functionName: "latestRoundData" + }); + if (answer <= 0n) throw new Error(`Chainlink EUR/USD answered ${answer}`); + return { decimals: immutables.oracleDecimals, raw: answer, slippageBps: immutables.slippageBps }; + }, + recoveryWallet: recovery.account.address, + async reverseRoute() { + const routes = await readEnabledRoutes(immutables.factory); + if (routes.length === 0) throw new Error("the factory has no enabled swap route to reverse"); + return reversePath(routes[0].path); + }, + async sendEure(from, to, amount) { + const wallet = wallets[from]; + const { request } = await client.simulateContract({ + abi: erc20Abi, + account: wallet.account, + address: immutables.eure, + args: [to, amount], + functionName: "transfer" + }); + return wallet.writeContract({ ...request, chain: null }); + }, + async sendReverseSwap(amountIn, minOut, path) { + const allowance = await client.readContract({ + abi: erc20Abi, + address: immutables.usdc, + args: [recovery.account.address, immutables.router], + functionName: "allowance" + }); + if (allowance < amountIn) { + const approve = await client.simulateContract({ + abi: erc20Abi, + account: recovery.account, + address: immutables.usdc, + args: [immutables.router, amountIn], + functionName: "approve" + }); + const approveHash = await recovery.writeContract({ ...approve.request, chain: null }); + await client.waitForTransactionReceipt({ hash: approveHash, timeout: RECEIPT_TIMEOUT_MS }); + } + const { request } = await client.simulateContract({ + abi: swapRouter02Abi, + account: recovery.account, + address: immutables.router, + args: [{ amountIn, amountOutMinimum: minOut, path, recipient: recovery.account.address }], + functionName: "exactInput" + }); + return recovery.writeContract({ ...request, chain: null }); + }, + setDepositStatus, + signMessage: message => recovery.signMessage({ message }), + usdcBalance: address => balance(immutables.usdc, address), + async waitReceipt(hash) { + const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }); + return receipt.status; + } + }; +} + +// ------------------------------------------------------------------ deadline trigger + +/** + * Marks settling deposits whose mint is older than the promised window for the refund + * path (`auto`), or reports them (`alert`). A deposit with a pending keeper transaction + * is marked on a later pass, once it settled. + */ +export async function runRecoveryDeadlines(now: number = Date.now()): Promise { + const mode = config.moneriumB2b.autoRecovery; + if (mode === "off") return; + const deadlineMs = config.moneriumB2b.recoveryDeadlineMinutes * 60_000; + const deposits = await MoneriumFiatDeposit.findAll({ + where: { + blockNumber: { [Op.ne]: null }, + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + status: { [Op.in]: [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Converting] } + } + }); + for (const deposit of deposits) { + if (!isPastDeadline(deposit, deadlineMs, now)) continue; + const ageMinutes = Math.floor((now - (deposit.mintedAt ?? deposit.createdAt).getTime()) / 60_000); + if (mode === "alert") { + logger.error( + `monerium-b2b: REFUND DUE — deposit ${deposit.id} was minted ${ageMinutes} min ago and is still ${deposit.status}; ` + + "MONERIUM_B2B_AUTO_RECOVERY=alert: mark it via POST /v1/admin/monerium-b2b/deposits/:id/recover (runbook §2.7)" + ); + continue; + } + const refusal = await markDepositForRecovery(deposit.id); + if (refusal) { + logger.warn(`monerium-b2b: deposit ${deposit.id} is past its window but cannot be marked yet: ${refusal}`); + } else { + logger.warn(`monerium-b2b: deposit ${deposit.id} missed the ${ageMinutes} min window; marked for recovery`); + } + } +} + +// ------------------------------------------------------------------ orchestrator + +/** + * True while a recovered payment is (or is about to be) on the recovery wallet: a + * `recover` execution that is pending or confirmed whose deposit has not left the + * refund path. The executor refuses to send another `recover` meanwhile. + */ +export async function activeRecoveryExists(): Promise { + const rows = await sequelize.query<{ id: string }>( + `SELECT e.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 + WHERE 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`, + { type: QueryTypes.SELECT } + ); + return rows.length > 0; +} + +export async function setDepositStatus(deposit: MoneriumFiatDeposit, status: MoneriumFiatDepositStatus): Promise { + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) return; + await withForwarderLock(account.forwarderAddress, async transaction => { + const current = await MoneriumFiatDeposit.findByPk(deposit.id, { transaction }); + if (current && isForwardTransition(current.status, status)) { + await current.update({ status }, { transaction }); + } + }); +} + +async function fail( + recovery: MoneriumRecovery, + deposit: MoneriumFiatDeposit, + deps: RecoveryDeps, + reason: string +): Promise { + logger.error(`monerium-b2b: REFUND FAILED — deposit ${deposit.id} in phase ${recovery.phase}: ${reason} (runbook §2.7)`); + await recovery.update({ error: reason.slice(0, 500) }); + await deps.setDepositStatus(deposit, MoneriumFiatDepositStatus.RecoveryFailed); +} + +async function retryOrFail( + recovery: MoneriumRecovery, + deposit: MoneriumFiatDeposit, + deps: RecoveryDeps, + phase: MoneriumRecoveryPhase, + reason: string +): Promise { + const attempts = recovery.attempts + 1; + if (attempts >= MAX_ATTEMPTS) { + await recovery.update({ attempts, phase }); + await fail(recovery, deposit, deps, `${reason} after ${attempts} attempts`); + return; + } + logger.warn(`monerium-b2b: refund step for deposit ${deposit.id} failed (attempt ${attempts}): ${reason}`); + await recovery.update({ attempts, error: reason.slice(0, 500), phase }); +} + +/** One step of one recovery. Returns after at most one value-moving send (plus its receipt wait). */ +export async function driveRecovery( + recovery: MoneriumRecovery, + deposit: MoneriumFiatDeposit, + deps: RecoveryDeps +): Promise { + const wallet = deps.recoveryWallet; + switch (recovery.phase) { + case MoneriumRecoveryPhase.Moved: { + const usdc = await deps.usdcBalance(wallet); + if (BigInt(recovery.usdcRecoveredRaw) === 0n || usdc === 0n) { + // Nothing to swap, or a swap already landed (a lost hash): what the wallet holds + // beyond the recovered EURe is the swap's output. + const eure = await deps.eureBalance(wallet); + const fromSwap = eure > BigInt(recovery.eureRecoveredRaw) ? eure - BigInt(recovery.eureRecoveredRaw) : 0n; + await recovery.update({ eureFromSwapRaw: fromSwap.toString(), phase: MoneriumRecoveryPhase.Swapped }); + return; + } + const { decimals, raw, slippageBps } = await deps.oracle(); + const minOut = reverseSwapMinOut(usdc, raw, decimals, slippageBps); + const path = await deps.reverseRoute(); + let hash: Hex; + try { + hash = await deps.sendReverseSwap(usdc, minOut, path); + } catch (error) { + await retryOrFail(recovery, deposit, deps, MoneriumRecoveryPhase.Moved, `reverse swap rejected: ${errorText(error)}`); + return; + } + await recovery.update({ phase: MoneriumRecoveryPhase.Swapping, reverseSwapTxHash: hash }); + return; + } + case MoneriumRecoveryPhase.Swapping: { + const hash = recovery.reverseSwapTxHash as Hex | null; + if (!hash) { + await recovery.update({ phase: MoneriumRecoveryPhase.Moved }); // crashed before the hash persisted: re-derive from balances + return; + } + const status = await deps.waitReceipt(hash); + if (status === "reverted") { + await retryOrFail(recovery, deposit, deps, MoneriumRecoveryPhase.Moved, `reverse swap ${hash} reverted`); + return; + } + const eure = await deps.eureBalance(wallet); + const fromSwap = eure > BigInt(recovery.eureRecoveredRaw) ? eure - BigInt(recovery.eureRecoveredRaw) : 0n; + await recovery.update({ eureFromSwapRaw: fromSwap.toString(), phase: MoneriumRecoveryPhase.Swapped }); + return; + } + case MoneriumRecoveryPhase.Swapped: { + const refundRaw = BigInt(deposit.amountRaw); + const { surplus, topUp } = refundNeed(refundRaw, await deps.eureBalance(wallet)); + if (topUp > 0n) { + const floatBalance = await deps.eureBalance(deps.floatWallet); + if (floatBalance < topUp) { + logger.error( + `monerium-b2b: FLOAT UNDERFUNDED — refund of deposit ${deposit.id} needs ${formatUnits(topUp, EURE_DECIMALS)} EURe, ` + + `the float holds ${formatUnits(floatBalance, EURE_DECIMALS)}; fund ${deps.floatWallet} (runbook §2.7)` + ); + return; // not a failure: retried every cycle once funded + } + let hash: Hex; + try { + hash = await deps.sendEure("float", wallet, topUp); + } catch (error) { + await retryOrFail( + recovery, + deposit, + deps, + MoneriumRecoveryPhase.Swapped, + `float top-up rejected: ${errorText(error)}` + ); + return; + } + await recovery.update({ + floatTopupRaw: topUp.toString(), + floatTopupTxHash: hash, + phase: MoneriumRecoveryPhase.ToppingUp + }); + return; + } + if (surplus > 0n) { + let hash: Hex; + try { + hash = await deps.sendEure("recovery", deps.floatWallet, surplus); + } catch (error) { + await retryOrFail( + recovery, + deposit, + deps, + MoneriumRecoveryPhase.Swapped, + `surplus sweep rejected: ${errorText(error)}` + ); + return; + } + await recovery.update({ phase: MoneriumRecoveryPhase.ToppingUp, surplusRaw: surplus.toString(), surplusTxHash: hash }); + return; + } + await recovery.update({ phase: MoneriumRecoveryPhase.ToppedUp }); + return; + } + case MoneriumRecoveryPhase.ToppingUp: { + const hash = (recovery.floatTopupTxHash ?? recovery.surplusTxHash) as Hex | null; + if (!hash) { + await recovery.update({ phase: MoneriumRecoveryPhase.Swapped }); + return; + } + const status = await deps.waitReceipt(hash); + if (status === "reverted") { + await retryOrFail(recovery, deposit, deps, MoneriumRecoveryPhase.Swapped, `transfer ${hash} reverted`); + return; + } + // Re-derive: the wallet must now hold exactly the refund; anything else loops through Swapped. + const { surplus, topUp } = refundNeed(BigInt(deposit.amountRaw), await deps.eureBalance(wallet)); + await recovery.update({ + phase: topUp === 0n && surplus === 0n ? MoneriumRecoveryPhase.ToppedUp : MoneriumRecoveryPhase.Swapped + }); + return; + } + case MoneriumRecoveryPhase.ToppedUp: { + if (!deposit.payerIban || !deposit.payerName) { + await fail(recovery, deposit, deps, "the issue order carried no payer IBAN/name to refund to"); + return; + } + let amount: string; + try { + amount = refundEurAmount(deposit.amountRaw); + } catch (error) { + await fail(recovery, deposit, deps, errorText(error)); + return; + } + if (Number(amount) >= SUPPORTING_DOCUMENT_THRESHOLD_EUR) { + await fail( + recovery, + deposit, + deps, + `refunds of EUR ${SUPPORTING_DOCUMENT_THRESHOLD_EUR} or more need a supporting document; place the order by hand` + ); + return; + } + const memo = refundMemo(deposit.id); + // Exactly-once: the memo is the idempotency key at Monerium. + const existing = await deps.listOrdersByMemo(wallet, memo); + if (existing.length > 0) { + await recovery.update({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: existing[0].id, refundAmount: amount }); + return; + } + const message = buildMoneriumSepaRedemptionMessage(amount, deposit.payerIban, deps.now()); + const request: MoneriumRedeemOrderRequest = { + address: wallet, + amount, + chain: (await deps.moneriumChain()) as MoneriumRedeemOrderRequest["chain"], + counterpart: { + details: { companyName: deposit.payerName, country: deposit.payerIban.slice(0, 2) }, + identifier: { iban: deposit.payerIban, standard: "iban" } + }, + currency: "eur", + kind: "redeem", + memo, + message, + signature: await deps.signMessage(message) + }; + let placed: { id: string | null }; + try { + placed = await deps.createRedeemOrder(request); + } catch (error) { + await retryOrFail( + recovery, + deposit, + deps, + MoneriumRecoveryPhase.ToppedUp, + `redeem order rejected: ${errorText(error)}` + ); + return; + } + await recovery.update({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: placed.id, refundAmount: amount }); + return; + } + case MoneriumRecoveryPhase.Redeeming: { + let order: { rejectedReason?: string; state: string } | undefined; + if (recovery.redeemOrderId) { + order = await deps.getOrder(recovery.redeemOrderId); + } else { + const [found] = await deps.listOrdersByMemo(wallet, refundMemo(deposit.id)); + if (found) { + await recovery.update({ redeemOrderId: found.id }); + order = found; + } + } + if (!order) return; // accepted asynchronously: it shows up in the next listing + if (order.state === "processed") { + await recovery.update({ error: null, phase: MoneriumRecoveryPhase.Redeemed }); + await deps.setDepositStatus(deposit, MoneriumFiatDepositStatus.Refunded); + logger.info( + `monerium-b2b: deposit ${deposit.id} refunded (${recovery.refundAmount} EUR, order ${recovery.redeemOrderId})` + ); + } else if (order.state === "rejected") { + await fail(recovery, deposit, deps, `Monerium rejected the redeem order: ${order.rejectedReason ?? "no reason given"}`); + } + return; + } + case MoneriumRecoveryPhase.Redeemed: + return; + } +} + +function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 500); +} + +/** + * Runs one step of the active recovery, or opens the next one: the oldest deposit in + * `recovering` whose `recover` execution is confirmed and that has no recovery row yet. + * A recovery whose deposit is `recovery_failed` waits for the operator and blocks the + * queue (one wallet, one refund at a time). + */ +export async function runRecoveryOrchestrator( + depsFor: (forwarder: Address) => Promise = liveRecoveryDeps +): Promise { + let recovery = await MoneriumRecovery.findOne({ + order: [["created_at", "ASC"]], + where: { phase: { [Op.ne]: MoneriumRecoveryPhase.Redeemed } } + }); + if (!recovery) { + const moved = await sequelize.query<{ depositId: string; eureInRaw: string; usdcNetRaw: string }>( + `SELECT e.deposit_id AS "depositId", e.eure_in_raw AS "eureInRaw", e.usdc_net_raw AS "usdcNetRaw" + 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 + WHERE e.kind = 'recover' AND e.status = 'confirmed' AND d.status = 'recovering' AND r.id IS NULL + ORDER BY e.created_at ASC + LIMIT 1`, + { type: QueryTypes.SELECT } + ); + if (moved.length === 0) return; + recovery = await MoneriumRecovery.create({ + depositId: moved[0].depositId, + eureRecoveredRaw: moved[0].eureInRaw, + phase: MoneriumRecoveryPhase.Moved, + usdcRecoveredRaw: moved[0].usdcNetRaw ?? "0" + }); + } + const deposit = await MoneriumFiatDeposit.findByPk(recovery.depositId); + if (!deposit) return; + if (deposit.status === MoneriumFiatDepositStatus.RecoveryFailed) { + logger.error( + `monerium-b2b: refund of deposit ${deposit.id} waits for the operator (${recovery.error}); the refund queue is blocked` + ); + return; + } + if (deposit.status === MoneriumFiatDepositStatus.Refunded) { + await recovery.update({ phase: MoneriumRecoveryPhase.Redeemed }); // closed by hand + return; + } + if (recovery.error) { + await recovery.update({ attempts: 0, error: null }); // operator retry: resume from the preserved phase + } + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) return; + try { + const deps = await depsFor(account.forwarderAddress as Address); + await driveRecovery(recovery, deposit, deps); + } catch (error) { + logger.error(`monerium-b2b: refund step for deposit ${deposit.id} errored:`, error); + } +} diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index fd5a2ec6d..960e61856 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -2,6 +2,7 @@ import { CronJob } from "cron"; import { QueryTypes } from "sequelize"; import sequelize from "../../config/database"; import logger from "../../config/logger"; +import { config } from "../../config/vars"; import { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; import { isKeeperChainConfigured } from "../services/monerium-b2b/chain"; import { runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; @@ -11,6 +12,7 @@ import { emitMoneriumDepositEvents } from "../services/monerium-b2b/manager-even import { runMintWatcher } 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"; const DEFAULT_CRON_TIME = "* * * * *"; // every minute @@ -70,6 +72,16 @@ class MoneriumB2bWorker { } await runDormancyGate(); + + // The refund path: deposits past the promised window are marked (or reported), + // and the one active refund advances by a step; both need the keeper's chain + // config, the orchestrator also the recovery and float keys (fail-fast config). + if (config.moneriumB2b.autoRecovery !== "off") { + await runRecoveryDeadlines(); + } + if (config.moneriumB2b.autoRecovery === "auto") { + await runRecoveryOrchestrator(); + } } // Manager-facing deposit events into the durable webhook outbox; the diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 8fcd437bc..29cb65393 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -225,11 +225,19 @@ interface Config { // Separate credential set from the legacy consumer OAuth integration above. moneriumB2b: { attestorPrivateKey: string | undefined; + /** off: nothing; alert: log deposits past the window; auto: mark them and run the refund. */ + autoRecovery: "off" | "alert" | "auto"; enabled: boolean; + /** Key of the EURe float wallet that tops a refund up to the exact amount. */ + floatPrivateKey: string | undefined; forwarderFactoryAddress: string | undefined; guardianPrivateKey: string | undefined; keeperPrivateKey: string | undefined; privateRpcUrl: string | undefined; + /** Promised conversion window from the mint, in minutes; the on-chain RECOVERY_DELAY is its floor. */ + recoveryDeadlineMinutes: number; + /** Key of the immutable RECOVERY_WALLET: signs the reverse swap and the Monerium redeem message. */ + recoveryPrivateKey: string | undefined; rpcUrl: string | undefined; webhookSecret: string; }; @@ -346,7 +354,11 @@ export const config: Config = { // (MONERIUM_WHITELABEL_CLIENT_ID/SECRET, MONERIUM_API_URL — @vortexfi/shared); // this block keeps only the chain/keeper-specific settings. attestorPrivateKey: process.env.MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, + autoRecovery: (["alert", "auto"].includes(process.env.MONERIUM_B2B_AUTO_RECOVERY ?? "") + ? process.env.MONERIUM_B2B_AUTO_RECOVERY + : "off") as "off" | "alert" | "auto", enabled: process.env.MONERIUM_B2B_ENABLED === "true", + floatPrivateKey: process.env.MONERIUM_B2B_FLOAT_PRIVATE_KEY, forwarderFactoryAddress: process.env.MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS, // Dormancy-gate pause key (guardian on the factory/forwarders). Distinct from the // keeper and attestor keys by design; unset = log-only mode for the dormancy gate. @@ -355,6 +367,8 @@ export const config: Config = { // Private-orderflow submission endpoint (e.g. https://rpc.flashbots.net); when unset // the keeper falls back to the public RPC and logs a warning (see chain.ts). privateRpcUrl: process.env.MONERIUM_B2B_PRIVATE_RPC_URL, + recoveryDeadlineMinutes: Number(process.env.MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES || 120), + recoveryPrivateKey: process.env.MONERIUM_B2B_RECOVERY_PRIVATE_KEY, rpcUrl: process.env.MONERIUM_B2B_RPC_URL, webhookSecret: process.env.MONERIUM_B2B_WEBHOOK_SECRET || "" }, @@ -480,10 +494,27 @@ if (config.moneriumB2b.enabled) { ) { throw new Error("MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS must be a valid EVM address"); } + if (config.moneriumB2b.autoRecovery === "auto") { + const missingRecovery: string[] = []; + if (!config.moneriumB2b.recoveryPrivateKey) missingRecovery.push("MONERIUM_B2B_RECOVERY_PRIVATE_KEY"); + if (!config.moneriumB2b.floatPrivateKey) missingRecovery.push("MONERIUM_B2B_FLOAT_PRIVATE_KEY"); + if (missingRecovery.length > 0) { + throw new Error(`MONERIUM_B2B_AUTO_RECOVERY=auto requires ${missingRecovery.join(", ")}`); + } + } + if (!Number.isInteger(config.moneriumB2b.recoveryDeadlineMinutes) || config.moneriumB2b.recoveryDeadlineMinutes <= 0) { + throw new Error("MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES must be a positive integer"); + } for (const [name, value] of [ ["MONERIUM_B2B_ATTESTOR_PRIVATE_KEY", config.moneriumB2b.attestorPrivateKey], ["MONERIUM_B2B_GUARDIAN_PRIVATE_KEY", config.moneriumB2b.guardianPrivateKey], - ["MONERIUM_B2B_KEEPER_PRIVATE_KEY", config.moneriumB2b.keeperPrivateKey] + ["MONERIUM_B2B_KEEPER_PRIVATE_KEY", config.moneriumB2b.keeperPrivateKey], + ...(config.moneriumB2b.recoveryPrivateKey + ? ([["MONERIUM_B2B_RECOVERY_PRIVATE_KEY", config.moneriumB2b.recoveryPrivateKey]] as const) + : []), + ...(config.moneriumB2b.floatPrivateKey + ? ([["MONERIUM_B2B_FLOAT_PRIVATE_KEY", config.moneriumB2b.floatPrivateKey]] as const) + : []) ] as const) { if (!/^0x[0-9a-fA-F]{64}$/.test(value as string)) { throw new Error(`${name} must be a 32-byte 0x-prefixed private key`); From f0b901b4b2f7f02366f8625531895956be6eb578 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 22:35:20 +0200 Subject: [PATCH 34/44] docs(repo): describe the automated refund path --- docs/architecture-monerium-b2b-onramp.md | 29 +++++++++++++++---- docs/operations-monerium-b2b-rollout.md | 2 +- docs/operations-monerium-b2b-runbook.md | 20 ++++++++++++- .../05-integrations/monerium-b2b.md | 9 +++++- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 0ea8d344b..613453c80 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -318,11 +318,24 @@ and sends at most one transaction per account per cycle: 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 the refund is manual for - now (runbook §2.7): the USDC is swapped back to EURe, a float wallet covers the - slippage residue, and a Monerium redeem order from the recovery wallet's company - profile returns the exact issue amount to the payer's IBAN; the operator then marks the - deposit `refunded`. + accounts (`recover` ignores the guardian pause). 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 → + topped_up → redeeming → redeemed` — the USDC is swapped back to EURe on the reversed + whitelisted route with a Chainlink-derived minimum, the EURe float tops the recovery + wallet up to exactly the issue amount (or a surplus is swept back to the float), and a + Monerium redeem order from the recovery wallet returns the exact amount to the payer's + IBAN (`payer_iban` / `payer_name`, captured from the issue order's counterpart). The + deposit becomes `refunded` when Monerium processes the order. One refund runs at a + time: every step re-derives what is left to do from the dedicated recovery wallet's + balances (so a lost transaction hash never repeats a send), and the keeper refuses a + second `recover` while one is in flight. A step that fails beyond its retries, a + missing payer, or an amount that needs a supporting document (EUR 15,000 and above) + parks the deposit in `recovery_failed` with the phase preserved; an operator retry + (deposit back to `recovering`) resumes there. The promised window is + `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (120) counted from the mint block + (`minted_at`); the on-chain `RECOVERY_DELAY` is its floor. - **Liveness without Vortex.** Past `TRIGGER_DELAY` (24 h) anyone may `swap` (Chainlink reference, no subsidy) and `forwardAll` the clone's USDC to the destination; payments may merge on that path, and the keeper reconciles what it did not send by hand. @@ -419,6 +432,9 @@ read-only — no keys, no transactions: a delisted or halted product keeps answering the candles endpoint with stale data and would make every keeper swap defer silently, so its status is an error line rather than an assumption. +7. **Refund monitor** (automated refunds only). The one active recovery must not + linger (warn after an hour, error after four or on a failed step) and the EURe float + must not run dry. ## Data model — the Monerium B2B tables @@ -470,7 +486,8 @@ erDiagram | Table | Purpose | |---|---| | `monerium_accounts` (069, 071, 078, 080) | One row per client account: Monerium profile UUID, IBAN, forwarder and destination addresses, fee policy mirror (`target_ppm`, `floor_ppm`), lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | -| `monerium_fiat_deposits` (069, 070, 073, 076, 080) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status through settlement (`converting`, `forwarded`) or refund (`recovering`, `refunded`, `recovery_failed`), on-chain mint identity, and two webhook-emission markers | +| `monerium_fiat_deposits` (069, 070, 073, 076, 080, 081) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status through settlement (`converting`, `forwarded`) or refund (`recovering`, `refunded`, `recovery_failed`), on-chain mint identity and mint time, the payer's IBAN and name (the refund target), and two webhook-emission markers | +| `monerium_recoveries` (081) | One row per refunded deposit: the phase of the refund, the EURe and USDC the keeper recovered, the reverse-swap output, the float top-up (the refund's subsidy) or the surplus swept back, the redeem order and the EUR amount refunded, attempts and the last error | | `monerium_conversion_executions` (069, 074, 075, 077, 079, 080) | One row per keeper transaction, bound to the deposit it serves (`deposit_id`) and typed by `kind`: a `swap` row is created before broadcast with the chunk, the reference (rate, source, averaging window, time) and route, then filled from `SwapExecuted` (USDC gross, fee, subsidy, net `usdcOut - fee + subsidy`); a `forward` row carries the amount pushed to the destination; a `recover` row the EURe and USDC moved to the recovery wallet. All carry tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and event log index, status | | `monerium_webhook_events` (069) | Durable persist-before-200 inbox for Monerium deliveries, dedup by event id, 30-day retention after processing | | `monerium_chain_cursors` (070) | Persisted block cursors for the mint watcher | diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 8eb64e02b..5dc204a75 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -197,7 +197,7 @@ fee policy 12.5 bps target / 15 bps floor (B1). | Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses a five-minute VWAP of Coinbase Exchange EURC-USDC candles — confirm that is what was meant | | Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 45 bps after the 2026-09-17 move to 60 bps: the twelve-month replay shows ~0.2 h/year of floor-cause deferral at that margin, so ordinary weekends no longer refund | | Recovery wallet + float wallet | Ops ↔ Monerium | Onboard a Vortex/SatoshiPay company profile in the whitelabel app; link one dedicated address as `RECOVERY_WALLET` (immutable at implementation deploy) and one as the EURe float; fund the float; keys into the keeper's KMS before recovery is automated | -| Refund automation (proposal phase 2) | Engineering | Deadline trigger, reverse swap, float top-up and redeem orchestration; until then runbook §2.7 by hand | +| Refund automation | Ops | Implemented (`recovery.ts`): ship with `MONERIUM_B2B_AUTO_RECOVERY=alert`, observe one sandbox refund end to end, then `auto` with the recovery and float keys set; refunds of EUR 15,000 or more stay manual until G1 settles the supporting-document question | | Sandbox SEPA simulation: payer counterpart | Engineering (needs Marcel's sandbox login) | Capture one real issue-order webhook to confirm `counterpart.identifier.iban` / `details.name` arrive as the spec says (the refund target) | | Subsidy vault funding and refill cadence | Ops | Before first activation; runbook §2.6 | | GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 855a1272b..db47118b0 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -242,7 +242,7 @@ daily budget covers roughly one and a half such swaps per day across all clients the keeper starts deferring. Raise the budget or lower `perSwapCap` if deferrals become routine; both are instant. -### 2.7 Refund (recovery) procedure — manual until automated +### 2.7 Refund (recovery) procedure Trigger: a deposit the promised window (2 h) was missed on, a remainder below `minSwapAmount`, a compliance decision, or a critical incident (§2.4). Prerequisites: the @@ -250,6 +250,20 @@ recovery wallet (`RECOVERY_WALLET()` on the implementation) is a linked address Vortex company profile at Monerium, its key and the EURe float wallet's key are in the operator's custody, and the float holds EURe. +**Automation.** `MONERIUM_B2B_AUTO_RECOVERY` selects the mode: `off` (default) leaves +every step below to the operator; `alert` logs `REFUND DUE` for deposits past +`MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (120, counted from the mint) and nothing else; +`auto` marks them, and — with `MONERIUM_B2B_RECOVERY_PRIVATE_KEY` (must control the +implementation's `RECOVERY_WALLET`) and `MONERIUM_B2B_FLOAT_PRIVATE_KEY` set — runs +steps 2–6 itself, one refund at a time, reporting through the refund monitor (§3). +Start on `alert`, switch to `auto` once a sandbox refund has been observed end to end. +What stays manual in `auto`: refunds of EUR 15,000 or more (Monerium's supporting +document), deposits whose issue order carried no payer IBAN/name, orders Monerium +rejects, and any step that failed five times — all park the deposit as +`recovery_failed` with the phase preserved (`monerium_recoveries.phase`/`error`); +fix the cause, then `PATCH .../deposits//status {"status": "recovering"}` resumes +from that phase. While one refund is `recovery_failed` the queue waits (one wallet). + 1. **Mark the deposit.** `POST /v1/admin/monerium-b2b/deposits//recover` (`Authorization: Bearer $ADMIN_SECRET`). Refused (409) while a keeper transaction for the deposit is pending — retry once it settled — or when the deposit is not @@ -300,6 +314,10 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- | `stranded funds on forwarder ... past RECOVERY_DELAY` (warn) | A batch has been open longer than the promised 2 h window and is neither forwarded nor recovering | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts), `deferring conversion` lines; if the payment cannot complete, mark it for recovery (§2.7) | | `stranded funds ... past TRIGGER_DELAY` (error) | Permissionless path now live; SLA long broken (keeper outage or a persistent deferral) | Escalate; anyone may call `swap(reference, route, amountIn)` and `forwardAll()` — that path prices against Chainlink and pays no subsidy; communicate the delay | | `REFERENCE VENUE —` (error) | The Coinbase product the reference reads is delisted or halted; every keeper swap defers silently | Change `COINBASE_REFERENCE_PRODUCT` (a live EURC market), redeploy the backend; the venue is an operational, not an on-chain, setting | +| `REFUND DUE — deposit ...` (error, `alert` mode) | A deposit outlived the promised window and the mode only reports | Mark it (§2.7 step 1) or switch to `auto` | +| `REFUND FAILED — deposit ... in phase ...` (error) | A refund step cannot complete automatically (large amount, missing payer, rejected order, five failed attempts) | §2.7: finish by hand from the named phase, or fix the cause and set the deposit back to `recovering` | +| `FLOAT UNDERFUNDED` / `FLOAT EMPTY` (error) | The EURe float cannot cover a top-up; the refund waits at `swapped` | Fund the float wallet named in the line; the step retries every cycle | +| `refund of deposit ... in phase ... since` (warn ≥1 h, error ≥4 h) | The active refund lingers | Check the recovery wallet's balances and pending transactions, RPC health, Monerium order state; escalate per §2.7 | | `untrusted factory` / `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on trusted factory` | Should-be-impossible state | Full incident: global pause, verify `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, run the manifest verifier, compare against manifest history | | `reconciled guardian-authorized fee policy change` | A timelocked fee-policy change applied — expected, DB updated | No incident; confirm it matches the announced change | | `config violation ... destination changed on chain` | Should be impossible: the clone has no destination setter | Full incident (see the row above) | diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index c5c29c780..a0dbff9a8 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -41,6 +41,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 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. 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, 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 pending partner re-confirmation mechanics (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 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 drives at most one such row at a time; the keeper refuses a second `recover` while one is in flight (`activeRecoveryExists`). Every step re-derives its remaining work from the recovery 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 recovery 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). Amounts of EUR 15,000 and above, a missing payer, a rejected order, or a step failing five times park the deposit as `recovery_failed` with the phase preserved for the operator's retry. The recovery and float keys are env-only, never logged, required at startup in `auto` mode, and the recovery key must control the implementation's `RECOVERY_WALLET` (checked before any send). `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 computes a five-minute volume-weighted average of Coinbase Exchange EURC-USDC one-minute candles (`reference-rate.ts`; widened to an hour when the five minutes carry no volume, so one thin print cannot set the reference), records price, window and time on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. ## Monitoring @@ -53,6 +54,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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). 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. +8. **Refund monitor** — with automated refunds configured, the oldest active recovery warns after an hour and errors after four or on a failed step (`classifyRefundQueue`, unit-tested); the float's EURe balance errors when empty and warns below 1,000 EURe. 7. **Reference-venue monitor** — probes the status of the Coinbase Exchange product the reference VWAP reads (`fetchCoinbaseProductStatus`, `classifyReferenceVenue`, unit-tested); anything but an online product with trading enabled is an error, because a delisted product keeps answering the candles endpoint with stale data and every keeper swap would defer silently (EURC-USD did exactly that after its 2024-08-29 delisting). ## Threat Vectors & Mitigations @@ -65,7 +67,10 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving a swap with no deposit to belong to | Conversion candidates require chain-indexed deposits; every swap is bound to one deposit before it is sent, with an explicit `amountIn` | | **Unsolicited USDC inflates deposit reporting** | USDC that arrived outside a swap is credited to a deposit | Execution net is `SwapExecuted.usdcOut - fee + subsidy`; a deposit's forward moves exactly the sum of its chunks' nets; unsolicited USDC leaves only through the keeper's `forwardAll` and is never reported as a conversion | | **Premature or misdirected recovery** | A compromised keeper moves a fresh payment off the clone, or to a wallet of its choosing | `recover` pays only the immutable `RECOVERY_WALLET`, only after the clone's batch has been open for `RECOVERY_DELAY`, with explicit amounts bounded by the balances; a deposit enters the refund path only through the admin endpoint (operator) or, once automated, the deadline; the invariant suite proves no early recovery and no other exit | -| **Recovery wallet compromise** | The Vortex wallet that receives recovered funds is drained before the refund | Funds sit there only during a recovery; the wallet is a dedicated linked address on a Vortex company profile at Monerium holding nothing else; refund steps and their ledger are the runbook's §2.7 until automated | +| **Recovery wallet compromise** | The Vortex wallet that receives recovered funds is drained before the refund | Funds sit there only during a recovery; the wallet is a dedicated linked address on a Vortex company profile at Monerium holding nothing else; one refund at a time; the key is env-only and must match the immutable `RECOVERY_WALLET` | +| **Reverse-swap sandwich or thin reverse route** | A searcher moves the pool while the refund's USDC is swapped back, or the pool is too thin | Minimum output at the Chainlink value less `SLIPPAGE_BPS`; private orderflow; a rejected or reverted swap retries with backoff and parks the refund for the operator after five attempts; the float, not the payer, absorbs slippage | +| **Double refund** | A crash between the redeem POST and its persistence places the order twice, or a lost top-up hash sends the float twice | Memo-keyed lookup at Monerium before every placement; balance-derived need (a landed top-up makes the need zero); one active recovery on an otherwise empty wallet | +| **Refund to the wrong account** | A tampered or missing counterpart sends the EUR elsewhere | The target is the issue order's own counterpart, recorded once and never overwritten by a later delivery; a deposit without it never refunds automatically (`recovery_failed`, runbook) | | **Manipulated reference rate** | A compromised keeper (or Coinbase response) supplies a reference that inflates the fee or triggers a subsidy | Contract band check against Chainlink; fee capped at `MAX_FEE_PPM`; subsidy bounded by the vault's per-swap cap and daily budget; the floor on the client's net; a permissionless caller's reference argument is ignored in favour of Chainlink | | **Subsidy-widened sandwich** | A searcher moves the pool so the fill lands just above floor minus cap and the vault pays the difference | Private orderflow for keeper swaps; modest per-swap cap and daily budget; the vault, not the client, absorbs the loss; no subsidy on the permissionless path | | **Subsidy vault drain** | A contract or key tries to pull vault USDC | `pay` accepts only factory-registered clones, within cap and budget; the clone passes its own immutable destination and verifies the exact delivery; withdrawals go to the immutable treasury only; guardian setters bound Vortex's own money, never client funds | @@ -108,3 +113,5 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` - [ ] 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 and refuse a deposit with a pending execution (`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, one recovery at a time (`activeRecoveryExists`) and the operator retry resuming from the preserved phase +- [ ] `MONERIUM_B2B_RECOVERY_PRIVATE_KEY` and `MONERIUM_B2B_FLOAT_PRIVATE_KEY` are env-only, validated as 32-byte keys, required only in `auto` mode, and the recovery key's address equals the implementation's `RECOVERY_WALLET` before any send From 953c736d9e45d1ca4e06ee9ea0c66ca390373e61 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 22:39:38 +0200 Subject: [PATCH 35/44] feat(api): notify managers when a deposit is refunded DEPOSIT_RETURNED joins the account-scoped deposit event family: fired once per refunded deposit with the EUR amount refunded, the payer's IBAN masked to its first and last four characters, Monerium's redeem order and the recover transaction. The deposits read API gains the same facts as a refund block. --- .../controllers/monerium-b2b.controller.ts | 18 +++++ .../monerium-b2b/manager-events.test.ts | 56 ++++++++++++++- .../services/monerium-b2b/manager-events.ts | 64 ++++++++++++++++- ...82-add-monerium-deposit-returned-marker.ts | 11 +++ .../src/models/moneriumFiatDeposit.model.ts | 8 +++ ...erium-b2b-account-read.integration.test.ts | 7 +- docs/api/openapi/vortex.openapi.d.ts | 7 ++ docs/api/openapi/vortex.openapi.json | 20 +++++- docs/api/pages/07-webhooks.md | 33 ++++++++- docs/api/pages/14-managed-profiles.md | 2 +- docs/api/wire-contract.snapshot.md | 70 +++++++++++++++++-- .../shared/src/endpoints/webhook.endpoints.ts | 32 ++++++++- 12 files changed, 312 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/database/migrations/082-add-monerium-deposit-returned-marker.ts diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index b093ccaf4..dc4c008c6 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -9,6 +9,7 @@ import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit from "../../models/moneriumFiatDeposit.model"; +import MoneriumRecovery from "../../models/moneriumRecovery.model"; import { APIError } from "../errors/api-error"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; @@ -147,6 +148,8 @@ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: where: { depositId: rows.map(row => row.id), status: { [Op.ne]: MoneriumConversionExecutionStatus.Failed } } }) : []; + const recoveries = rows.length ? await MoneriumRecovery.findAll({ where: { depositId: rows.map(row => row.id) } }) : []; + const recoveryByDeposit = new Map(recoveries.map(recovery => [recovery.depositId, recovery])); const executionsByDeposit = new Map(); for (const execution of executions) { const grouped = executionsByDeposit.get(execution.depositId as string) ?? []; @@ -163,6 +166,12 @@ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: execution.kind === MoneriumConversionExecutionKind.Forward && execution.status === MoneriumConversionExecutionStatus.Confirmed ); + const recover = depositExecutions.find( + execution => + execution.kind === MoneriumConversionExecutionKind.Recover && + execution.status === MoneriumConversionExecutionStatus.Confirmed + ); + const recovery = recoveryByDeposit.get(row.id); return { amountRaw: row.amountRaw, conversions: swaps.map(execution => ({ @@ -177,6 +186,15 @@ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: currency: row.currency, depositId: row.id, forwardTxHash: forward?.txHash ?? null, + // Present once the deposit entered the refund path: what was (or is being) refunded. + refund: + recovery || recover + ? { + amount: recovery?.refundAmount ?? null, + recoverTxHash: recover?.txHash ?? null, + redeemOrderId: recovery?.redeemOrderId ?? null + } + : null, status: row.status, txHash: row.txHash, usdcNetRaw: swaps 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 69521a3cf..1a3e1c659 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 @@ -7,13 +7,14 @@ import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; import Webhook from "../../../models/webhook.model"; import WebhookDelivery from "../../../models/webhookDelivery.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; import { createTestUser } from "../../../test-utils/factories"; import { provisionMoneriumB2bAccount } from "./account-provisioning"; import { NOTIFY_CONFIRMATION_DEPTH } from "./chain"; -import { emitMoneriumDepositEvents } from "./manager-events"; +import { emitMoneriumDepositEvents, maskIban } from "./manager-events"; const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; @@ -279,6 +280,59 @@ describe("monerium b2b manager events", () => { expect(await WebhookDelivery.count()).toBe(1); }); + it("emits DEPOSIT_RETURNED once a deposit was refunded, with the refund facts and a masked IBAN", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RETURNED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 999, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + receivedEventAt: new Date(), + status: MoneriumFiatDepositStatus.Refunded, + txHash: "0xmint" + }); + await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: "100000000000000000000", + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xrecover", + usdcNetRaw: "0" + }); + await MoneriumRecovery.create({ + depositId: deposit.id, + eureRecoveredRaw: "100000000000000000000", + phase: MoneriumRecoveryPhase.Redeemed, + redeemOrderId: "order-redeem-1", + refundAmount: "100.00", + usdcRecoveredRaw: "0" + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await emitMoneriumDepositEvents(depsAtBlock(null)); + const deliveries = await WebhookDelivery.findAll(); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]).toMatchObject({ eventId: `deposit-returned:${deposit.id}`, webhookId: webhook?.id }); + expect(deliveries[0].payload).toMatchObject({ + eventType: WebhookEventType.DEPOSIT_RETURNED, + payload: { + depositId: deposit.id, + refund: { amount: "100.00", payerIbanMasked: "DE89…3000", recoverTxHash: "0xrecover", redeemOrderId: "order-redeem-1" }, + status: "refunded" + } + }); + await deposit.reload(); + expect(deposit.returnedEventAt).not.toBeNull(); + expect(maskIban("EE08 7224 5745 6244 9516")).toBe("EE08…9516"); + }); + 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 6c9d4f776..6758b9dbc 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -15,6 +15,7 @@ import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumRecovery from "../../../models/moneriumRecovery.model"; import webhookService from "../webhook/webhook.service"; import { enqueueWebhookDeliveries } from "../webhook/webhook-outbox.service"; import { getPublicClient, NOTIFY_CONFIRMATION_DEPTH } from "./chain"; @@ -34,6 +35,12 @@ const defaultDeps: ManagerEventDeps = { } }; +/** First and last four characters of an IBAN, for partner-facing payloads. */ +export function maskIban(iban: string): string { + const compact = iban.replace(/\s+/g, ""); + return compact.length <= 8 ? compact : `${compact.slice(0, 4)}…${compact.slice(-4)}`; +} + /** Execution-level pricing facts, identical on every deposit portion the execution consumed. */ export function executionPricing(execution: MoneriumConversionExecution): ConversionExecutionPricing { return { @@ -183,16 +190,71 @@ async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: await deposit.update({ convertedEventAt: new Date() }); } +async function emitReturnedEvents(): Promise { + const deposits = await MoneriumFiatDeposit.findAll({ + limit: BATCH_LIMIT, + order: [["created_at", "ASC"]], + where: { + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + returnedEventAt: null, + status: MoneriumFiatDepositStatus.Refunded + } + }); + for (const deposit of deposits) { + try { + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) continue; + const [recovery, recoverExecution] = await Promise.all([ + MoneriumRecovery.findOne({ where: { depositId: deposit.id } }), + MoneriumConversionExecution.findOne({ + where: { + depositId: deposit.id, + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed + } + }) + ]); + const managerProfileId = await resolveManagerProfileId(account); + const payload: WebhookPayload = { + eventId: `deposit-returned:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_RETURNED, + payload: { + ...depositPayloadBase(deposit, account), + refund: { + amount: recovery?.refundAmount ?? refundAmountFromRaw(deposit.amountRaw), + payerIbanMasked: deposit.payerIban ? maskIban(deposit.payerIban) : "", + recoverTxHash: recoverExecution?.txHash ?? null, + redeemOrderId: recovery?.redeemOrderId ?? null + } + }, + timestamp: new Date().toISOString() + }; + await enqueueForManager(WebhookEventType.DEPOSIT_RETURNED, managerProfileId, payload); + await deposit.update({ returnedEventAt: new Date() }); + } catch (error) { + logger.error(`monerium-b2b: DEPOSIT_RETURNED emission failed for deposit ${deposit.id}:`, error); + } + } +} + +/** The issue amount to the cent, for a refund closed by hand before a recovery row recorded it. */ +function refundAmountFromRaw(amountRaw: string): string { + const cents = BigInt(amountRaw) / 10n ** 16n; + return `${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`; +} + /** * Emits the manager-facing deposit events into the durable webhook outbox: * DEPOSIT_RECEIVED once a deposit is minted, DEPOSIT_CONVERTED once the whole converted - * deposit was forwarded to the destination and that forward sits at notification depth. + * deposit was forwarded to the destination and that forward sits at notification depth, + * DEPOSIT_RETURNED once a deposit that missed the promised window was refunded. * Emission markers make each event fire exactly once regardless of the advancing component. */ export async function emitMoneriumDepositEvents(deps: ManagerEventDeps = defaultDeps): Promise { try { await emitReceivedEvents(); await emitConvertedEvents(deps); + await emitReturnedEvents(); } catch (error) { logger.error("monerium-b2b: manager event emission failed:", error); } diff --git a/apps/api/src/database/migrations/082-add-monerium-deposit-returned-marker.ts b/apps/api/src/database/migrations/082-add-monerium-deposit-returned-marker.ts new file mode 100644 index 000000000..8f0a054d9 --- /dev/null +++ b/apps/api/src/database/migrations/082-add-monerium-deposit-returned-marker.ts @@ -0,0 +1,11 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Emission marker for the DEPOSIT_RETURNED manager event, like the received/converted +// markers: fires exactly once per refunded deposit, never replays to late subscribers. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_fiat_deposits", "returned_event_at", { allowNull: true, type: DataTypes.DATE }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_fiat_deposits", "returned_event_at"); +} diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts index 345f02caa..84f54b3c8 100644 --- a/apps/api/src/models/moneriumFiatDeposit.model.ts +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -46,6 +46,7 @@ export interface MoneriumFiatDepositAttributes { payerName: string | null; receivedEventAt: Date | null; convertedEventAt: Date | null; + returnedEventAt: Date | null; createdAt: Date; updatedAt: Date; } @@ -64,6 +65,7 @@ type MoneriumFiatDepositCreationAttributes = Optional< | "payerName" | "receivedEventAt" | "convertedEventAt" + | "returnedEventAt" | "createdAt" | "updatedAt" >; @@ -88,6 +90,7 @@ class MoneriumFiatDeposit declare payerName: string | null; declare receivedEventAt: Date | null; declare convertedEventAt: Date | null; + declare returnedEventAt: Date | null; declare createdAt: Date; declare updatedAt: Date; } @@ -173,6 +176,11 @@ MoneriumFiatDeposit.init( field: "received_event_at", type: DataTypes.DATE }, + returnedEventAt: { + allowNull: true, + field: "returned_event_at", + type: DataTypes.DATE + }, status: { allowNull: false, defaultValue: MoneriumFiatDepositStatus.Pending, diff --git a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts index f19f9711b..6932ec8cd 100644 --- a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts +++ b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts @@ -149,10 +149,11 @@ describe("monerium b2b account read surface", () => { } ], forwardTxHash: "0xforward", + refund: null, txHash: "0xmint", usdcNetRaw: "108000000" }); - expect(rows[0]).toMatchObject({ conversions: [], forwardTxHash: null, usdcNetRaw: "0" }); + expect(rows[0]).toMatchObject({ conversions: [], forwardTxHash: null, refund: null, usdcNetRaw: "0" }); expect(deposits.body.pagination).toMatchObject({ total: 2 }); }); @@ -197,7 +198,7 @@ describe("monerium b2b account read surface", () => { const response = await app.request("/v1/webhook", { body: JSON.stringify({ - events: ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"], + events: ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED"], url: "https://manager.example.com/vortex/deposits" }), headers: { "Content-Type": "application/json", ...managerHeaders }, @@ -205,7 +206,7 @@ describe("monerium b2b account read surface", () => { }); expect(response.status).toBe(201); const body = (await response.json()) as { id: string; events: string[]; quoteId: string | null }; - expect(body.events).toEqual(["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"]); + expect(body.events).toEqual(["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED"]); expect(body.quoteId).toBeNull(); // The transaction-family requirement still holds at the same HTTP surface. diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 51842dd43..7ef865e84 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2911,6 +2911,13 @@ export interface components { depositId: string; /** @description The single transaction that delivered the whole converted deposit to the destination; null until the deposit is forwarded. */ forwardTxHash: string | null; + /** @description Present once the deposit entered the refund path (it could not be converted within the promised window): the EUR amount refunded to the payer once known, Monerium's redeem order id, and the transaction that moved the deposit off the forwarding contract. Null otherwise. */ + refund: { + /** @description The EUR amount refunded, to the cent; null until the refund order is placed. */ + amount: string | null; + recoverTxHash: string | null; + redeemOrderId: string | null; + } | null; /** * @description Deposit status (forward-only): the provider states, then `converting` and `forwarded`, or - when the deposit could not be converted within the promised window - `recovering`, `refunded` and `recovery_failed`. * @enum {string} diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index df7ecf0c6..d5d37083e 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2629,6 +2629,23 @@ "description": "The single transaction that delivered the whole converted deposit to the destination; null until the deposit is forwarded.", "type": ["string", "null"] }, + "refund": { + "description": "Present once the deposit entered the refund path (it could not be converted within the promised window): the EUR amount refunded to the payer once known, Monerium's redeem order id, and the transaction that moved the deposit off the forwarding contract. Null otherwise.", + "properties": { + "amount": { + "description": "The EUR amount refunded, to the cent; null until the refund order is placed.", + "type": ["string", "null"] + }, + "recoverTxHash": { + "type": ["string", "null"] + }, + "redeemOrderId": { + "type": ["string", "null"] + } + }, + "required": ["amount", "recoverTxHash", "redeemOrderId"], + "type": ["object", "null"] + }, "status": { "description": "Deposit status (forward-only): the provider states, then `converting` and `forwarded`, or - when the deposit could not be converted within the promised window - `recovering`, `refunded` and `recovery_failed`.", "enum": [ @@ -2660,6 +2677,7 @@ "currency", "depositId", "forwardTxHash", + "refund", "status", "txHash", "usdcNetRaw" @@ -11062,7 +11080,7 @@ "properties": { "events": { "items": { - "description": "(optional): Array of event types to subscribe to. Transaction events [\"TRANSACTION_CREATED\", \"STATUS_CHANGE\"] are the default when omitted. The account-scoped deposit events [\"DEPOSIT_RECEIVED\", \"DEPOSIT_CONVERTED\"] must be requested explicitly, cannot be mixed with transaction events, require a profile-scoped secret credential, and take no quoteId/sessionId.", + "description": "(optional): Array of event types to subscribe to. Transaction events [\"TRANSACTION_CREATED\", \"STATUS_CHANGE\"] are the default when omitted. The account-scoped deposit events [\"DEPOSIT_RECEIVED\", \"DEPOSIT_CONVERTED\", \"DEPOSIT_RETURNED\"] must be requested explicitly, cannot be mixed with transaction events, require a profile-scoped secret credential, and take no quoteId/sessionId.", "type": "string" }, "type": "array" diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index c44b2e862..a5ae448ce 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -6,7 +6,7 @@ You can subscribe to: - **Transaction creation** — a new ramp is registered. - **Status changes** — a ramp's status moves between `PENDING`, `COMPLETE`, and `FAILED`. -- **Deposit events** — for partner managers with business EUR onramp accounts: a client's EUR deposit was received (`DEPOSIT_RECEIVED`) or converted and forwarded (`DEPOSIT_CONVERTED`). See [Deposit Events](#deposit-events) — they follow account-scoped rules and durable delivery. +- **Deposit events** — for partner managers with business EUR onramp accounts: a client's EUR deposit was received (`DEPOSIT_RECEIVED`), converted and forwarded (`DEPOSIT_CONVERTED`), or refunded because it could not be converted within the promised window (`DEPOSIT_RETURNED`). See [Deposit Events](#deposit-events) — they follow account-scoped rules and durable delivery. ## Security Model @@ -113,7 +113,7 @@ Managers whose business clients hold EUR onramp accounts can subscribe to deposi ```json { "url": "https://manager.example.com/vortex/deposits", - "events": ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"] + "events": ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED"] } ``` @@ -185,6 +185,35 @@ Deposit `status` values: `pending`, `minted`, `held`, `returned` (provider state The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against, a volume-weighted average of the Coinbase Exchange EURC-USDC market over the five minutes before the swap, widened to sixty minutes when those five carry no trades (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. +### `DEPOSIT_RETURNED` + +Fired once per deposit that could not be converted within the promised window (or that an operator withdrew from conversion), after Vortex refunded the full EUR amount to the bank account the payment came from. Chunks already converted are swapped back and any shortfall is covered by Vortex; the payer always receives the exact issue amount. + +```json +{ + "eventId": "deposit-returned:9f6f6a7e-...", + "eventType": "DEPOSIT_RETURNED", + "timestamp": "2025-01-15T13:05:00.000Z", + "payload": { + "accountId": "c2a5...", + "profileId": "7d1b...", + "depositId": "9f6f6a7e-...", + "amountRaw": "100000000000000000000", + "currency": "eur", + "status": "refunded", + "txHash": "0x...", + "refund": { + "amount": "100.00", + "payerIbanMasked": "DE89…3000", + "redeemOrderId": "8c0fd7b1-...", + "recoverTxHash": "0x..." + } + } +} +``` + +`refund.amount` is the EUR amount refunded, to the cent — always the full issue amount. `payerIbanMasked` identifies the receiving account by its first and last four characters, `redeemOrderId` is Monerium's order for the outgoing SEPA transfer, and `recoverTxHash` the transaction that moved the deposit off the forwarding contract. + ### Delivery Semantics Deposit events are delivered **durably, at least once**: each event is persisted before sending and retried with growing backoff (1, 5, 15, 60, 180 minutes; abandoned after 6 attempts). Unlike transaction webhooks, a failing endpoint never deactivates the subscription — deliveries resume when your endpoint recovers, and outages lose nothing that has not exhausted its retries. Deduplicate on `eventId`; events are emitted only from subscription time forward (history is never replayed to a new subscription). diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index b408042ab..6d1421225 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -131,7 +131,7 @@ Register, sign, and start exactly as described in [Ramp Lifecycle](https://api-d Two things behave differently for managed children: - **Pricing** is resolved as: the child's own partner-pricing assignment if one exists, otherwise **your (the manager's) active assignment**, otherwise default Vortex pricing — identically for header-delegated calls and direct child credentials. Children automatically inherit your negotiated fees. -- **Transaction webhooks are not supported for managed subjects** — registration returns `400 MANAGED_PROFILE_UNSUPPORTED` with the header and `403` with a child credential. Poll the child-scoped ramp status and history endpoints instead. The exception is the deposit-event family for EUR onramp accounts: the **manager** subscribes with their own credential (no header) and receives `DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED` for all their children's accounts — see the Webhooks page. +- **Transaction webhooks are not supported for managed subjects** — registration returns `400 MANAGED_PROFILE_UNSUPPORTED` with the header and `403` with a child credential. Poll the child-scoped ramp status and history endpoints instead. The exception is the deposit-event family for EUR onramp accounts: the **manager** subscribes with their own credential (no header) and receives `DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED`/`DEPOSIT_RETURNED` for all their children's accounts — see the Webhooks page. ## Common Errors diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 6c192361a..5a9b51f52 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -12,7 +12,7 @@ A diff here means: check backward compatibility for live integrations, and keep ## packages/shared — partner wire contract (`src/endpoints`) ```text -ACCOUNT_WEBHOOK_EVENT_TYPES: readonly [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED] +ACCOUNT_WEBHOOK_EVENT_TYPES: readonly [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED, WebhookEventType.DEPOSIT_RETURNED] AcceptedRecipientInvite: { id: string; @@ -483,6 +483,28 @@ DepositReceivedWebhookPayload: { timestamp: string; } +DepositReturnedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RETURNED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + } & { + refund: { + amount: string; + payerIbanMasked: string; + recoverTxHash: null | string; + redeemOrderId: null | string; + }; + }; + timestamp: string; +} + DepositStatus: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" } DepositWebhookPayloadBase: { @@ -1675,7 +1697,7 @@ RegisterRampResponse: { } RegisterWebhookRequest: { - events?: Array; + events?: Array; quoteId?: string; sessionId?: string; url: string; @@ -1683,7 +1705,7 @@ RegisterWebhookRequest: { RegisterWebhookResponse: { createdAt: string; - events: Array; + events: Array; id: string; isActive: boolean; quoteId: null | string; @@ -2474,6 +2496,26 @@ WebhookDeliveryAttempt: { txHash: null | string; }; timestamp: string; + } | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RETURNED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + } & { + refund: { + amount: string; + payerIbanMasked: string; + recoverTxHash: null | string; + redeemOrderId: null | string; + }; + }; + timestamp: string; } | { eventId: string; eventType: WebhookEventType.STATUS_CHANGE; @@ -2501,7 +2543,7 @@ WebhookDeliveryAttempt: { webhookId: string; } -WebhookEventType: enum WebhookEventType { DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } +WebhookEventType: enum WebhookEventType { DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", DEPOSIT_RETURNED = "DEPOSIT_RETURNED", STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } WebhookPayload: { eventId: string; @@ -2543,6 +2585,26 @@ WebhookPayload: { txHash: null | string; }; timestamp: string; +} | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RETURNED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + } & { + refund: { + amount: string; + payerIbanMasked: string; + recoverTxHash: null | string; + redeemOrderId: null | string; + }; + }; + timestamp: string; } | { eventId: string; eventType: WebhookEventType.STATUS_CHANGE; diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 4b30ddc26..8be70ad9d 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -4,7 +4,8 @@ export enum WebhookEventType { TRANSACTION_CREATED = "TRANSACTION_CREATED", STATUS_CHANGE = "STATUS_CHANGE", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", - DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED" + DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", + DEPOSIT_RETURNED = "DEPOSIT_RETURNED" } /** @@ -13,7 +14,11 @@ export enum WebhookEventType { * transaction events in one webhook, and are delivered durably (at-least-once with * backoff) to the account's controlling manager. */ -export const ACCOUNT_WEBHOOK_EVENT_TYPES = [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED] as const; +export const ACCOUNT_WEBHOOK_EVENT_TYPES = [ + WebhookEventType.DEPOSIT_RECEIVED, + WebhookEventType.DEPOSIT_CONVERTED, + WebhookEventType.DEPOSIT_RETURNED +] as const; export enum DepositStatus { /** Provider order placed, EURe not minted yet. */ @@ -154,11 +159,32 @@ export interface DepositConvertedWebhookPayload { }; } +/** A deposit that could not be converted inside the promised window was refunded to the payer's bank account. */ +export interface DepositReturnedWebhookPayload { + /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.DEPOSIT_RETURNED; + timestamp: string; + payload: DepositWebhookPayloadBase & { + refund: { + /** The EUR amount refunded, to the cent ("1234.56"): always the full issue amount. */ + amount: string; + /** The payer's IBAN the refund went to, masked to its first and last four characters. */ + payerIbanMasked: string; + /** Monerium's redeem order id for the refund, when known. */ + redeemOrderId: string | null; + /** The on-chain transaction that moved the deposit off the forwarding contract for the refund. */ + recoverTxHash: string | null; + }; + }; +} + export type WebhookPayload = | TransactionCreatedWebhookPayload | StatusChangeWebhookPayload | DepositReceivedWebhookPayload - | DepositConvertedWebhookPayload; + | DepositConvertedWebhookPayload + | DepositReturnedWebhookPayload; export interface WebhookDeliveryAttempt { webhookId: string; From 82b1b9a2604210811c8373bdd51f1d2474970b19 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 17 Sep 2026 22:39:39 +0200 Subject: [PATCH 36/44] docs(repo): describe the DEPOSIT_RETURNED event in the ADR, spec and architecture --- docs/adr-0005-monerium-b2b-onramp.md | 3 ++- docs/architecture-monerium-b2b-onramp.md | 6 ++++-- docs/security-spec/02-signing-keys/server-side-signing.md | 2 +- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index d40c83545..a1bbf81fd 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -82,7 +82,8 @@ Supporting decisions, all in force: read-level projection is the path if unified history is ever wanted. Tables stay `monerium_*` (the legacy OAuth integration owns no tables; no collision). - **Deposit webhooks as a generic event family** (`DEPOSIT_RECEIVED` / - `DEPOSIT_CONVERTED`) on the public webhook contract, delivered durably (outbox, + `DEPOSIT_CONVERTED` / `DEPOSIT_RETURNED`, the last added 2026-09-17 for the refund + path) on the public webhook contract, delivered durably (outbox, at-least-once) to the partner manager. A cap-split deposit emits one final `DEPOSIT_CONVERTED` after all portions settle, with `conversions[]` and aggregate attributed USDC rather than a misleading event per chunk. diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 613453c80..3186d2d32 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -95,7 +95,7 @@ flowchart LR FWD -- "stuck payment" --> RECOV ONB -- "link address + request IBAN" --> MAPI MONI -- "association / config reads" --> MAPI - OUTBOX -- "DEPOSIT_RECEIVED / DEPOSIT_CONVERTED" --> PAPI + OUTBOX -- "DEPOSIT_RECEIVED / CONVERTED / RETURNED" --> PAPI PAPI -- "poll (delegation)" --> READ ``` @@ -327,7 +327,9 @@ and sends at most one transaction per account per cycle: wallet up to exactly the issue amount (or a surplus is swept back to the float), and a Monerium redeem order from the recovery wallet returns the exact amount to the payer's IBAN (`payer_iban` / `payer_name`, captured from the issue order's counterpart). The - deposit becomes `refunded` when Monerium processes the order. One refund runs at a + deposit becomes `refunded` when Monerium processes the order, and the partner receives + one `DEPOSIT_RETURNED` (refunded amount, masked payer IBAN, redeem order, recover + transaction). One refund runs at a time: every step re-derives what is left to do from the dedicated recovery wallet's balances (so a lost transaction hash never repeats a send), and the keeper refuses a second `recover` while one is in flight. A step that fails beyond its retries, a diff --git a/docs/security-spec/02-signing-keys/server-side-signing.md b/docs/security-spec/02-signing-keys/server-side-signing.md index b733926a0..c8d8eb907 100644 --- a/docs/security-spec/02-signing-keys/server-side-signing.md +++ b/docs/security-spec/02-signing-keys/server-side-signing.md @@ -20,7 +20,7 @@ All keys are loaded from environment variables. There is no HSM, secrets manager 6. **Missing mandatory keys MUST prevent server startup** — If `PENDULUM_FUNDING_SEED` or the currently required legacy-named `MOONBEAM_EXECUTOR_PRIVATE_KEY` compatibility fallback are absent, startup validation fails. This requirement reflects general EVM configuration compatibility, not active Moonbeam execution. 7. **The CryptoService singleton MUST initialize keys exactly once** — `initializeKeys()` should be called once at startup. Repeated calls should be idempotent or rejected. 8. **Webhook signatures MUST bind the delivery timestamp** — `X-Vortex-Signature` is computed over `` `${timestamp}.${body}` `` where `timestamp` is the value of the `X-Vortex-Timestamp` header (unix seconds). Consumers verify against that exact string, reject timestamps outside a bounded window, and deduplicate on the payload's `eventId`, which is unique per event and stable across delivery retries. A signature over the body alone MUST NOT verify. -9. **Every webhook row MUST have an owner principal** — the partner behind a partner-scoped secret key or the user behind a user-scoped secret key (`webhooks.partner_id` / `webhooks.user_id`). Registering a webhook for a quote requires that the owner principal owns the quote (`quote_tickets.partner_id` / `user_id` match); a foreign quote returns the same 404 as a nonexistent one. Deletion is owner-scoped with a uniform 404 for foreign IDs. Delivery matching filters webhooks by the quote's owner, so session-scoped subscriptions cannot receive another tenant's events. Ownerless rows are unrepresentable: migration 056 deletes any pre-existing rows (there were none in production) and a CHECK constraint requires exactly one of `partner_id`/`user_id`, so the delivery matcher has no ownerless branch — one would match every quote and reopen the cross-tenant hole for exactly the rows an attacker could have planted before ownership existed. An event whose quote owner cannot be resolved is delivered to nobody. The account-scoped deposit-event family (`DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED`) follows the same principle with a different owner derivation: subscriptions are user-owned only (registration rejects a partner credential, a quote/session target, and any mix with transaction events), and delivery matches exclusively `webhooks.user_id = `, resolved from `monerium_accounts.vortex_profile_id` through the active `managed_profiles` relationship (`webhook.service.ts findAccountEventWebhooks`, `monerium-b2b/manager-events.ts`). An account without a resolvable controlling manager delivers to nobody. These deliveries go through the durable `webhook_deliveries` outbox (unique per webhook and event, claim-based dispatch with backoff) rather than the in-process retry loop, and a failing endpoint is never auto-deactivated. +9. **Every webhook row MUST have an owner principal** — the partner behind a partner-scoped secret key or the user behind a user-scoped secret key (`webhooks.partner_id` / `webhooks.user_id`). Registering a webhook for a quote requires that the owner principal owns the quote (`quote_tickets.partner_id` / `user_id` match); a foreign quote returns the same 404 as a nonexistent one. Deletion is owner-scoped with a uniform 404 for foreign IDs. Delivery matching filters webhooks by the quote's owner, so session-scoped subscriptions cannot receive another tenant's events. Ownerless rows are unrepresentable: migration 056 deletes any pre-existing rows (there were none in production) and a CHECK constraint requires exactly one of `partner_id`/`user_id`, so the delivery matcher has no ownerless branch — one would match every quote and reopen the cross-tenant hole for exactly the rows an attacker could have planted before ownership existed. An event whose quote owner cannot be resolved is delivered to nobody. The account-scoped deposit-event family (`DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED`/`DEPOSIT_RETURNED`) follows the same principle with a different owner derivation: subscriptions are user-owned only (registration rejects a partner credential, a quote/session target, and any mix with transaction events), and delivery matches exclusively `webhooks.user_id = `, resolved from `monerium_accounts.vortex_profile_id` through the active `managed_profiles` relationship (`webhook.service.ts findAccountEventWebhooks`, `monerium-b2b/manager-events.ts`). An account without a resolvable controlling manager delivers to nobody. These deliveries go through the durable `webhook_deliveries` outbox (unique per webhook and event, claim-based dispatch with backoff) rather than the in-process retry loop, and a failing endpoint is never auto-deactivated. 10. **Webhook callback URLs MUST NOT reach internal infrastructure (SSRF)** — registration accepts only HTTPS URLs without embedded credentials, rejects IP-literal hosts outside publicly routable space, and resolves the hostname — rejecting it if it resolves to a non-public address (a host that does not resolve yet is allowed, since DNS is often provisioned after integration setup and delivery re-validates anyway). Before every delivery the hostname is re-resolved and every resolved address must be public; redirects are rejected (`redirect: "error"`). Address classification follows the IANA special-purpose registries for both IPv4 and IPv6, so documentation/benchmarking/6to4/site-local ranges are treated as non-public. **Residual risk (accepted):** a resolve-then-connect race remains — the guard and `fetch` resolve independently, so a DNS-rebinding attacker controlling the domain can answer differently for each. Closing it requires pinning the validated address for the connection (preserving Host/SNI) or an egress proxy enforcing destination policy; tracked as follow-up. Exploitation requires an authenticated secret key, and deliveries are POSTs whose response body is never returned to the registrant (blind SSRF). ## Threat Vectors & Mitigations diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index a0dbff9a8..d1a0430db 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -28,7 +28,7 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 13. **Onboarding provider writes are exactly-once and provider reads are scoped** — the automated link (`POST /addresses`) and IBAN request (`POST /ibans`) run through the profile-scoped `financial_operations` ledger (flow `monerium-b2b-onboarding`), so crashes and retries never repeat a claimed provider call; interrupted calls reconcile by re-reading the linked addresses / issued IBANs. Every linked-address and IBAN selection requires the exact mapped profile, configured Monerium chain, and forwarder address; multiple exact IBAN matches are rejected rather than selected arbitrarily. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. 15. **The read surface is effective-user scoped and accepts no selectors** — `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` resolve the account strictly from the acting profile (manager delegation via `X-Managed-Profile-Id` under the standard managed-profile authorization with EU corridor + business policy, or the child's own credential); no caller-supplied account, profile, or IBAN identifier is accepted, a foreign manager gets the uniform managed-profile 403, and R09 `unattr:` synthetic deposit rows are never returned (`monerium-b2b-account-read.integration.test.ts`). -16. **Manager deposit events are final, chain-backed, and manager-only** — `DEPOSIT_RECEIVED` requires the real chain id, transaction hash, log index, and block number, so a provider order alone cannot claim that funds landed. `DEPOSIT_RECEIVED` fires for any state past the mint (the keeper may already have started converting within the cycle). `DEPOSIT_CONVERTED` fires once only after the deposit is `forwarded` and its confirmed `forward` execution is `NOTIFY_CONFIRMATION_DEPTH` blocks deep; its `conversions[]` lists the deposit's confirmed chunk swaps (EURe in, net USDC, reference rate, fee and subsidy per chunk in the `execution` block), `forwardTxHash` is the single transfer to the destination and payload `usdcNetRaw` is the amount that transfer carried. Per-deposit markers prevent replay to late subscribers. Deliveries go only to the controlling manager's webhooks through the durable outbox; `unattr:` rows never emit (`manager-events.test.ts`). +16. **Manager deposit events are final, chain-backed, and manager-only** — `DEPOSIT_RECEIVED` requires the real chain id, transaction hash, log index, and block number, so a provider order alone cannot claim that funds landed. `DEPOSIT_RECEIVED` fires for any state past the mint (the keeper may already have started converting within the cycle). `DEPOSIT_CONVERTED` fires once only after the deposit is `forwarded` and its confirmed `forward` execution is `NOTIFY_CONFIRMATION_DEPTH` blocks deep; its `conversions[]` lists the deposit's confirmed chunk swaps (EURe in, net USDC, reference rate, fee and subsidy per chunk in the `execution` block), `forwardTxHash` is the single transfer to the destination and payload `usdcNetRaw` is the amount that transfer carried. `DEPOSIT_RETURNED` fires once when a deposit is `refunded`, carrying the refunded EUR amount, the payer's IBAN masked to its first and last four characters (the full IBAN never leaves the backend), Monerium's redeem order id and the `recover` transaction. Per-deposit markers prevent replay to late subscribers. Deliveries go only to the controlling manager's webhooks through the durable outbox; `unattr:` rows never emit (`manager-events.test.ts`). 17. **Account lifecycle transitions are explicit** — `onboarding → active`, `active → {suspended, closed}`, and `suspended → {active, closed}` are the only state changes; `closed` is terminal. Repeating the current status is idempotent. The admin controller returns 409 for every invalid edge, including reopening a closed account or moving an active account back to onboarding (`moneriumB2b.controller.test.ts`). ## Keeper From 154ca1f469e6690f04da6d15767cb83963c7d772 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 18 Sep 2026 10:33:57 +0200 Subject: [PATCH 37/44] feat(repo): cap each swap's subsidy at a keeper-supplied maximum The keeper now passes maxSubsidy into swap(): its escalation tier for the time the chunk has waited. The contract binds the vault's top-up to it at execution, so a fill that moved between the quote and the swap cannot draw more than the tier; the vault's own cap and budget still apply on top. The invariant suite proves the vault never pays above the caller's cap. --- .../src/VortexForwarder.sol | 24 +++-- .../test/VortexForwarder.fork.t.sol | 5 +- .../test/VortexForwarder.invariants.t.sol | 84 ++++++++++------ .../test/VortexForwarder.t.sol | 95 ++++++++++++------- 4 files changed, 140 insertions(+), 68 deletions(-) diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index df2eec6e2..ed248d8a0 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -202,6 +202,7 @@ contract VortexForwarder { error NoPendingFeePolicy(); error ReferenceOutOfBand(); error SubsidyUnavailable(); + error SubsidyAboveCap(); error DelayNotElapsed(); error TransferFailed(); error Reentrancy(); @@ -355,7 +356,16 @@ contract VortexForwarder { /// @param amountIn Exactly how much EURe to convert: at least minSwapAmount, at most /// perSwapCap and the balance. Explicit so the keeper's chunking maps every /// swap to one bank payment. - function swap(uint256 referenceRate, uint256 routeIndex, uint256 amountIn) external nonReentrant whenNotPaused { + /// @param maxSubsidy The most USDC the caller lets the vault pay for this swap: the + /// keeper's escalation tier for the time the chunk has waited (docs, fees + /// section). Binding at execution, so a fill that moved between the quote and + /// the swap cannot draw more than the tier; the vault's own cap and budget + /// still apply on top. Ignored on the permissionless path, which pays nothing. + function swap(uint256 referenceRate, uint256 routeIndex, uint256 amountIn, uint256 maxSubsidy) + external + nonReentrant + whenNotPaused + { bool privileged = _privileged(); if (!privileged) _requireBatchAge(TRIGGER_DELAY, NotAuthorizedYet.selector); @@ -366,7 +376,7 @@ contract VortexForwarder { uint256 referenceUsed = privileged ? _checkedReference(referenceRate, oraclePrice) : oraclePrice; uint256 usdcReceived = _swap(routeIndex, amountIn); - (uint256 fee, uint256 subsidy) = _settle(amountIn, usdcReceived, referenceUsed, privileged); + (uint256 fee, uint256 subsidy) = _settle(amountIn, usdcReceived, referenceUsed, privileged, maxSubsidy); // The oracle floor is enforced on the client's NET (fill - fee + subsidy), not on // the raw fill: a subsidized fill may sit below it, and a subsidy must never @@ -406,10 +416,11 @@ contract VortexForwarder { /// - fill between the floor and the target: no fee, no subsidy; /// - fill below reference x (1 - floorPpm): a privileged swap draws the shortfall /// from the vault onto this clone; a permissionless swap pays nothing. - /// The vault reverts (and so does the swap) when its cap, budget, pause or - /// balance cannot cover the shortfall, and the forwarder reverts unless exactly - /// the shortfall arrived here — a swap is never partially subsidized. - function _settle(uint256 amountIn, uint256 usdcReceived, uint256 referenceUsed, bool privileged) + /// The caller's `maxSubsidy` bounds the shortfall first; the vault reverts (and so + /// does the swap) when its cap, budget, pause or balance cannot cover it, and the + /// forwarder reverts unless exactly the shortfall arrived here — a swap is never + /// partially subsidized. + function _settle(uint256 amountIn, uint256 usdcReceived, uint256 referenceUsed, bool privileged, uint256 maxSubsidy) internal returns (uint256 fee, uint256 subsidy) { @@ -426,6 +437,7 @@ contract VortexForwarder { if (usdcReceived >= floorOut || !privileged) return (0, 0); subsidy = floorOut - usdcReceived; + if (subsidy > maxSubsidy) revert SubsidyAboveCap(); address vault = FACTORY.subsidyVault(); if (vault == address(0)) revert SubsidyUnavailable(); // The vault is guardian-settable without a timelock, so its word is not enough: diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol index 9500d0601..385bfd77f 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -5,6 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {VortexForwarder, IERC20, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; +import {NO_CAP} from "./VortexForwarder.t.sol"; interface IUniswapV3Factory { function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address); @@ -126,7 +127,7 @@ contract VortexForwarderForkTest is Test { uint256 fair = (amountIn * uint256(answer)) / 1e20; // 6-dec USDC at oracle rate vm.prank(keeper); - fwd.swap(_reference(), 0, amountIn); + fwd.swap(_reference(), 0, amountIn, NO_CAP); assertEq(IERC20Meta(USDC).balanceOf(destination), 0, "USDC must wait on the clone until forward"); // With the vault funded the client lands at or above the policy floor (15 bps), @@ -146,7 +147,7 @@ contract VortexForwarderForkTest is Test { function test_fork_chunkedPayment_accumulatesThenRecovers() public onlyForked { deal(EURE_V2, address(fwd), 12_000e18); // cap is 10k vm.prank(keeper); - fwd.swap(_reference(), 0, 10_000e18); + fwd.swap(_reference(), 0, 10_000e18, NO_CAP); assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 2_000e18); uint256 converted = IERC20Meta(USDC).balanceOf(address(fwd)); assertGt(converted, 0); diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol index cd28fd5ac..8401f4f00 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -5,7 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {VortexForwarder, IERC20, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; -import {MockERC20, MockOracle, MockRouter} from "./VortexForwarder.t.sol"; +import {MockERC20, MockOracle, MockRouter, NO_CAP} from "./VortexForwarder.t.sol"; /// Randomized action handler. Ghost variables track every token unit entering the /// system so the invariants below can assert exit-path exhaustiveness (plan §2.3.1): @@ -41,6 +41,8 @@ contract ForwarderHandler is Test { uint256 public earlyRecoveries; /// Swaps that changed an already-armed batch marker. Must stay zero. uint256 public markerRetimes; + /// Swaps whose vault subsidy exceeded the caller's maxSubsidy. Must stay zero. + uint256 public capViolations; uint32 public immutable INITIAL_TARGET_PPM = 1_250; uint32 public immutable INITIAL_FLOOR_PPM = 1_500; uint256 public constant VAULT_FUNDING = 10_000e6; @@ -109,42 +111,63 @@ contract ForwarderHandler is Test { /// Router pays a randomized amount around the fair oracle value: far below exercises /// the floor/cap reverts, slightly below the subsidy path, above the fee path. - function keeperSwap(uint96 raw, uint96 rawAmount) external { - _swapAs(keeper, raw, rawAmount); + function keeperSwap(uint96 raw, uint96 rawAmount, uint32 rawCap) external { + _swapAs(keeper, raw, rawAmount, rawCap); } - function randoSwap(uint96 raw, uint96 rawAmount) external { - _swapAs(rando, raw, rawAmount); + function randoSwap(uint96 raw, uint96 rawAmount, uint32 rawCap) external { + _swapAs(rando, raw, rawAmount, rawCap); } - function _swapAs(address caller, uint96 raw, uint96 rawAmount) internal { + struct SwapSnapshot { + uint256 routerMinted; + uint256 vault; + uint256 clone; + uint256 fee; + uint64 marker; + } + + function _swapAs(address caller, uint96 raw, uint96 rawAmount, uint32 rawCap) internal { oracle.set(1.14e8, block.timestamp); - uint256 balance = eure.balanceOf(address(fwd)); - uint256 ceiling = balance > 10_000e18 ? 10_000e18 : balance; - // Mostly legal amounts; occasionally out of bounds to exercise the reverts. - uint256 amountIn = bound(uint256(rawAmount), 0, ceiling + 30e18); - uint256 fair = (amountIn * REFERENCE) / 1e20; - uint256 payout = bound(uint256(raw), (fair * 95) / 100, (fair * 105) / 100); - router.setNextOut(payout); - - uint256 routerUsdcBefore = usdc.totalMinted(); - uint256 vaultBefore = usdc.balanceOf(address(vault)); - uint256 cloneBefore = usdc.balanceOf(address(fwd)); - uint256 feeBefore = usdc.balanceOf(feeRecipient); - uint64 markerBefore = fwd.batchOpenedAt(); + (uint256 amountIn, uint256 fair) = _arrangeFill(raw, rawAmount); + // The keeper's tier: sometimes nothing, sometimes a few USDC, sometimes unbounded. + uint256 maxSubsidy = rawCap % 3 == 0 ? NO_CAP : uint256(rawCap) % 12e6; + SwapSnapshot memory before = SwapSnapshot({ + routerMinted: usdc.totalMinted(), + vault: usdc.balanceOf(address(vault)), + clone: usdc.balanceOf(address(fwd)), + fee: usdc.balanceOf(feeRecipient), + marker: fwd.batchOpenedAt() + }); vm.prank(caller); - try fwd.swap(REFERENCE, 0, amountIn) { - uint256 paid = usdc.totalMinted() - routerUsdcBefore; - ghostUsdcPaidByRouter += paid; - ghostSubsidyPaid += vaultBefore - usdc.balanceOf(address(vault)); - uint256 net = usdc.balanceOf(address(fwd)) - cloneBefore; // fill - fee + subsidy - if (net < (fair * 9_940) / 10_000) pricingViolations++; // Chainlink - 60 bps - if (caller == keeper && net < (fair * (1_000_000 - fwd.floorPpm())) / 1_000_000) pricingViolations++; - if (usdc.balanceOf(feeRecipient) - feeBefore > paid / 100) pricingViolations++; // MAX_FEE_PPM - if (markerBefore != 0 && fwd.batchOpenedAt() != markerBefore) markerRetimes++; + try fwd.swap(REFERENCE, 0, amountIn, maxSubsidy) { + _recordSwap(caller, fair, maxSubsidy, before); } catch {} } + /// Mostly legal amounts (occasionally out of bounds to exercise the reverts) and a + /// router payout randomized around the fair oracle value. + function _arrangeFill(uint96 raw, uint96 rawAmount) internal returns (uint256 amountIn, uint256 fair) { + uint256 balance = eure.balanceOf(address(fwd)); + uint256 ceiling = balance > 10_000e18 ? 10_000e18 : balance; + amountIn = bound(uint256(rawAmount), 0, ceiling + 30e18); + fair = (amountIn * REFERENCE) / 1e20; + router.setNextOut(bound(uint256(raw), (fair * 95) / 100, (fair * 105) / 100)); + } + + function _recordSwap(address caller, uint256 fair, uint256 maxSubsidy, SwapSnapshot memory before) internal { + uint256 paid = usdc.totalMinted() - before.routerMinted; + ghostUsdcPaidByRouter += paid; + uint256 subsidy = before.vault - usdc.balanceOf(address(vault)); + ghostSubsidyPaid += subsidy; + if (subsidy > maxSubsidy) capViolations++; + uint256 net = usdc.balanceOf(address(fwd)) - before.clone; // fill - fee + subsidy + if (net < (fair * 9_940) / 10_000) pricingViolations++; // Chainlink - 60 bps + if (caller == keeper && net < (fair * (1_000_000 - fwd.floorPpm())) / 1_000_000) pricingViolations++; + if (usdc.balanceOf(feeRecipient) - before.fee > paid / 100) pricingViolations++; // MAX_FEE_PPM + if (before.marker != 0 && fwd.batchOpenedAt() != before.marker) markerRetimes++; + } + function keeperForward(uint96 raw) external { uint256 amount = bound(uint256(raw), 0, usdc.balanceOf(address(fwd)) + 1e6); vm.prank(keeper); @@ -260,6 +283,11 @@ contract VortexForwarderInvariantTest is Test { assertEq(handler.markerRetimes(), 0, "a swap re-timed an open batch"); } + /// The vault never pays more for a swap than the caller allowed (A+). + function invariant_subsidyNeverAboveCallerCap() public view { + assertEq(handler.capViolations(), 0, "the vault paid above the caller's maxSubsidy"); + } + /// Config changes only through their authorized paths: the fee policy moves /// exclusively via the guardian's timelocked setter (P11 ghost model tracks every /// legal transition — a rando call or an early apply can never move it), stays diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 49aebb3be..80bf90464 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -8,6 +8,8 @@ import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; // Reference rate the keeper passes in the unit tests; equal to the mock oracle price. uint256 constant REF = 1.14e8; +// "No keeper tier": lets the vault's own cap decide, as the tests did before A+. +uint256 constant NO_CAP = type(uint256).max; contract MockERC20 { string public name; @@ -88,7 +90,7 @@ contract MockRouter { /// Malicious router that tries to re-enter swap during the swap. contract MockReentrantRouter { function exactInput(ISwapRouter02.ExactInputParams calldata) external payable returns (uint256) { - VortexForwarder(msg.sender).swap(REF, 0, 1_000e18); // must revert via reentrancy guard + VortexForwarder(msg.sender).swap(REF, 0, 1_000e18, NO_CAP); // must revert via reentrancy guard return 0; } } @@ -201,7 +203,7 @@ contract VortexForwarderTest is Test { function _keeperSwap(uint256 amountIn) internal { vm.prank(keeper); - fwd.swap(REF, 0, amountIn); + fwd.swap(REF, 0, amountIn, NO_CAP); } // ---------------------------------------------------------------- EIP-1271 @@ -364,11 +366,11 @@ contract VortexForwarderTest is Test { router.setNextOut(ORACLE_FLOOR_1K - 1); vm.prank(rando); vm.expectRevert(VortexForwarder.InsufficientOutput.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); router.setNextOut(ORACLE_FLOOR_1K); vm.prank(rando); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); assertEq(usdc.balanceOf(address(fwd)), ORACLE_FLOOR_1K); } @@ -379,7 +381,7 @@ contract VortexForwarderTest is Test { skip(53 hours); // just past the 52h P8 window vm.prank(keeper); vm.expectRevert(VortexForwarder.StalePrice.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); } function test_swap_publicOnlyAfterTriggerDelay() public { @@ -388,17 +390,17 @@ contract VortexForwarderTest is Test { vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); fwd.poke(); vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); skip(TRIGGER_DELAY + 1); oracle.set(1.14e8, block.timestamp); vm.prank(rando); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); } @@ -408,11 +410,11 @@ contract VortexForwarderTest is Test { oracle.set(0, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); oracle.set(-1, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); } function test_swap_amountBounds() public { @@ -420,12 +422,12 @@ contract VortexForwarderTest is Test { router.setNextOut(TARGET_10K); vm.startPrank(keeper); vm.expectRevert(VortexForwarder.BelowMinimum.selector); - fwd.swap(REF, 0, 24e18); + fwd.swap(REF, 0, 24e18, NO_CAP); vm.expectRevert(VortexForwarder.InvalidAmount.selector); - fwd.swap(REF, 0, 10_000e18 + 1); // above the cap - fwd.swap(REF, 0, 10_000e18); + fwd.swap(REF, 0, 10_000e18 + 1, NO_CAP); // above the cap + fwd.swap(REF, 0, 10_000e18, NO_CAP); vm.expectRevert(VortexForwarder.InvalidAmount.selector); - fwd.swap(REF, 0, 5_000e18 + 1); // above the balance + fwd.swap(REF, 0, 5_000e18 + 1, NO_CAP); // above the balance vm.stopPrank(); assertEq(eure.balanceOf(address(fwd)), 5_000e18); // remainder awaits the next chunk } @@ -460,7 +462,7 @@ contract VortexForwarderTest is Test { fwd.setGuardianPaused(true); // test contract is factory guardian vm.startPrank(keeper); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); vm.expectRevert(VortexForwarder.Paused.selector); fwd.forward(TARGET_1K); vm.expectRevert(VortexForwarder.Paused.selector); @@ -471,7 +473,7 @@ contract VortexForwarderTest is Test { factory.setGlobalPaused(true); vm.startPrank(keeper); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); vm.expectRevert(VortexForwarder.Paused.selector); fwd.forward(TARGET_1K); vm.stopPrank(); @@ -553,7 +555,7 @@ contract VortexForwarderTest is Test { eure.mint(address(fwd2), 1_000e18); vm.prank(keeper); vm.expectRevert(VortexForwarder.Reentrancy.selector); - fwd2.swap(REF, 0, 1_000e18); + fwd2.swap(REF, 0, 1_000e18, NO_CAP); } // ---------------------------------------------------------------- recovery @@ -699,7 +701,7 @@ contract VortexForwarderTest is Test { _fund(1_000e18); router.setNextOut(TARGET_1K); vm.prank(keeper); - fwd.swap(REF, 1, 1_000e18); + fwd.swap(REF, 1, 1_000e18, NO_CAP); assertEq(router.lastPath(), direct); } @@ -709,12 +711,12 @@ contract VortexForwarderTest is Test { vm.prank(keeper); vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); - fwd.swap(REF, 7, 1_000e18); + fwd.swap(REF, 7, 1_000e18, NO_CAP); factory.setRouteEnabled(0, false); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidRoute.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); factory.setRouteEnabled(7, false); @@ -797,12 +799,41 @@ contract VortexForwarderTest is Test { assertEq(usdc.balanceOf(address(fwd)), FLOOR_1K); } + /// A+: the keeper's tier binds at execution. A fill that needs more than the caller + /// allowed reverts the whole swap, whatever the vault would have paid. + function test_swap_subsidyAboveKeeperCap_revertsTheWholeSwap() public { + _fund(1_000e18); + router.setNextOut(1_136e6); // needs 2.29 USDC + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyAboveCap.selector); + fwd.swap(REF, 0, 1_000e18, 2_290_000 - 1); + assertEq(eure.balanceOf(address(fwd)), 1_000e18); + assertEq(usdc.balanceOf(address(vault)), 1_000e6); + + vm.prank(keeper); + fwd.swap(REF, 0, 1_000e18, 2_290_000); // exactly the shortfall: allowed + assertEq(usdc.balanceOf(address(fwd)), FLOOR_1K); + assertEq(usdc.balanceOf(address(vault)), 1_000e6 - 2_290_000); + } + + function test_swap_keeperCapZero_onlyFillsAtOrAboveTheFloorSucceed() public { + _fund(1_000e18); + router.setNextOut(1_136e6); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyAboveCap.selector); + fwd.swap(REF, 0, 1_000e18, 0); // the ladder's first tiers: wait for the market + router.setNextOut(FLOOR_1K); + vm.prank(keeper); + fwd.swap(REF, 0, 1_000e18, 0); + assertEq(usdc.balanceOf(address(fwd)), FLOOR_1K); + } + function test_swap_subsidyOverCap_revertsTheWholeSwap() public { _fund(1_000e18); router.setNextOut(1_130e6); // needs 8.29 USDC; the cap is 50 bps of 1140 = 5.7 USDC vm.prank(keeper); vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); assertEq(eure.balanceOf(address(fwd)), 1_000e18); assertEq(usdc.balanceOf(address(fwd)), 0); } @@ -813,7 +844,7 @@ contract VortexForwarderTest is Test { router.setNextOut(1_130e6); // below both floors; the 8.29 USDC top-up the vault "pays" never arrives vm.prank(keeper); vm.expectRevert(VortexForwarder.SubsidyUnavailable.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); assertEq(eure.balanceOf(address(fwd)), 1_000e18); assertEq(usdc.balanceOf(address(fwd)), 0); } @@ -824,7 +855,7 @@ contract VortexForwarderTest is Test { router.setNextOut(1_136e6); vm.prank(keeper); vm.expectRevert(VortexSubsidyVault.BudgetExhausted.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); } function test_swap_withoutVault_onlyFillsAtOrAboveTheFloorSucceed() public { @@ -833,7 +864,7 @@ contract VortexForwarderTest is Test { router.setNextOut(1_136e6); vm.prank(keeper); vm.expectRevert(VortexForwarder.SubsidyUnavailable.selector); - fwd.swap(REF, 0, 1_000e18); + fwd.swap(REF, 0, 1_000e18, NO_CAP); router.setNextOut(1_145e6); _keeperSwap(1_000e18); @@ -848,7 +879,7 @@ contract VortexForwarderTest is Test { router.setNextOut(1_127e6); vm.prank(keeper); vm.expectRevert(VortexForwarder.InsufficientOutput.selector); - fwd.swap(lowReference, 0, 1_000e18); + fwd.swap(lowReference, 0, 1_000e18, NO_CAP); assertEq(usdc.balanceOf(address(vault)), 1_000e6, "subsidy transfer must be undone"); } @@ -860,7 +891,7 @@ contract VortexForwarderTest is Test { router.setNextOut(1_128e6); vm.prank(keeper); vm.expectRevert(VortexForwarder.InsufficientOutput.selector); - fwd.swap(lowReference, 0, 1_000e18); + fwd.swap(lowReference, 0, 1_000e18, NO_CAP); assertEq(usdc.balanceOf(feeRecipient), 0, "fee transfer must be undone"); assertEq(eure.balanceOf(address(fwd)), 1_000e18); } @@ -870,12 +901,12 @@ contract VortexForwarderTest is Test { router.setNextOut(1_150e6); vm.startPrank(keeper); vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); - fwd.swap((REF * 10_101) / 10_000, 0, 1_000e18); // 101 bps above + fwd.swap((REF * 10_101) / 10_000, 0, 1_000e18, NO_CAP); // 101 bps above vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); - fwd.swap((REF * 9_899) / 10_000, 0, 1_000e18); // 101 bps below + fwd.swap((REF * 9_899) / 10_000, 0, 1_000e18, NO_CAP); // 101 bps below vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); - fwd.swap(0, 0, 1_000e18); - fwd.swap((REF * 10_100) / 10_000, 0, 1_000e18); // exactly 100 bps: allowed + fwd.swap(0, 0, 1_000e18, NO_CAP); + fwd.swap((REF * 10_100) / 10_000, 0, 1_000e18, NO_CAP); // exactly 100 bps: allowed vm.stopPrank(); assertGt(usdc.balanceOf(address(fwd)), 0); } @@ -887,14 +918,14 @@ contract VortexForwarderTest is Test { oracle.set(1.14e8, block.timestamp); router.setNextOut(1_136e6); // below the floor: the client simply gets the fill vm.prank(rando); - fwd.swap(1, 0, 1_000e18); // garbage reference is ignored on this path + fwd.swap(1, 0, 1_000e18, NO_CAP); // garbage reference is ignored on this path assertEq(usdc.balanceOf(address(fwd)), 1_136e6); assertEq(usdc.balanceOf(address(vault)), 1_000e6); _fund(1_000e18); router.setNextOut(1_145e6); // above the Chainlink-based target: the fee still applies vm.prank(rando); - fwd.swap(999, 0, 1_000e18); + fwd.swap(999, 0, 1_000e18, NO_CAP); assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); } From d11737a3398f18ba858466640516ce89d09297d4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 18 Sep 2026 10:33:58 +0200 Subject: [PATCH 38/44] feat(api): escalate the subsidy tier with a chunk's waiting time Product wants the keeper to wait for the market before Vortex pays a shortfall, and to pay more the longer a chunk waits. MONERIUM_B2B_SUBSIDY_LADDER holds the schedule (seconds waited to max bps of the reference value; launch: nothing for six minutes, then 10 bps more every two minutes to 50, then 100). The clock runs per chunk, from the mint or the previous chunk's confirmation; the tier is passed into swap(..., maxSubsidy) and persisted for the calldata-exact crash recovery. Deferrals log the shortfall so the ladder can be tuned from data, and the keeper cycle is configurable (20 s) so a waiting chunk is re-quoted often. --- .../src/api/services/monerium-b2b/chain.ts | 3 +- .../monerium-b2b/conversion-executor.test.ts | 94 ++++++++++++-- .../monerium-b2b/conversion-executor.ts | 122 +++++++++++++++--- .../src/api/workers/monerium-b2b.worker.ts | 3 +- apps/api/src/config/vars.ts | 39 ++++++ .../083-add-conversion-max-subsidy.ts | 15 +++ .../moneriumConversionExecution.model.ts | 9 ++ 7 files changed, 249 insertions(+), 36 deletions(-) create mode 100644 apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index 5eac177db..d695df0fb 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -113,7 +113,8 @@ export const forwarderAbi = [ inputs: [ { name: "referenceRate", type: "uint256" }, { name: "routeIndex", type: "uint256" }, - { name: "amountIn", type: "uint256" } + { name: "amountIn", type: "uint256" }, + { name: "maxSubsidy", type: "uint256" } ], name: "swap", outputs: [], 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 05af7c3ae..44b365323 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 @@ -9,13 +9,16 @@ import MoneriumConversionExecution, { } from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import * as chain from "./chain"; +import { parseSubsidyLadder } from "../../../config/vars"; import { broadcastExecutionSequence, + chunkElapsedSeconds, classifyHashlessPending, conversionAmountsFromSwapEvent, expectedCalldata, finalizeExecution, isExpectedTransaction, + maxSubsidyBpsFor, planAction, planChunk, pricePlannedSwap, @@ -60,6 +63,41 @@ describe("planChunk", () => { }); }); +// The subsidy ladder (adr-0005 amendment 2026-09-18): how much of a shortfall Vortex pays +// after a chunk has waited, from a "seconds:bps" config string. +describe("subsidy ladder", () => { + const ladder = parseSubsidyLadder(undefined); + + it("parses the launch ladder and looks the tier up by waiting time", () => { + expect(ladder[0]).toEqual({ afterSeconds: 0, maxSubsidyBps: 0 }); + expect(ladder.at(-1)).toEqual({ afterSeconds: 960, maxSubsidyBps: 100 }); + expect(maxSubsidyBpsFor(ladder, 0)).toBe(0); + expect(maxSubsidyBpsFor(ladder, 359)).toBe(0); + expect(maxSubsidyBpsFor(ladder, 360)).toBe(10); + expect(maxSubsidyBpsFor(ladder, 700)).toBe(30); + expect(maxSubsidyBpsFor(ladder, 5_000)).toBe(100); // holds the last step until the refund deadline + }); + + it("rejects a malformed or non-ascending ladder", () => { + expect(() => parseSubsidyLadder("60:10")).toThrow("start at 0"); + expect(() => parseSubsidyLadder("0:0,120:20,60:30")).toThrow("ascend"); + expect(() => parseSubsidyLadder("0:0,120:20,240:10")).toThrow("ascend"); + expect(() => parseSubsidyLadder("0:x")).toThrow(":"); + expect(parseSubsidyLadder("0:0,120:25")).toEqual([ + { afterSeconds: 0, maxSubsidyBps: 0 }, + { afterSeconds: 120, maxSubsidyBps: 25 } + ]); + }); + + it("counts a chunk's wait from the mint or from the previous chunk's confirmation", () => { + const now = 1_800_000_000_000; + const deposit = { createdAt: new Date(now - 900_000), mintedAt: new Date(now - 600_000) }; + expect(chunkElapsedSeconds(deposit, null, now)).toBe(600); + expect(chunkElapsedSeconds(deposit, new Date(now - 120_000), now)).toBe(120); + expect(chunkElapsedSeconds({ createdAt: new Date(now - 300_000), mintedAt: null }, null, now)).toBe(300); + }); +}); + function swapRow(eureInRaw: bigint, usdcNetRaw: bigint): MoneriumConversionExecution { return { eureInRaw: eureInRaw.toString(), @@ -94,7 +132,13 @@ describe("planAction", () => { recoveryInFlight: false }; const deposit = (id: string, status: MoneriumFiatDepositStatus, amount: bigint) => - ({ amountRaw: amount.toString(), id, status }) as MoneriumFiatDeposit; + ({ + amountRaw: amount.toString(), + createdAt: new Date(base.nowMs - 10 * 60_000), + id, + mintedAt: new Date(base.nowMs - 7 * 60_000), + status + }) as MoneriumFiatDeposit; const withSwaps = (row: MoneriumFiatDeposit, swaps: MoneriumConversionExecution[]) => ({ deposit: row, state: settlementState(row, swaps) @@ -102,7 +146,8 @@ describe("planAction", () => { it("swaps the next chunk of the oldest convertible deposit", () => { const plan = planAction([withSwaps(deposit("a", MoneriumFiatDepositStatus.Minted, 25_000n * EUR), [])], base); - expect(plan).toMatchObject({ amountIn: 10_000n * EUR, kind: "swap" }); + // The first chunk's clock runs from the mint (seven minutes ago here). + expect(plan).toMatchObject({ amountIn: 10_000n * EUR, elapsedSeconds: 420, kind: "swap" }); }); it("forwards a deposit once every chunk is confirmed, with the sum of the nets", () => { @@ -183,6 +228,7 @@ describe("projectSwap", () => { amountIn: 1_000n * EUR, floorPpm: 1_500, maxFeePpm: 10_000, + maxSubsidyRaw: 1_140n * USDC, // an unbounded tier: the vault decides oracleDecimals: 8, oracleRaw: 114_000_000n, referenceRaw: 114_000_000n, @@ -191,6 +237,13 @@ describe("projectSwap", () => { vault }; + it("defers a shortfall above the keeper's current tier before asking the vault", () => { + // 2.29 USDC needed; a 10 bps tier of 1140 allows 1.14. + expect(projectSwap({ ...base, maxSubsidyRaw: 1_140_000n, quotedOut: 1_136n * USDC }).defer).toContain("current tier"); + expect(projectSwap({ ...base, maxSubsidyRaw: 0n, quotedOut: 1_136n * USDC }).defer).toContain("current tier"); + expect(projectSwap({ ...base, maxSubsidyRaw: 2_290_000n, quotedOut: 1_136n * USDC }).defer).toBeNull(); + }); + it("takes the surplus above the target as fee, capped at MAX_FEE_PPM", () => { expect(projectSwap({ ...base, quotedOut: 1_145n * USDC })).toEqual({ defer: null, @@ -262,13 +315,19 @@ describe("projectSwap", () => { }); describe("expectedCalldata", () => { - const swap = { eureInRaw: (1_000n * EUR).toString(), kind: MoneriumConversionExecutionKind.Swap, usdcNetRaw: null }; + const swap = { + eureInRaw: (1_000n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Swap, + maxSubsidyRaw: "2290000", + usdcNetRaw: null + }; - it("rebuilds a swap's calldata from the persisted reference, route and chunk, or nothing", () => { + it("rebuilds a swap's calldata from the persisted reference, route, chunk and tier cap, or nothing", () => { expect(expectedCalldata({ ...swap, referenceRateRaw: null, routeIndex: 0 })).toBeNull(); expect(expectedCalldata({ ...swap, referenceRateRaw: "114000000", routeIndex: null })).toBeNull(); + expect(expectedCalldata({ ...swap, maxSubsidyRaw: null, referenceRateRaw: "114000000", routeIndex: 1 })).toBeNull(); expect(expectedCalldata({ ...swap, referenceRateRaw: "114000000", routeIndex: 1 })).toBe( - encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 1n, 1_000n * EUR], functionName: "swap" }) + encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 1n, 1_000n * EUR, 2_290_000n], functionName: "swap" }) ); }); @@ -277,6 +336,7 @@ describe("expectedCalldata", () => { expectedCalldata({ eureInRaw: (100n * EUR).toString(), kind: MoneriumConversionExecutionKind.Forward, + maxSubsidyRaw: null, referenceRateRaw: null, routeIndex: null, usdcNetRaw: (108n * USDC).toString() @@ -286,6 +346,7 @@ describe("expectedCalldata", () => { expectedCalldata({ eureInRaw: (40n * EUR).toString(), kind: MoneriumConversionExecutionKind.Recover, + maxSubsidyRaw: null, referenceRateRaw: null, routeIndex: null, usdcNetRaw: (65n * USDC).toString() @@ -335,7 +396,11 @@ describe("classifyHashlessPending", () => { describe("isExpectedTransaction", () => { const keeper = "0x1111111111111111111111111111111111111111"; const forwarder = "0x2222222222222222222222222222222222222222"; - const input = encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 0n, 1_000n * EUR], functionName: "swap" }); + const input = encodeFunctionData({ + abi: chain.forwarderAbi, + args: [114_000_000n, 0n, 1_000n * EUR, 0n], + functionName: "swap" + }); const expected = { from: keeper, input, nonce: 7, to: forwarder }; it("requires the exact keeper, nonce, forwarder, and calldata", () => { @@ -346,7 +411,7 @@ describe("isExpectedTransaction", () => { expect(isExpectedTransaction({ ...expected, input: "0x" }, keeper, forwarder, 7, input)).toBe(false); const otherChunk = encodeFunctionData({ abi: chain.forwarderAbi, - args: [114_000_000n, 0n, 999n * EUR], + args: [114_000_000n, 0n, 999n * EUR, 0n], functionName: "swap" }); expect(isExpectedTransaction({ ...expected, input: otherChunk }, keeper, forwarder, 7, input)).toBe(false); @@ -548,7 +613,7 @@ describe("pricePlannedSwap", () => { } } - const price = () => pricePlannedSwap(FORWARDER, FACTORY, 1_000n * EUR); + const price = (maxSubsidyBps = 50) => pricePlannedSwap(FORWARDER, FACTORY, 1_000n * EUR, maxSubsidyBps); it("defers on a non-positive Chainlink answer", async () => { arrange({ oracleAnswer: 0n }); @@ -570,9 +635,9 @@ describe("pricePlannedSwap", () => { expect(await price()).toEqual({ kind: "defer", reason: "the factory has no enabled swap route" }); }); - it("uses the first enabled route unprojected off mainnet", async () => { + it("uses the first enabled route unprojected off mainnet, still carrying the tier cap", async () => { arrange({ chainId: 11_155_111 }); - expect(await price()).toEqual({ kind: "ready", projection: null, reference, routeIndex: 0 }); + expect(await price()).toEqual({ kind: "ready", maxSubsidyRaw: 5_700_000n, projection: null, reference, routeIndex: 0 }); }); it("defers when no route can be quoted", async () => { @@ -584,18 +649,21 @@ describe("pricePlannedSwap", () => { arrange(); expect(await price()).toEqual({ kind: "ready", + maxSubsidyRaw: 5_700_000n, // 50 bps of the 1140 USDC reference value projection: { defer: null, fee: 425_000n, net: 1_138_575_000n, subsidy: 0n }, reference, routeIndex: 1 }); }); - it("defers with the route and quote when the projection defers", async () => { + it("defers with the route, quote and shortfall when the projection defers", async () => { arrange({ quotes: { "0xaa": 1_130n * USDC, "0xbb": new Error("no pool") } }); // needs 8.29 USDC, cap is 5.7 - expect(await price()).toMatchObject({ + expect(await price(100)).toMatchObject({ kind: "defer", - reason: expect.stringMatching(/per-swap cap.*\(route 0 quoted 1130000000\)/) + reason: expect.stringMatching(/per-swap cap.*\(route 0 quoted 1130000000, shortfall 72 bps, tier 100 bps\)/) }); + // A tier below the shortfall defers before the vault is even consulted. + expect(await price(0)).toMatchObject({ kind: "defer", reason: expect.stringContaining("current tier 0") }); }); }); 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 cce8d314e..246c14552 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -147,6 +147,8 @@ export interface SwapProjectionInput { amountIn: bigint; floorPpm: number; maxFeePpm: number; + /** The keeper's subsidy tier for this chunk (6 decimals): the most Vortex pays right now. */ + maxSubsidyRaw: bigint; oracleDecimals: number; oracleRaw: bigint; quotedOut: bigint; @@ -189,7 +191,9 @@ export function projectSwap(input: SwapProjectionInput): SwapProjection { const net = input.quotedOut - fee + subsidy; let defer: string | null = null; - if (subsidy > 0n) { + if (subsidy > input.maxSubsidyRaw) { + defer = `projected subsidy ${subsidy} exceeds the current tier ${input.maxSubsidyRaw}`; + } else if (subsidy > 0n) { const vault = input.vault; if (!vault) { defer = `a subsidy of ${subsidy} is needed but no subsidy vault is configured`; @@ -210,6 +214,35 @@ export function projectSwap(input: SwapProjectionInput): SwapProjection { return { defer, fee, net, subsidy }; } +// ------------------------------------------------------------------ subsidy ladder + +/** + * The most Vortex pays for a chunk that has waited `elapsedSeconds`, in bps of the + * reference value: the last ladder step whose time has come (adr-0005 amendment + * 2026-09-18). The ladder holds its last step from then on; the refund deadline, not the + * ladder, ends the wait. + */ +export function maxSubsidyBpsFor( + ladder: ReadonlyArray<{ afterSeconds: number; maxSubsidyBps: number }>, + elapsedSeconds: number +): number { + let bps = 0; + for (const step of ladder) { + if (elapsedSeconds >= step.afterSeconds) bps = step.maxSubsidyBps; + } + return bps; +} + +/** How long the next chunk of a deposit has been waiting: since the mint, or since the previous chunk confirmed. */ +export function chunkElapsedSeconds( + deposit: Pick, + lastSwapAt: Date | null, + nowMs: number +): number { + const since = Math.max((deposit.mintedAt ?? deposit.createdAt).getTime(), lastSwapAt?.getTime() ?? 0); + return Math.max(0, Math.floor((nowMs - since) / 1000)); +} + // ------------------------------------------------------------------ chunk planning /** @@ -233,6 +266,8 @@ export function planChunk(remaining: bigint, minSwapAmount: bigint, perSwapCap: export interface DepositSettlementState { /** Confirmed chunk swaps of the deposit, oldest first. */ swaps: MoneriumConversionExecution[]; + /** When the newest confirmed chunk settled: the next chunk's clock starts here. */ + lastSwapAt: Date | null; convertedEureRaw: bigint; remainingEureRaw: bigint; /** Sum of the confirmed chunks' net USDC: what a forward or a recovery moves. */ @@ -247,7 +282,11 @@ export function settlementState( const convertedEureRaw = swaps.reduce((sum, swap) => sum + BigInt(swap.eureInRaw), 0n); const usdcNetRaw = swaps.reduce((sum, swap) => sum + BigInt(swap.usdcNetRaw ?? "0"), 0n); const remainingEureRaw = BigInt(deposit.amountRaw) - convertedEureRaw; - return { convertedEureRaw, remainingEureRaw: remainingEureRaw < 0n ? 0n : remainingEureRaw, swaps, usdcNetRaw }; + const lastSwapAt = swaps.reduce( + (latest, swap) => (swap.updatedAt && (!latest || swap.updatedAt > latest) ? swap.updatedAt : latest), + null + ); + return { convertedEureRaw, lastSwapAt, remainingEureRaw: remainingEureRaw < 0n ? 0n : remainingEureRaw, swaps, usdcNetRaw }; } async function loadSettlementState(deposit: MoneriumFiatDeposit, transaction?: Transaction): Promise { @@ -440,14 +479,22 @@ export interface RecoveryTransactionIdentity { * amounts for a recovery. Null for a swap that never got priced. */ export function expectedCalldata( - execution: Pick + execution: Pick< + MoneriumConversionExecution, + "eureInRaw" | "kind" | "maxSubsidyRaw" | "referenceRateRaw" | "routeIndex" | "usdcNetRaw" + > ): Hex | null { switch (execution.kind) { case MoneriumConversionExecutionKind.Swap: - if (execution.referenceRateRaw === null || execution.routeIndex === null) return null; + if (execution.referenceRateRaw === null || execution.routeIndex === null || execution.maxSubsidyRaw === null) return null; return encodeFunctionData({ abi: forwarderAbi, - args: [BigInt(execution.referenceRateRaw), BigInt(execution.routeIndex), BigInt(execution.eureInRaw)], + args: [ + BigInt(execution.referenceRateRaw), + BigInt(execution.routeIndex), + BigInt(execution.eureInRaw), + BigInt(execution.maxSubsidyRaw) + ], functionName: "swap" }); case MoneriumConversionExecutionKind.Forward: @@ -675,7 +722,14 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans export type PlannedSwap = | { kind: "defer"; reason: string } - | { kind: "ready"; projection: SwapProjection | null; reference: ReferenceQuote; routeIndex: number }; + | { + kind: "ready"; + /** The tier cap in USDC (6 decimals): the `maxSubsidy` argument of the swap. */ + maxSubsidyRaw: bigint; + projection: SwapProjection | null; + reference: ReferenceQuote; + routeIndex: number; + }; function deferSwap(reason: string): PlannedSwap { return { kind: "defer", reason }; @@ -698,12 +752,18 @@ async function quoteRoutes( } /** - * Reference, route and projection for a swap of `amountIn` - * (docs/architecture-monerium-b2b-onramp.md, fees section). 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. + * 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. */ -export async function pricePlannedSwap(forwarder: Address, factory: Address, amountIn: bigint): Promise { +export async function pricePlannedSwap( + forwarder: Address, + factory: Address, + amountIn: bigint, + maxSubsidyBps: number +): Promise { const client = getPublicClient(); const immutables = await getForwarderImmutables(forwarder); const [targetPpm, floorPpm, roundData, vaultAddress] = await Promise.all([ @@ -729,12 +789,15 @@ export async function pricePlannedSwap(forwarder: Address, factory: Address, amo ); } + const referenceOut = (amountIn * reference.rateRaw) / 10n ** BigInt(12 + immutables.oracleDecimals); + const maxSubsidyRaw = (referenceOut * BigInt(maxSubsidyBps)) / BPS; + const routes = await readEnabledRoutes(factory); if (routes.length === 0) { return deferSwap("the factory has no enabled swap route"); } if ((await getChainId()) !== 1) { - return { kind: "ready", projection: null, reference, routeIndex: routes[0].index }; + return { kind: "ready", maxSubsidyRaw, projection: null, reference, routeIndex: routes[0].index }; } const quotes = await quoteRoutes(routes, amountIn); if (quotes.length === 0) { @@ -746,6 +809,7 @@ export async function pricePlannedSwap(forwarder: Address, factory: Address, amo amountIn, floorPpm: Number(floorPpm), maxFeePpm: immutables.maxFeePpm, + maxSubsidyRaw, oracleDecimals: immutables.oracleDecimals, oracleRaw, quotedOut: best.quotedOut, @@ -755,13 +819,17 @@ export async function pricePlannedSwap(forwarder: Address, factory: Address, amo vault }); if (projection.defer) { - return deferSwap(`${projection.defer} (route ${best.index} quoted ${best.quotedOut})`); + // Calibration data for the ladder: the shortfall this attempt would have needed. + const shortfallBps = referenceOut > 0n ? Number((projection.subsidy * BPS) / referenceOut) : 0; + return deferSwap( + `${projection.defer} (route ${best.index} quoted ${best.quotedOut}, shortfall ${shortfallBps} bps, tier ${maxSubsidyBps} bps)` + ); } logger.info( `monerium-b2b: priced swap of ${amountIn} on route ${best.index}: quoted ${best.quotedOut}, ` + - `reference ${reference.price}, fee ${projection.fee}, subsidy ${projection.subsidy}` + `reference ${reference.price}, fee ${projection.fee}, subsidy ${projection.subsidy}, tier ${maxSubsidyBps} bps` ); - return { kind: "ready", projection, reference, routeIndex: best.index }; + return { kind: "ready", maxSubsidyRaw, projection, reference, routeIndex: best.index }; } // ------------------------------------------------------------------ action planning @@ -770,7 +838,7 @@ export type PlannedAction = | { kind: "none"; reason: string } | { kind: "recover"; deposit: MoneriumFiatDeposit; eureRaw: bigint; usdcRaw: bigint } | { kind: "forward"; deposit: MoneriumFiatDeposit; usdcRaw: bigint } - | { kind: "swap"; deposit: MoneriumFiatDeposit; amountIn: bigint }; + | { kind: "swap"; deposit: MoneriumFiatDeposit; amountIn: bigint; elapsedSeconds: number }; export interface ActionPlanningInput { batchOpenedAtSec: bigint; @@ -831,7 +899,12 @@ export function planAction( reason: `deposit ${next.deposit.id} has ${next.state.remainingEureRaw} raw EURe left, below the minimum swap` }; } - return { amountIn, deposit: next.deposit, kind: "swap" }; + return { + amountIn, + deposit: next.deposit, + elapsedSeconds: chunkElapsedSeconds(next.deposit, next.state.lastSwapAt, input.nowMs), + kind: "swap" + }; } // ------------------------------------------------------------------ executor @@ -934,9 +1007,12 @@ export async function runConversionExecutor(accountId: string): Promise { // creates an execution row. let plan: PlannedSwap | null = null; if (planned.kind === "swap") { - plan = await pricePlannedSwap(forwarder, factory, planned.amountIn); + const maxSubsidyBps = maxSubsidyBpsFor(config.moneriumB2b.subsidyLadder, planned.elapsedSeconds); + plan = await pricePlannedSwap(forwarder, factory, planned.amountIn, maxSubsidyBps); if (plan.kind === "defer") { - logger.warn(`monerium-b2b: deferring conversion for account ${account.id}: ${plan.reason}`); + logger.warn( + `monerium-b2b: deferring conversion for account ${account.id} (chunk waited ${planned.elapsedSeconds}s): ${plan.reason}` + ); if (pokeNeeded) { await sendPoke(forwarder); } @@ -965,6 +1041,7 @@ export async function runConversionExecutor(accountId: string): Promise { usdcNetRaw: call.usdcNetRaw, ...(readyPlan?.kind === "ready" ? { + maxSubsidyRaw: readyPlan.maxSubsidyRaw.toString(), referenceAt: readyPlan.reference.time, referenceRateRaw: readyPlan.reference.rateRaw.toString(), referenceSource: readyPlan.reference.source, @@ -1065,7 +1142,7 @@ export async function runConversionExecutor(accountId: string): Promise { } type ExecutionRequest = - | { args: readonly [bigint, bigint, bigint]; functionName: "swap" } + | { args: readonly [bigint, bigint, bigint, bigint]; functionName: "swap" } | { args: readonly [bigint]; functionName: "forward" } | { args: readonly [bigint, bigint]; functionName: "recover" }; @@ -1118,7 +1195,10 @@ function executionCall( return { eureInRaw: planned.amountIn.toString(), kind: MoneriumConversionExecutionKind.Swap, - request: { args: [plan.reference.rateRaw, BigInt(plan.routeIndex), planned.amountIn], functionName: "swap" }, + request: { + args: [plan.reference.rateRaw, BigInt(plan.routeIndex), planned.amountIn, plan.maxSubsidyRaw], + functionName: "swap" + }, usdcNetRaw: null }; } diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 960e61856..8dbb4c720 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -14,7 +14,8 @@ import { runMonitoringPass } from "../services/monerium-b2b/monitoring"; import { advanceOnboardingAccounts } from "../services/monerium-b2b/onboarding"; import { runRecoveryDeadlines, runRecoveryOrchestrator } from "../services/monerium-b2b/recovery"; -const DEFAULT_CRON_TIME = "* * * * *"; // every minute +/** Six-field cron with seconds: a waiting chunk is re-quoted every cycle (MONERIUM_B2B_KEEPER_CYCLE_SECONDS). */ +const DEFAULT_CRON_TIME = `*/${config.moneriumB2b.keeperCycleSeconds} * * * * *`; /** * Keeper loop for the Monerium B2B onramp (plan §3): webhook inbox -> mint watcher -> diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 29cb65393..862cf28a9 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -230,6 +230,8 @@ interface Config { enabled: boolean; /** Key of the EURe float wallet that tops a refund up to the exact amount. */ floatPrivateKey: string | undefined; + /** Seconds between keeper cycles: how often a waiting chunk is re-quoted. */ + keeperCycleSeconds: number; forwarderFactoryAddress: string | undefined; guardianPrivateKey: string | undefined; keeperPrivateKey: string | undefined; @@ -239,6 +241,12 @@ interface Config { /** Key of the immutable RECOVERY_WALLET: signs the reverse swap and the Monerium redeem message. */ recoveryPrivateKey: string | undefined; rpcUrl: string | undefined; + /** + * How much of a chunk's shortfall below the client's floor Vortex pays, as a ladder of + * "after N seconds waited, at most M bps of the reference value" steps; before the first + * step's time the keeper only executes fills at or above the floor. + */ + subsidyLadder: Array<{ afterSeconds: number; maxSubsidyBps: number }>; webhookSecret: string; }; subscanApiKey: string | undefined; @@ -286,6 +294,32 @@ interface Config { }; } +/** + * Launch subsidy ladder (adr-0005 amendment 2026-09-18): nothing for six minutes, then + * 10 bps more every two minutes up to 50, then 100 bps from minute sixteen on. + */ +export const DEFAULT_SUBSIDY_LADDER = "0:0,360:10,480:20,600:30,720:40,840:50,960:100"; + +/** Parses "seconds:bps,seconds:bps,..." into an ascending ladder; throws on anything malformed. */ +export function parseSubsidyLadder(raw: string | undefined): Array<{ afterSeconds: number; maxSubsidyBps: number }> { + const steps = (raw?.trim() || DEFAULT_SUBSIDY_LADDER).split(",").map(entry => { + const [seconds, bps] = entry.split(":").map(part => Number(part.trim())); + if (!Number.isInteger(seconds) || seconds < 0 || !Number.isInteger(bps) || bps < 0 || bps > 10_000) { + throw new Error(`MONERIUM_B2B_SUBSIDY_LADDER entry "${entry}" must be : with bps in 0..10000`); + } + return { afterSeconds: seconds, maxSubsidyBps: bps }; + }); + if (steps[0].afterSeconds !== 0) { + throw new Error("MONERIUM_B2B_SUBSIDY_LADDER must start at 0 seconds"); + } + for (let i = 1; i < steps.length; i++) { + if (steps[i].afterSeconds <= steps[i - 1].afterSeconds || steps[i].maxSubsidyBps < steps[i - 1].maxSubsidyBps) { + throw new Error("MONERIUM_B2B_SUBSIDY_LADDER steps must ascend in both seconds and bps"); + } + } + return steps; +} + export const config: Config = { adminSecret: process.env.ADMIN_SECRET || "", amplitudeWss: process.env.AMPLITUDE_WSS || "wss://rpc-amplitude.pendulumchain.tech", @@ -363,6 +397,7 @@ export const config: Config = { // Dormancy-gate pause key (guardian on the factory/forwarders). Distinct from the // keeper and attestor keys by design; unset = log-only mode for the dormancy gate. guardianPrivateKey: process.env.MONERIUM_B2B_GUARDIAN_PRIVATE_KEY, + keeperCycleSeconds: Number(process.env.MONERIUM_B2B_KEEPER_CYCLE_SECONDS || 20), keeperPrivateKey: process.env.MONERIUM_B2B_KEEPER_PRIVATE_KEY, // Private-orderflow submission endpoint (e.g. https://rpc.flashbots.net); when unset // the keeper falls back to the public RPC and logs a warning (see chain.ts). @@ -370,6 +405,7 @@ export const config: Config = { recoveryDeadlineMinutes: Number(process.env.MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES || 120), recoveryPrivateKey: process.env.MONERIUM_B2B_RECOVERY_PRIVATE_KEY, rpcUrl: process.env.MONERIUM_B2B_RPC_URL, + subsidyLadder: parseSubsidyLadder(process.env.MONERIUM_B2B_SUBSIDY_LADDER), webhookSecret: process.env.MONERIUM_B2B_WEBHOOK_SECRET || "" }, mykobo: { @@ -505,6 +541,9 @@ if (config.moneriumB2b.enabled) { if (!Number.isInteger(config.moneriumB2b.recoveryDeadlineMinutes) || config.moneriumB2b.recoveryDeadlineMinutes <= 0) { throw new Error("MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES must be a positive integer"); } + if (!Number.isInteger(config.moneriumB2b.keeperCycleSeconds) || config.moneriumB2b.keeperCycleSeconds < 5) { + throw new Error("MONERIUM_B2B_KEEPER_CYCLE_SECONDS must be an integer of at least 5"); + } for (const [name, value] of [ ["MONERIUM_B2B_ATTESTOR_PRIVATE_KEY", config.moneriumB2b.attestorPrivateKey], ["MONERIUM_B2B_GUARDIAN_PRIVATE_KEY", config.moneriumB2b.guardianPrivateKey], diff --git a/apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts b/apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts new file mode 100644 index 000000000..b880ed7dd --- /dev/null +++ b/apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts @@ -0,0 +1,15 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// The keeper's subsidy tier for a chunk swap (docs/architecture-monerium-b2b-onramp.md, +// fees section): passed into `swap(reference, route, amountIn, maxSubsidy)` and +// persisted before broadcast, so the calldata-exact crash recovery can rebuild it. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "max_subsidy_raw", { + allowNull: true, + type: DataTypes.DECIMAL(38, 0) + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "max_subsidy_raw"); +} diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index 9731ab4db..5cf54dadc 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -42,6 +42,8 @@ export interface MoneriumConversionExecutionAttributes { referenceAt: Date | null; /** Factory route index the swap executed. */ routeIndex: number | null; + /** The keeper's subsidy tier for the chunk (6 decimals), the `maxSubsidy` argument; persisted before broadcast. */ + maxSubsidyRaw: string | null; txHash: string | null; /** The swap's transaction nonce, persisted BEFORE broadcast (crash-recovery identity). */ nonce: number | null; @@ -70,6 +72,7 @@ type MoneriumConversionExecutionCreationAttributes = Optional< | "referenceWindowSeconds" | "referenceAt" | "routeIndex" + | "maxSubsidyRaw" | "txHash" | "nonce" | "broadcastBlockNumber" @@ -100,6 +103,7 @@ class MoneriumConversionExecution declare referenceWindowSeconds: number | null; declare referenceAt: Date | null; declare routeIndex: number | null; + declare maxSubsidyRaw: string | null; declare txHash: string | null; declare nonce: number | null; declare broadcastBlockNumber: number | null; @@ -167,6 +171,11 @@ MoneriumConversionExecution.init( defaultValue: MoneriumConversionExecutionKind.Swap, type: DataTypes.ENUM(...Object.values(MoneriumConversionExecutionKind)) }, + maxSubsidyRaw: { + allowNull: true, + field: "max_subsidy_raw", + type: DataTypes.DECIMAL(38, 0) + }, nonce: { allowNull: true, type: DataTypes.INTEGER From 1571eb1b396ab00607ce385ce92bd041a6ea3d83 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 18 Sep 2026 10:37:37 +0200 Subject: [PATCH 39/44] feat(api): price swaps against the Coinbase bid/ask midpoint Product moved the reference back to spot for the first iteration: a five-minute average lags a moving market, and in a falling one the lag becomes subsidy. The keeper now reads the EURC-USDC ticker's top of book just before each swap and uses the midpoint; a last trade can be one-sided or stale on a quiet weekend, and a spread above 50 bps is a thin book the keeper defers on instead of pricing against. The averaging window has nothing left to record and goes. --- .../monerium-b2b/conversion-executor.test.ts | 3 +- .../monerium-b2b/conversion-executor.ts | 1 - .../api/services/monerium-b2b/monitoring.ts | 4 +- .../monerium-b2b/reference-rate.test.ts | 152 ++++++------------ .../services/monerium-b2b/reference-rate.ts | 141 ++++++---------- .../084-drop-conversion-reference-window.ts | 14 ++ .../moneriumConversionExecution.model.ts | 9 -- docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/api/openapi/vortex.openapi.json | 2 +- .../shared/src/endpoints/webhook.endpoints.ts | 2 +- 10 files changed, 116 insertions(+), 214 deletions(-) create mode 100644 apps/api/src/database/migrations/084-drop-conversion-reference-window.ts 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 44b365323..084ff1cd9 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 @@ -561,8 +561,7 @@ describe("pricePlannedSwap", () => { price: "1.14000000", rateRaw: 114_000_000n, source: "test", - time: new Date(0), - windowSeconds: 300 + time: new Date(0) }; const vault: chain.SubsidyVaultState = { balance: 1_000n * USDC, 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 246c14552..0510f90c5 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1045,7 +1045,6 @@ export async function runConversionExecutor(accountId: string): Promise { referenceAt: readyPlan.reference.time, referenceRateRaw: readyPlan.reference.rateRaw.toString(), referenceSource: readyPlan.reference.source, - referenceWindowSeconds: readyPlan.reference.windowSeconds, routeIndex: readyPlan.routeIndex } : {}) diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index f0691e32f..4b1bb2cf9 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -48,8 +48,8 @@ import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProduc * and clone bytecode. Guardian fee-policy changes (P11) are reconciled into the DB * and logged, not alarmed; the destination has no setter, so a change there, like * bytecode or registration drift, is an incident. - * 6. Reference-venue monitor: the Coinbase product the reference VWAP reads. A delisted - * or halted product keeps answering the candles endpoint with stale data, so every + * 6. Reference-venue monitor: the Coinbase product the reference midpoint reads. A + * delisted or halted product keeps answering its endpoints with stale data, so every * keeper swap would defer silently; its status is probed instead of assumed. * 7. Refund monitor (automated refunds only): the active recovery must not linger, and * the EURe float that tops refunds up must not run dry. diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts index f76bc54db..1d4320290 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts @@ -1,132 +1,74 @@ import { describe, expect, it } from "bun:test"; import { - Candle, - COINBASE_EURC_CANDLES_URL, - COINBASE_REFERENCE_SOURCE, - computeWindowVwap, + classifyReferenceVenue, + computeMid, + fetchCoinbaseProductStatus, fetchCoinbaseReference, isWithinReferenceBand, - parseCandles, - REFERENCE_FALLBACK_WINDOW_SECONDS, - REFERENCE_WINDOW_SECONDS, - selectReferenceWindow + parseTicker, + spreadBps } from "./reference-rate"; -const END = 1_800_000_000; // window end, a minute boundary -const DECIMALS = 8; - -/** A flat candle: low = high = close, so its typical price is `price`. */ -function candle(bucketStart: number, price: number, volume: number): Candle { - return [bucketStart, price, price, price, price, volume]; -} - -describe("computeWindowVwap", () => { - it("weights each candle's typical price by its volume", () => { - const candles = [candle(END - 60, 1.14, 10), candle(END - 120, 1.16, 30)]; - // (1.14 x 10 + 1.16 x 30) / 40 = 1.155 - expect(computeWindowVwap(candles, END, REFERENCE_WINDOW_SECONDS, DECIMALS)).toBe(115_500_000n); - }); - - it("uses (low + high + close) / 3 as the candle price", () => { - const skewed: Candle = [END - 60, 1.14, 1.15, 1.2, 1.145, 5]; - expect(computeWindowVwap([skewed], END, REFERENCE_WINDOW_SECONDS, DECIMALS)).toBe(114_500_000n); - }); - - it("ignores candles outside the window and returns null without volume", () => { - const candles = [candle(END - 360, 2.0, 100), candle(END, 3.0, 100), candle(END - 60, 1.14, 0)]; - expect(computeWindowVwap(candles, END, REFERENCE_WINDOW_SECONDS, DECIMALS)).toBeNull(); - expect(computeWindowVwap(candles, END, 3_600, DECIMALS)).toBe(200_000_000n); - }); - - it("rejects negative or non-finite candle values", () => { - expect(() => computeWindowVwap([candle(END - 60, -1, 1)], END, 300, DECIMALS)).toThrow(); - expect(() => computeWindowVwap([candle(END - 60, Number.NaN, 1)], END, 300, DECIMALS)).toThrow(); - }); -}); - -describe("selectReferenceWindow", () => { - it("prefers the five-minute window and widens to an hour only when it has no volume", () => { - const busy = [candle(END - 60, 1.14, 10), candle(END - 1_800, 1.5, 100)]; - expect(selectReferenceWindow(busy, END, DECIMALS)).toEqual({ - rateRaw: 114_000_000n, - windowSeconds: REFERENCE_WINDOW_SECONDS - }); - - const quiet = [candle(END - 60, 1.14, 0), candle(END - 1_800, 1.5, 100)]; - expect(selectReferenceWindow(quiet, END, DECIMALS)).toEqual({ - rateRaw: 150_000_000n, - windowSeconds: REFERENCE_FALLBACK_WINDOW_SECONDS - }); - - expect(selectReferenceWindow([candle(END - 60, 1.14, 0)], END, DECIMALS)).toBeNull(); - }); -}); - -describe("parseCandles", () => { - it("accepts Coinbase's array-of-arrays shape and rejects anything else", () => { - expect(parseCandles([[END, 1, 2, 1.5, 1.8, 3]])).toEqual([[END, 1, 2, 1.5, 1.8, 3]]); - expect(() => parseCandles({ candles: [] })).toThrow("not an array"); - expect(() => parseCandles([[END, 1, 2]])).toThrow("malformed"); - expect(() => parseCandles([[END, "1", 2, 1.5, 1.8, 3]])).toThrow("malformed"); - }); -}); +// The partner reference is the Coinbase EURC-USDC bid/ask midpoint (adr-0005 P12, +// amendment 2026-09-18): spot, no averaging, with a spread guard for thin books. describe("isWithinReferenceBand", () => { - const oracle = 114_000_000n; // 1.14 at 8 decimals - - it("accepts references inside the band, inclusive of its edges", () => { + it("mirrors the contract's symmetric band around Chainlink", () => { + const oracle = 114_000_000n; expect(isWithinReferenceBand(oracle, oracle, 100)).toBe(true); expect(isWithinReferenceBand((oracle * 10_100n) / 10_000n, oracle, 100)).toBe(true); expect(isWithinReferenceBand((oracle * 9_900n) / 10_000n, oracle, 100)).toBe(true); - }); - - it("rejects references outside the band and a zero reference", () => { expect(isWithinReferenceBand((oracle * 10_101n) / 10_000n, oracle, 100)).toBe(false); expect(isWithinReferenceBand((oracle * 9_899n) / 10_000n, oracle, 100)).toBe(false); - expect(isWithinReferenceBand(0n, oracle, 100)).toBe(false); }); }); -describe("fetchCoinbaseReference", () => { - const nowMs = (END - 30) * 1000; // half a minute into the bucket that ends at END +describe("top of book", () => { + it("parses a ticker and rejects anything that is not two positive decimals", () => { + expect(parseTicker({ ask: "1.1475", bid: "1.1471", price: "1.1472", volume: "12.5" })).toEqual({ ask: "1.1475", bid: "1.1471" }); + expect(() => parseTicker({ ask: "1.1475" })).toThrow("malformed"); + expect(() => parseTicker({ ask: "abc", bid: "1.1471" })).toThrow("malformed"); + expect(() => parseTicker(null)).toThrow("malformed"); + }); - function fakeFetch(status: number, body: unknown) { - const calls: string[] = []; - const fetchImpl = async (url: string) => { - calls.push(url); - return { json: async () => body, ok: status >= 200 && status < 300, status }; - }; - return { calls, fetchImpl }; - } + it("computes the midpoint at the oracle's decimals and the spread in bps", () => { + expect(computeMid({ ask: "1.1475", bid: "1.1471" }, 8)).toBe(114_730_000n); + expect(spreadBps({ ask: "1.1475", bid: "1.1471" }, 8)).toBe(3); + expect(spreadBps({ ask: "1.1530", bid: "1.1470" }, 8)).toBe(52); + expect(() => spreadBps({ ask: "1.1470", bid: "1.1475" }, 8)).toThrow("inverted"); + expect(() => spreadBps({ ask: "1.0", bid: "0" }, 8)).toThrow("inverted or empty"); + }); +}); + +describe("fetchCoinbaseReference", () => { + const ok = (body: unknown) => async () => ({ json: async () => body, ok: true, status: 200 }); - it("requests an hour of one-minute candles and returns the five-minute VWAP with its window", async () => { - const { calls, fetchImpl } = fakeFetch(200, [candle(END - 60, 1.1432, 10), candle(END - 120, 1.1432, 10)]); - const quote = await fetchCoinbaseReference(DECIMALS, fetchImpl, nowMs); - expect(calls).toHaveLength(1); - expect(calls[0].startsWith(`${COINBASE_EURC_CANDLES_URL}?granularity=60&start=`)).toBe(true); - expect(calls[0]).toContain(`start=${new Date((END - REFERENCE_FALLBACK_WINDOW_SECONDS) * 1000).toISOString()}`); - expect(calls[0]).toContain(`end=${new Date(nowMs).toISOString()}`); + it("reads the ticker midpoint as the reference", async () => { + const quote = await fetchCoinbaseReference(8, ok({ ask: "1.1475", bid: "1.1471" }), 1_800_000_000_000); expect(quote).toEqual({ - price: "1.1432", - rateRaw: 114_320_000n, - source: COINBASE_REFERENCE_SOURCE, - time: new Date(nowMs), - windowSeconds: REFERENCE_WINDOW_SECONDS + price: "1.1473", + rateRaw: 114_730_000n, + source: "coinbase-exchange:EURC-USDC:mid", + time: new Date(1_800_000_000_000) }); }); - it("fails on a non-2xx response, a malformed body, or an hour without volume", async () => { - await expect(fetchCoinbaseReference(DECIMALS, fakeFetch(503, []).fetchImpl, nowMs)).rejects.toThrow("503"); - await expect(fetchCoinbaseReference(DECIMALS, fakeFetch(200, { price: "1.14" }).fetchImpl, nowMs)).rejects.toThrow( - "not an array" - ); - await expect( - fetchCoinbaseReference(DECIMALS, fakeFetch(200, [candle(END - 60, 1.14, 0)]).fetchImpl, nowMs) - ).rejects.toThrow("no EURC-USDC volume"); + it("defers on a thin book, an inverted book, a bad status or a malformed body", async () => { + await expect(fetchCoinbaseReference(8, ok({ ask: "1.1530", bid: "1.1470" }))).rejects.toThrow("spread of 52 bps exceeds 50 bps"); + await expect(fetchCoinbaseReference(8, ok({ ask: "1.1470", bid: "1.1475" }))).rejects.toThrow("inverted"); + await expect(fetchCoinbaseReference(8, async () => ({ json: async () => null, ok: false, status: 503 }))).rejects.toThrow("503"); + await expect(fetchCoinbaseReference(8, ok({ price: "1.1472" }))).rejects.toThrow("malformed"); }); -}); -import { classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; + it("requests the EURC-USDC ticker", async () => { + let requested = ""; + await fetchCoinbaseReference(8, async url => { + requested = url; + return { json: async () => ({ ask: "1.1475", bid: "1.1471" }), ok: true, status: 200 }; + }); + expect(requested).toBe("https://api.exchange.coinbase.com/products/EURC-USDC/ticker"); + }); +}); describe("reference venue status", () => { it("accepts only an online product with trading enabled", () => { diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.ts index 0313d4cdf..9659a3664 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -2,34 +2,28 @@ import { formatUnits, parseUnits } from "viem"; /** * Partner reference rate for the forwarder fee bands (docs/adr-0005-monerium-b2b-onramp.md, P12): - * a volume-weighted average price over the last five minutes of Coinbase Exchange - * EURC-USDC one-minute candles, computed fresh before every swap and recorded on the - * execution row (rate, window, time) so the partner can recompute it from Coinbase's - * public candle history. Averaging instead of taking the last tick keeps a single thin - * print — common on weekends and outside business hours — from becoming the reference. - * When the five-minute window carries no volume the window widens to an hour; with no - * volume in an hour there is no reference and the keeper defers. The keeper passes the - * rate into swapAndForward; the contract rejects it outside its Chainlink band. + * the Coinbase Exchange EURC-USDC bid/ask midpoint, read fresh before every swap and + * recorded on the execution row (rate, source, time) so the partner can check it against + * Coinbase's public ticker. Spot rather than an average (amendment 2026-09-18): an average + * lags a moving market, and the lag would turn into subsidy in a falling one. The midpoint + * rather than the last trade because a last print can be one-sided or minutes stale on a + * quiet weekend; a wide spread is itself a thin market, and the keeper then defers rather + * than pricing against it. The keeper passes the rate into `swap`; the contract rejects it + * outside its Chainlink band. */ /** * The Coinbase Exchange product the reference is read from. EURC-USD and EURC-EUR were - * delisted on 2024-08-29 and still answer the candles endpoint with two-year-old data, - * so the product's status is monitored (`fetchCoinbaseProductStatus`), not assumed. + * delisted on 2024-08-29 and still answer their endpoints with two-year-old data, so + * the product's status is monitored (`fetchCoinbaseProductStatus`), not assumed. */ export const COINBASE_REFERENCE_PRODUCT = "EURC-USDC"; export const COINBASE_EURC_PRODUCT_URL = `https://api.exchange.coinbase.com/products/${COINBASE_REFERENCE_PRODUCT}`; -export const COINBASE_EURC_CANDLES_URL = `${COINBASE_EURC_PRODUCT_URL}/candles`; -export const COINBASE_REFERENCE_SOURCE = `coinbase-exchange:${COINBASE_REFERENCE_PRODUCT}:vwap`; -export const REFERENCE_WINDOW_SECONDS = 5 * 60; -export const REFERENCE_FALLBACK_WINDOW_SECONDS = 60 * 60; -const CANDLE_GRANULARITY_SECONDS = 60; +export const COINBASE_EURC_TICKER_URL = `${COINBASE_EURC_PRODUCT_URL}/ticker`; +export const COINBASE_REFERENCE_SOURCE = `coinbase-exchange:${COINBASE_REFERENCE_PRODUCT}:mid`; +/** A top of book wider than this is too thin to be a reference; the keeper defers. */ +export const MAX_SPREAD_BPS = 50; const FETCH_TIMEOUT_MS = 5_000; -/** Coinbase candle volumes carry up to eight decimals. */ -const VOLUME_DECIMALS = 8; - -/** One Coinbase candle: bucket start (unix seconds), low, high, open, close, volume. */ -export type Candle = readonly [number, number, number, number, number, number]; export interface ReferenceQuote { /** The reference as a decimal string at the oracle's decimals, e.g. "1.14320000". */ @@ -37,10 +31,8 @@ export interface ReferenceQuote { /** The reference scaled to the forwarder's ORACLE_DECIMALS. */ rateRaw: bigint; source: string; - /** When the reference was computed; the window ends at the current minute bucket. */ + /** When the reference was read. */ time: Date; - /** Length of the averaging window that produced the rate (300, or 3600 when widened). */ - windowSeconds: number; } /** Mirrors VortexForwarder._checkedReference: |reference - oracle| <= oracle x band / 10000. */ @@ -49,61 +41,36 @@ export function isWithinReferenceBand(rateRaw: bigint, oracleRaw: bigint, bandBp return rateRaw + tolerance >= oracleRaw && rateRaw <= oracleRaw + tolerance; } -function toRaw(value: number, decimals: number): bigint { - if (!Number.isFinite(value) || value < 0) { - throw new Error(`candle value is not a non-negative number: ${String(value)}`); - } - return parseUnits(value.toFixed(decimals), decimals); +export interface TopOfBook { + ask: string; + bid: string; } -/** - * Volume-weighted average over the candles whose bucket starts inside - * [windowEnd - windowSeconds, windowEnd), weighting each candle's typical price - * (low + high + close) / 3 by its volume. Null when the window holds no volume. - */ -export function computeWindowVwap( - candles: readonly Candle[], - windowEndSec: number, - windowSeconds: number, - decimals: number -): bigint | null { - let weighted = 0n; - let volume = 0n; - for (const [time, low, high, , close, size] of candles) { - if (time < windowEndSec - windowSeconds || time >= windowEndSec) continue; - const typical = (toRaw(low, decimals) + toRaw(high, decimals) + toRaw(close, decimals)) / 3n; - const sizeRaw = toRaw(size, VOLUME_DECIMALS); - weighted += typical * sizeRaw; - volume += sizeRaw; +/** Extracts the top of book from a Coinbase ticker response; anything but two positive decimals throws. */ +export function parseTicker(body: unknown): TopOfBook { + const ticker = body as { ask?: unknown; bid?: unknown } | null; + const bid = ticker?.bid; + const ask = ticker?.ask; + if (typeof bid !== "string" || typeof ask !== "string" || !/^\d+(\.\d+)?$/.test(bid) || !/^\d+(\.\d+)?$/.test(ask)) { + throw new Error("Coinbase ticker response is malformed"); } - return volume === 0n ? null : weighted / volume; + return { ask, bid }; } -/** The primary window, or the widened one when the primary carries no volume; null when neither does. */ -export function selectReferenceWindow( - candles: readonly Candle[], - windowEndSec: number, - decimals: number -): { rateRaw: bigint; windowSeconds: number } | null { - for (const windowSeconds of [REFERENCE_WINDOW_SECONDS, REFERENCE_FALLBACK_WINDOW_SECONDS]) { - const rateRaw = computeWindowVwap(candles, windowEndSec, windowSeconds, decimals); - if (rateRaw !== null && rateRaw > 0n) { - return { rateRaw, windowSeconds }; - } +/** Spread of the top of book in bps of the midpoint (floored). */ +export function spreadBps(book: TopOfBook, decimals: number): number { + const bid = parseUnits(book.bid, decimals); + const ask = parseUnits(book.ask, decimals); + if (bid <= 0n || ask < bid) { + throw new Error(`Coinbase top of book is inverted or empty (bid ${book.bid}, ask ${book.ask})`); } - return null; + const mid = (bid + ask) / 2n; + return Number(((ask - bid) * 10_000n) / mid); } -export function parseCandles(body: unknown): Candle[] { - if (!Array.isArray(body)) { - throw new Error("Coinbase candles response is not an array"); - } - return body.map(row => { - if (!Array.isArray(row) || row.length < 6 || !row.slice(0, 6).every(v => typeof v === "number" && Number.isFinite(v))) { - throw new Error("Coinbase candle row is malformed"); - } - return row.slice(0, 6) as unknown as Candle; - }); +/** The bid/ask midpoint scaled to `decimals`, floored to the unit. */ +export function computeMid(book: TopOfBook, decimals: number): bigint { + return (parseUnits(book.bid, decimals) + parseUnits(book.ask, decimals)) / 2n; } export type FetchLike = ( @@ -111,36 +78,26 @@ export type FetchLike = ( init?: { signal?: AbortSignal } ) => Promise<{ ok: boolean; status: number; json(): Promise }>; -/** Fetches the last hour of one-minute candles and computes the reference. Any failure throws; the caller defers. */ +/** Reads the ticker and returns the midpoint. Any failure throws; the caller defers. */ export async function fetchCoinbaseReference( decimals: number, fetchImpl: FetchLike = fetch, nowMs: number = Date.now() ): Promise { - // The window ends at the end of the current minute bucket, so the in-progress candle counts. - const windowEndSec = - Math.floor(nowMs / 1000 / CANDLE_GRANULARITY_SECONDS) * CANDLE_GRANULARITY_SECONDS + CANDLE_GRANULARITY_SECONDS; - const startSec = windowEndSec - REFERENCE_FALLBACK_WINDOW_SECONDS; - const url = - `${COINBASE_EURC_CANDLES_URL}?granularity=${CANDLE_GRANULARITY_SECONDS}` + - `&start=${new Date(startSec * 1000).toISOString()}&end=${new Date(nowMs).toISOString()}`; - const response = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + const response = await fetchImpl(COINBASE_EURC_TICKER_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!response.ok) { - throw new Error(`Coinbase candles responded ${response.status}`); + throw new Error(`Coinbase ticker responded ${response.status}`); + } + const book = parseTicker(await response.json()); + const spread = spreadBps(book, decimals); + if (spread > MAX_SPREAD_BPS) { + throw new Error(`Coinbase ${COINBASE_REFERENCE_PRODUCT} spread of ${spread} bps exceeds ${MAX_SPREAD_BPS} bps`); } - const window = selectReferenceWindow(parseCandles(await response.json()), windowEndSec, decimals); - if (!window) { - throw new Error( - `no ${COINBASE_REFERENCE_PRODUCT} volume on Coinbase in the last ${REFERENCE_FALLBACK_WINDOW_SECONDS / 60} minutes` - ); + const rateRaw = computeMid(book, decimals); + if (rateRaw <= 0n) { + throw new Error(`Coinbase ${COINBASE_REFERENCE_PRODUCT} midpoint is zero`); } - return { - price: formatUnits(window.rateRaw, decimals), - rateRaw: window.rateRaw, - source: COINBASE_REFERENCE_SOURCE, - time: new Date(nowMs), - windowSeconds: window.windowSeconds - }; + return { price: formatUnits(rateRaw, decimals), rateRaw, source: COINBASE_REFERENCE_SOURCE, time: new Date(nowMs) }; } // ------------------------------------------------------------------ venue status diff --git a/apps/api/src/database/migrations/084-drop-conversion-reference-window.ts b/apps/api/src/database/migrations/084-drop-conversion-reference-window.ts new file mode 100644 index 000000000..552001797 --- /dev/null +++ b/apps/api/src/database/migrations/084-drop-conversion-reference-window.ts @@ -0,0 +1,14 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// The reference is the Coinbase bid/ask midpoint (spot) since adr-0005's 2026-09-18 +// amendment; the averaging window of the former VWAP has nothing left to record. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "reference_window_seconds"); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "reference_window_seconds", { + allowNull: true, + type: DataTypes.INTEGER + }); +} diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index 5cf54dadc..06d620720 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -37,8 +37,6 @@ export interface MoneriumConversionExecutionAttributes { /** Partner reference the swap was priced against, ORACLE_DECIMALS; persisted before broadcast. */ referenceRateRaw: string | null; referenceSource: string | null; - /** Averaging window (seconds) the reference was computed over. */ - referenceWindowSeconds: number | null; referenceAt: Date | null; /** Factory route index the swap executed. */ routeIndex: number | null; @@ -69,7 +67,6 @@ type MoneriumConversionExecutionCreationAttributes = Optional< | "usdcNetRaw" | "referenceRateRaw" | "referenceSource" - | "referenceWindowSeconds" | "referenceAt" | "routeIndex" | "maxSubsidyRaw" @@ -100,7 +97,6 @@ class MoneriumConversionExecution declare destination: string; declare referenceRateRaw: string | null; declare referenceSource: string | null; - declare referenceWindowSeconds: number | null; declare referenceAt: Date | null; declare routeIndex: number | null; declare maxSubsidyRaw: string | null; @@ -195,11 +191,6 @@ MoneriumConversionExecution.init( field: "reference_source", type: DataTypes.STRING(64) }, - referenceWindowSeconds: { - allowNull: true, - field: "reference_window_seconds", - type: DataTypes.INTEGER - }, routeIndex: { allowNull: true, field: "route_index", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 7ef865e84..e057c9379 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2889,7 +2889,7 @@ export interface components { execution: { /** @description Fee taken on the chunk in 6-decimal base units. */ feeRaw: string | null; - /** @description Reference EUR/USD rate the execution was priced against: a volume-weighted average of the Coinbase Exchange EURC-USDC market over the five minutes before the swap (widened to sixty minutes when those five carry no trades), in the oracle's decimals (8). */ + /** @description Reference EUR/USD rate the execution was priced against: the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap, in the oracle's decimals (8). */ referenceRateRaw: string | null; /** @description Subsidy paid by the vault onto the forwarding contract for the chunk, delivered with the deposit's transfer, in 6-decimal base units. */ subsidyRaw: string | null; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index d5d37083e..1ce5ff5c3 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2582,7 +2582,7 @@ "type": ["string", "null"] }, "referenceRateRaw": { - "description": "Reference EUR/USD rate the execution was priced against: a volume-weighted average of the Coinbase Exchange EURC-USDC market over the five minutes before the swap (widened to sixty minutes when those five carry no trades), in the oracle's decimals (8).", + "description": "Reference EUR/USD rate the execution was priced against: the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap, in the oracle's decimals (8).", "type": ["string", "null"] }, "subsidyRaw": { diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 8be70ad9d..e91ced25c 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -128,7 +128,7 @@ export interface DepositReceivedWebhookPayload { export interface ConversionExecutionPricing { /** Fee taken on the execution (6-decimal base units). */ feeRaw: string | null; - /** Reference EUR/USD rate the execution was priced against (a Coinbase EURC-USDC VWAP over the five minutes before the swap, or sixty when those carry no volume), in the oracle's decimals (8). */ + /** Reference EUR/USD rate the execution was priced against: the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap, in the oracle's decimals (8). */ referenceRateRaw: string | null; /** Subsidy paid by the vault straight to the destination (6-decimal base units). */ subsidyRaw: string | null; From c806b3802651360300a371c6d19bed52e6b33462 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 18 Sep 2026 10:41:41 +0200 Subject: [PATCH 40/44] docs(repo): record the subsidy ladder, the per-swap cap and the spot reference --- docs/adr-0005-monerium-b2b-onramp.md | 39 ++++++++++++++-- docs/api/pages/07-webhooks.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 45 ++++++++++++------- docs/operations-monerium-b2b-rollout.md | 23 +++++----- docs/operations-monerium-b2b-runbook.md | 19 +++++--- .../05-integrations/monerium-b2b.md | 8 ++-- 6 files changed, 95 insertions(+), 41 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index a1bbf81fd..f331d5d16 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -3,8 +3,9 @@ **Status:** Accepted (selected 2026-07-17; parameters finalized and documents consolidated 2026-08-26; amended 2026-09-15 with reference-priced fee bands, the subsidy vault, the route whitelist and the 7 day sweep; amended 2026-09-17 with whole-deposit settlement, the -Vortex-held recovery path and the removal of the client fallback role — see the second -amendment). This ADR is the +Vortex-held recovery path and the removal of the client fallback role; amended 2026-09-18 +with the keeper's subsidy ladder, the per-swap subsidy cap and the spot reference — see the +amendment sections). This ADR is the single source of truth for the *decisions and risk acceptances* of the B2B EUR → USDC onramp. How the system works lives in [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); security @@ -96,7 +97,8 @@ A flat skim on whatever the DEX returns cannot express that, so the contract now every fill into bands against a reference rate (decided with the partner; contracts were not yet deployed, so this replaced the flat fee before launch with no migration): -- **Reference rate.** Before each swap the keeper computes a five-minute +- **Reference rate** (superseded 2026-09-18 by the bid/ask midpoint, see the third + amendment). Before each swap the keeper computes a five-minute volume-weighted average of Coinbase Exchange EURC-USDC one-minute candles (typical price × volume), widened to an hour when the five minutes carry no volume, so a single thin print on a weekend or outside business hours never becomes the reference @@ -189,6 +191,34 @@ for that refund is agreed commercially. Decisions (the proposal that led here is refund path is for payments nobody converts), so a deposit into a dormant account is refunded rather than parked. +## Amendment 2026-09-18: subsidy ladder, per-swap subsidy cap, spot reference + +- **The subsidy escalates with the time a chunk has waited.** Product wants the keeper + to wait for the market before Vortex pays a shortfall, and to pay more the longer a + chunk waits. The ladder is a Vortex spending policy, not a client protection (the + client's floor never moves), so it lives in the keeper (`MONERIUM_B2B_SUBSIDY_LADDER`, + seconds waited → max bps of the reference value; launch: 0 bps for six minutes, then + 10/20/30/40/50 bps in two-minute steps, 100 bps from minute sixteen, held until the + refund deadline). The clock runs per chunk, from the mint or the previous chunk's + confirmation, and the keeper re-quotes every cycle (`MONERIUM_B2B_KEEPER_CYCLE_SECONDS`, + 20 s). Deferred attempts log the shortfall so the ladder is tuned from data. A + contract-side ladder was considered and rejected: the contract cannot observe a try, + a keeper-supplied tier index would be unverifiable, and time-tiered vault caps would + buy enforcement against a keeper the design already bounds by the vault's cap and + budget. +- **The tier binds on chain anyway (`swap(..., maxSubsidy)`).** The keeper passes its + tier as a per-swap cap and the forwarder refuses a top-up above it, so a fill that + moved between the quote and the swap cannot draw more than the tier. The contract + learns nothing about time or ladders; the vault's cap (`maxSubsidyPpm`, to be raised to + the ladder's top, 100 bps) and daily budget remain the hard bounds (P13 note below). +- **Spot reference instead of the VWAP.** An average lags a moving market, and in a + falling one the lag turns into subsidy. The reference is now the Coinbase Exchange + EURC-USDC bid/ask midpoint read just before the swap (P12): no averaging, no lag; the + midpoint rather than the last trade because a last print can be one-sided or stale on a + quiet weekend, and a spread above 50 bps makes the keeper defer rather than price + against a thin book. The 2026-09-16 drift replay that sized `SLIPPAGE_BPS` used the + five-minute VWAP; spot moves those figures only marginally. + ## Final parameters (decided 2026-08-26 unless noted) | ID | Parameter | Value | @@ -210,7 +240,8 @@ for that refund is agreed commercially. Decisions (the proposal that led here is | P9 | Notification confirmation depth | 32 blocks (implemented) | | P10 | Router pin and routes | SwapRouter02 immutable; routes are a guardian-managed, on-chain validated whitelist (EURe/EURC/USDC, four tiers, ≤ 2 hops); initial route EURe→EURC→USDC at the 5 bps tiers, re-verify at the deploy block (amended 2026-09-15) | | P11 | Fee adjustability | Guardian `setFeePolicy(target, floor)` within `MAX_FEE_PPM`; raising either value is announced and applies after 24 h, lowering is immediate (amended 2026-09-15) | -| P12 | Reference rate | Five-minute VWAP over Coinbase Exchange EURC-USDC one-minute candles (widened to 60 min when the five minutes have no volume), keeper-computed per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15). The floor on the net binds first: with `floorPpm` 15 bps and `SLIPPAGE_BPS` 40 bps, a reference more than `SLIPPAGE_BPS − floorPpm` ≈ 25 bps below Chainlink makes every normal fill (fee band or subsidized) revert on chain and defer off chain, so ~25 bps is the working downside margin against a stale round; the 100 bps band is the outlier ceiling for a keeper-supplied value, not the operating tolerance (2026-09-16) | +| P12 | Reference rate | **Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap, deferring on a spread above 50 bps** (amended 2026-09-18; from 2026-09-15 a five-minute VWAP over one-minute candles widened to 60 min on no volume), keeper-computed per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15). The floor on the net binds first: with `floorPpm` 15 bps and `SLIPPAGE_BPS` 40 bps, a reference more than `SLIPPAGE_BPS − floorPpm` ≈ 25 bps below Chainlink makes every normal fill (fee band or subsidized) revert on chain and defer off chain, so ~25 bps is the working downside margin against a stale round; the 100 bps band is the outlier ceiling for a keeper-supplied value, not the operating tolerance (2026-09-16) | +| P14 | Subsidy ladder | **`MONERIUM_B2B_SUBSIDY_LADDER` = `0:0,360:10,480:20,600:30,720:40,840:50,960:100`** (2026-09-18; keeper policy, tunable from deferral logs); per-chunk clock; the vault's per-swap cap must be at least the ladder's top | | P13 | Subsidy vault limits | One shared vault; **50 bps of the reference value per swap, 200 USDC per UTC day** at launch, guardian-settable; withdraw to treasury only (2026-09-15) | | T2 | Whitelabel MSA terms | Open — G1 negotiation (rollout doc), includes the per-IBAN suspension ask | | T3 | KYB submission mechanism | Open, deliberately unbuilt — pilot corporates are approved by Monerium under partner KYC reliance and imported via the admin mapping; no identity-data submission path may exist until this settles (security-spec invariant 11) | diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index a5ae448ce..c960703a8 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -183,7 +183,7 @@ Each `conversions[]` entry is one chunk swap of this deposit: the EURe it consum Deposit `status` values: `pending`, `minted`, `held`, `returned` (provider states), then `converting`, `forwarded`, or — when a payment cannot be converted within the promised window — `recovering`, `refunded`, `recovery_failed`. `DEPOSIT_RECEIVED` may already report `converting` when conversion started within the same minute. -The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against, a volume-weighted average of the Coinbase Exchange EURC-USDC market over the five minutes before the swap, widened to sixty minutes when those five carry no trades (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. +The nested `execution` object carries the pricing of the whole execution, identical on every deposit it consumed: `referenceRateRaw` is the EUR/USD reference the swap was settled against, the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). A deposit's own net already includes its share of both. ### `DEPOSIT_RETURNED` diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 3186d2d32..98be14665 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -62,7 +62,7 @@ flowchart LR end subgraph Reference["Reference rate"] - CB[Coinbase Exchange\nEURC-USDC 1-min candles] + CB[Coinbase Exchange\nEURC-USDC ticker] end subgraph Vortex["Vortex API (keeper backend)"] @@ -166,9 +166,9 @@ sequenceDiagram V->>F: (watcher) sees the Transfer log -> stamps chain identity V->>V: DEPOSIT_RECEIVED -> outbox -> partner webhook loop one chunk per keeper cycle (at most perSwapCap) until the deposit is converted - V->>CB: last hour of 1-min candles -> 5-min VWAP (reference, recorded on the execution row) - V->>V: quote every whitelisted route, project fee/subsidy, defer if the vault cannot cover - V->>F: swap(reference, bestRoute, chunk) [execution row bound to the deposit, committed first] + V->>CB: top of book -> bid/ask midpoint (reference, recorded on the execution row) + V->>V: quote every route, project fee/subsidy, defer above the subsidy tier for the chunk's wait or beyond the vault + V->>F: swap(reference, bestRoute, chunk, maxSubsidy = tier) [execution row bound to the deposit, committed first] F->>F: swap the chunk on the route; fee above target (to treasury), floor on the net; USDC stays here F->>S: pay(shortfall) when the fill is below the floor S->>F: subsidy USDC onto the clone @@ -349,12 +349,12 @@ The partner agreement fixes the client's rate against a reference: the reference settles every fill into three bands against that reference (decisions: [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md), amendment). -- **Reference rate.** Before each swap the keeper computes a five-minute volume-weighted - average of Coinbase Exchange EURC-USDC one-minute candles (`reference-rate.ts`: typical - price `(low + high + close) / 3` weighted by volume; widened to an hour when the five - minutes carry no volume, so a single thin weekend print never becomes the reference), - stores price, window and time on the execution row, and passes the rate into - `swap`. The contract rejects a reference outside +- **Reference rate.** Before each swap the keeper reads the Coinbase Exchange EURC-USDC + ticker and takes the bid/ask midpoint (`reference-rate.ts`): spot, so the reference + never lags a moving market; the midpoint rather than the last trade because a last + print can be one-sided or minutes stale on a quiet weekend; a spread above 50 bps is a + thin book and the keeper defers. It stores price, source and time on the execution + row and passes the rate into `swap`. The contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink EUR/USD; a permissionless caller's value is ignored and Chainlink is the reference. No reference means the keeper defers. - **Fee policy (`targetPpm`, `floorPpm`)**: per clone, in ppm below the reference, @@ -387,11 +387,24 @@ settles every fill into three bands against that reference (decisions: 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. +- **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 + 10 bps more every two minutes to 50, then 100 from minute sixteen, held until the + refund deadline). The clock runs per chunk, from the mint or the previous chunk's + confirmation, and the keeper re-quotes every `MONERIUM_B2B_KEEPER_CYCLE_SECONDS` + (20 s); quoting is free, so waiting costs nothing. The tier is passed into `swap` as + `maxSubsidy` and binds on chain: a fill that moved between the quote and the swap + cannot draw more than the tier. The ladder is Vortex's spending policy, not a client + protection — the client's floor never moves — which is why it lives in config and not + in the contract; the vault's cap and daily budget stay the hard bounds. - **Keeper deferral**: before reserving an execution row the keeper mirrors the settlement off-chain (`projectSwap`). It defers — nothing sent, no row, funds wait, - stranding 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 vault - balance, or the projected net would breach the floor. After the 24 h trigger anyone + stranding marker armed — when the reference is unavailable, thin or out of band, no + route quotes, the projected subsidy exceeds the current tier, the vault's cap, the + remaining budget or the vault balance, or the projected net would breach the floor. + Every deferral logs the shortfall in bps against the tier, the data the ladder is + tuned from. After the 24 h trigger anyone may execute the swap anyway, priced against Chainlink and unsubsidized (accepted limitation, ADR). - **Destination (`FEE_RECIPIENT`)**: an immutable baked into the **implementation** @@ -430,8 +443,8 @@ read-only — no keys, no transactions: 5. **Subsidy-vault monitor.** Balance, daily budget, spend and pause state of the shared vault: paused or empty is an error (every below-floor swap defers), less than a day of budget or an exhausted day is a refill warning. -6. **Reference-venue monitor.** Probes the Coinbase product the reference VWAP reads: - a delisted or halted product keeps answering the candles endpoint with stale data +6. **Reference-venue monitor.** Probes the Coinbase product the reference reads: a + delisted or halted product keeps answering its endpoints with stale data and would make every keeper swap defer silently, so its status is an error line rather than an assumption. 7. **Refund monitor** (automated refunds only). The one active recovery must not @@ -490,7 +503,7 @@ erDiagram | `monerium_accounts` (069, 071, 078, 080) | One row per client account: Monerium profile UUID, IBAN, forwarder and destination addresses, fee policy mirror (`target_ppm`, `floor_ppm`), lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | | `monerium_fiat_deposits` (069, 070, 073, 076, 080, 081) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status through settlement (`converting`, `forwarded`) or refund (`recovering`, `refunded`, `recovery_failed`), on-chain mint identity and mint time, the payer's IBAN and name (the refund target), and two webhook-emission markers | | `monerium_recoveries` (081) | One row per refunded deposit: the phase of the refund, the EURe and USDC the keeper recovered, the reverse-swap output, the float top-up (the refund's subsidy) or the surplus swept back, the redeem order and the EUR amount refunded, attempts and the last error | -| `monerium_conversion_executions` (069, 074, 075, 077, 079, 080) | One row per keeper transaction, bound to the deposit it serves (`deposit_id`) and typed by `kind`: a `swap` row is created before broadcast with the chunk, the reference (rate, source, averaging window, time) and route, then filled from `SwapExecuted` (USDC gross, fee, subsidy, net `usdcOut - fee + subsidy`); a `forward` row carries the amount pushed to the destination; a `recover` row the EURe and USDC moved to the recovery wallet. All carry tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and event log index, status | +| `monerium_conversion_executions` (069, 074, 075, 077, 079, 080) | One row per keeper transaction, bound to the deposit it serves (`deposit_id`) and typed by `kind`: a `swap` row is created before broadcast with the chunk, the reference (rate, source, time), the route and the subsidy tier cap (`max_subsidy_raw`), then filled from `SwapExecuted` (USDC gross, fee, subsidy, net `usdcOut - fee + subsidy`); a `forward` row carries the amount pushed to the destination; a `recover` row the EURe and USDC moved to the recovery wallet. All carry tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and event log index, status | | `monerium_webhook_events` (069) | Durable persist-before-200 inbox for Monerium deliveries, dedup by event id, 30-day retention after processing | | `monerium_chain_cursors` (070) | Persisted block cursors for the mint watcher | | `webhook_deliveries` (072) | Generic durable outbox for the deposit-event webhook family: one row per (webhook, event), claim-based dispatch with backoff, 30-day retention after settling | diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 5dc204a75..1942051ee 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -130,12 +130,11 @@ fee policy 12.5 bps target / 15 bps floor (B1). > came from. 4. **Rate, fee and subsidy (B1/P1/P2/P12/P13)** — disclose the guarantee, the fee and the hard bound separately: - - Reference rate: a five-minute volume-weighted average of the Coinbase Exchange - EURC-USDC market, computed immediately before each conversion from public - one-minute candles and recorded with the conversion (widened to a one-hour - average when the five minutes carry no trades). The agreement's "Coinbase EURC - oracle" — align the wording; the source is the exchange market, weekdays and - weekends alike, smoothed so a single thin print cannot set the rate. + - Reference rate: the midpoint between the best bid and the best ask on the Coinbase + Exchange EURC-USDC market, read immediately before each conversion from the public + ticker and recorded with the conversion; a conversion waits while the spread is + wider than 0.5%. The agreement's "Coinbase EURC oracle" — align the wording; the + source is the exchange market, weekdays and weekends alike. - Guarantee: each keeper-executed conversion delivers the reference rate minus 12.5 bps whenever the market allows it, and never less than the reference minus 15 bps. Vortex's fee is whatever the market delivers above the 12.5 bps target, @@ -143,9 +142,12 @@ fee policy 12.5 bps target / 15 bps floor (B1). conversion up from its own subsidy budget. The 12.5 bps target and 15 bps floor are per client; raising either requires a 24 h on-chain pre-announcement (P11), lowering is immediate. - - Subsidy limits: top-ups are capped per conversion and per day (P13). When the - budget cannot cover a conversion, the conversion waits rather than executing below - the floor. After a conversion has waited 24 hours, anyone may execute it at the + - Subsidy limits: top-ups are capped per conversion and per day (P13), and the + amount Vortex is willing to top up grows with the time a chunk has waited for the + market (P14: nothing for the first six minutes, then in steps up to the cap). A + chunk therefore executes as soon as the market delivers the floor on its own, or + once Vortex's willingness to pay meets the shortfall; when neither happens within + the promised window the payment is refunded. After a conversion has waited 24 hours, anyone may execute it at the unsubsidized Chainlink-bounded terms below; the guarantee applies to conversions Vortex's keeper executes. - Hard bound (not a fee): no conversion ever delivers less than the Chainlink @@ -194,7 +196,8 @@ fee policy 12.5 bps target / 15 bps floor (B1). | SulPayments agreement (terms above) | Marcel ↔ partner | Drafting inputs ready | | Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering (needs Marcel's sandbox login) | Open — only remaining engineering unknown | | Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | -| Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses a five-minute VWAP of Coinbase Exchange EURC-USDC candles — confirm that is what was meant | +| Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint (spot, since 2026-09-18) — confirm that is what was meant | +| Subsidy ladder calibration | Ops ↔ product | Launch ladder in P14; retune from the `deferring conversion` shortfall lines and the vault spend after the first weeks; raise the vault's per-swap cap to the ladder's top before enabling | | Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 45 bps after the 2026-09-17 move to 60 bps: the twelve-month replay shows ~0.2 h/year of floor-cause deferral at that margin, so ordinary weekends no longer refund | | Recovery wallet + float wallet | Ops ↔ Monerium | Onboard a Vortex/SatoshiPay company profile in the whitelabel app; link one dedicated address as `RECOVERY_WALLET` (immutable at implementation deploy) and one as the EURe float; fund the float; keys into the keeper's KMS before recovery is automated | | Refund automation | Ops | Implemented (`recovery.ts`): ship with `MONERIUM_B2B_AUTO_RECOVERY=alert`, observe one sandbox refund end to end, then `auto` with the recovery and float keys set; refunds of EUR 15,000 or more stay manual until G1 settles the supporting-document question | diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index db47118b0..0e37eb3a1 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -237,10 +237,15 @@ cast call $USDC "balanceOf(address)(uint256)" $VAULT --rpc-url $RPC cast send $VAULT "withdraw(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY ``` -Sizing: at the €25k per-swap cap a worst-case top-up is about 135 USDC, so the 200 USDC -daily budget covers roughly one and a half such swaps per day across all clients before -the keeper starts deferring. Raise the budget or lower `perSwapCap` if deferrals become -routine; both are instant. +Sizing: the vault's per-swap cap must be at least the subsidy ladder's top (100 bps, so +`setMaxSubsidyPpm(10000)` at launch), because the keeper's tier is the effective cap and +the vault's is the ceiling. At the €25k per-swap cap a top-up at the ladder's top is +about 285 USDC, so size the daily budget from the expected number of chunks that reach +the late tiers, not from one worst case; raise the budget or lower `perSwapCap` if +deferrals become routine; both are instant. The ladder itself +(`MONERIUM_B2B_SUBSIDY_LADDER`, seconds:bps steps) and the re-quote cadence +(`MONERIUM_B2B_KEEPER_CYCLE_SECONDS`) are backend settings; tune the ladder from the +`deferring conversion ... shortfall N bps, tier M bps` log lines. ### 2.7 Refund (recovery) procedure @@ -305,7 +310,7 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- |---|---|---| | `DEPTH BELOW FLOOR — raw quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Even minimum-size fills land below Chainlink − 60 bps on every route before settlement. The floor is enforced on the client's net, so the keeper still executes while the vault covers the shortfall (up to the per-swap cap; beyond it the keeper defers and logs `deferring conversion`), but every swap of that size now costs a subsidy and the unsubsidized permissionless path would revert | Investigate pool state (LP exit, depeg) and watch the vault spend (§2.6); whitelist a better route or lower `perSwapCap`; global pause (§2.1) if it is a depeg or the vault is being drained; re-run the liquidity-baseline methodology before trusting the route again | | `raw quote impact at perSwapCap exceeds SLIPPAGE_BPS` | Cap-sized swaps would need a vault subsidy; availability and vault spend, not fund risk | Lower `perSwapCap`, add a route, or accept the subsidies; watch for escalation | -| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable — check egress), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (depeg — do not force), `no enabled swap route could be quoted` or `the factory has no enabled swap route` (§2.1 route lever) | Funds wait with the batch marker open; a deferral that outlives the 2 h window means the payment is refunded (§2.7) rather than converted late — communicate; after 24 h the permissionless path can execute unsubsidized | +| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable, a malformed ticker, or `spread of N bps exceeds 50 bps` — a thin book; check egress and the venue), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `exceeds the current tier` (normal while the chunk waits for the market; the line names the shortfall and the tier), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (depeg — do not force), `no enabled swap route could be quoted` or `the factory has no enabled swap route` (§2.1 route lever) | Funds wait with the batch marker open; a deferral that outlives the 2 h window means the payment is refunded (§2.7) rather than converted late — communicate; after 24 h the permissionless path can execute unsubsidized | | `SUBSIDY VAULT —` (error) | Vault paused or empty: every below-floor swap defers | §2.6: fund or unpause; check why it emptied (budget too high for the market?) | | `subsidy vault ... refill before below-floor swaps start deferring` | Less than a day of budget left, or today's budget spent | §2.6 refill; consider the budget vs. observed spreads | | `no subsidy vault is configured on the factory` | `setSubsidyVault` never ran; below-floor swaps defer | §2.6 | @@ -657,7 +662,7 @@ FROM monerium_fiat_deposits WHERE account_id = ''; SELECT kind, deposit_id, eure_in_raw, usdc_gross_raw, fee_raw, subsidy_raw, usdc_net_raw, destination, - reference_rate_raw, reference_source, reference_window_seconds, route_index, + reference_rate_raw, reference_source, max_subsidy_raw, route_index, tx_hash, nonce, broadcast_block_number, block_number, swap_log_index, status, error FROM monerium_conversion_executions WHERE account_id = '' ORDER BY created_at; @@ -667,7 +672,7 @@ Required results: - One `forwarded` deposit with the real transfer hash and log index. - One `confirmed` `swap` execution bound to it with the 25 EURe input, a recorded - reference (rate, source, averaging window) and route index 0, a fee or subsidy + reference (rate, source), the tier cap and route index 0, a fee or subsidy consistent with the fill's position against the reference bands (`usdc_net_raw = usdc_gross_raw - fee_raw + subsidy_raw`), non-null nonce/hash/block/swap-log-index, destination matching the clone, and `error IS NULL`. If the vault was left empty and diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index d1a0430db..6cc7ab491 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 minute: webhook inbox 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, 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 pending partner re-confirmation mechanics (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 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 drives at most one such row at a time; the keeper refuses a second `recover` while one is in flight (`activeRecoveryExists`). Every step re-derives its remaining work from the recovery 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 recovery 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). Amounts of EUR 15,000 and above, a missing payer, a rejected order, or a step failing five times park the deposit as `recovery_failed` with the phase preserved for the operator's retry. The recovery and float keys are env-only, never logged, required at startup in `auto` mode, and the recovery key must control the implementation's `RECOVERY_WALLET` (checked before any send). `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 computes a five-minute volume-weighted average of Coinbase Exchange EURC-USDC one-minute candles (`reference-rate.ts`; widened to an hour when the five minutes carry no volume, so one thin print cannot set the reference), records price, window and time on the execution row before broadcast, and passes the rate into `swapAndForward`; 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. On chain the fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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 fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. ## Monitoring @@ -55,7 +55,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 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. 8. **Refund monitor** — with automated refunds configured, the oldest active recovery warns after an hour and errors after four or on a failed step (`classifyRefundQueue`, unit-tested); the float's EURe balance errors when empty and warns below 1,000 EURe. -7. **Reference-venue monitor** — probes the status of the Coinbase Exchange product the reference VWAP reads (`fetchCoinbaseProductStatus`, `classifyReferenceVenue`, unit-tested); anything but an online product with trading enabled is an error, because a delisted product keeps answering the candles endpoint with stale data and every keeper swap would defer silently (EURC-USD did exactly that after its 2024-08-29 delisting). +7. **Reference-venue monitor** — probes the status of the Coinbase Exchange product the reference midpoint reads (`fetchCoinbaseProductStatus`, `classifyReferenceVenue`, unit-tested); anything but an online product with trading enabled is an error, because a delisted product keeps answering the candles endpoint with stale data and every keeper swap would defer silently (EURC-USD did exactly that after its 2024-08-29 delisting). ## Threat Vectors & Mitigations @@ -102,7 +102,9 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] HTTPS enforcement, timeouts, and wire-schema validation on every provider call are delivered by the shared client ([monerium.md](./monerium.md)); `monerium-api.ts` adds no transport of its own - [ ] Current webhook signature/id protocol and upstream order-state vocabulary re-verified from a production delivery before first mainnet deposit (registry T4) - [ ] Foundry suite covers the three fee bands, the fee cap, the reference band, the floor on the net after subsidy, vault cap/budget/pause/treasury-only withdrawal, route validation and the permissionless no-subsidy path; `projectSwap` mirrors the same numbers (`conversion-executor.test.ts`) -- [ ] Recovery calldata identity is rebuilt per kind — `swap(reference, route, amountIn)`, `forward(amount)`, `recover(eure, usdc)` — from what was persisted before broadcast (`expectedCalldata`); a row without it stays pending; forward/recover confirmations require matching event amounts +- [ ] Recovery calldata identity is rebuilt per kind — `swap(reference, route, amountIn, maxSubsidy)`, `forward(amount)`, `recover(eure, usdc)` — from what was persisted before broadcast (`expectedCalldata`); a row without it stays pending; forward/recover confirmations require matching event amounts +- [ ] The subsidy ladder parses and validates at startup (`parseSubsidyLadder`: starts at 0, ascends in time and bps), the tier lookup and per-chunk clock are unit-tested, `projectSwap` defers above the tier before consulting the vault, and the Foundry invariant proves the vault never pays above the caller's `maxSubsidy` +- [ ] 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; 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 elapsed-time failure exists after nonce persistence; 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` From d37018158c37744650cc3ca4286af2dac3065cba Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 18 Sep 2026 11:00:25 +0200 Subject: [PATCH 41/44] docs(repo): record the spot drift replay behind the 60 bps floor --- docs/adr-0005-monerium-b2b-onramp.md | 11 +++++++++-- docs/operations-monerium-b2b-rollout.md | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index f331d5d16..828b80c1e 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -216,8 +216,15 @@ for that refund is agreed commercially. Decisions (the proposal that led here is EURC-USDC bid/ask midpoint read just before the swap (P12): no averaging, no lag; the midpoint rather than the last trade because a last print can be one-sided or stale on a quiet weekend, and a spread above 50 bps makes the keeper defer rather than price - against a thin book. The 2026-09-16 drift replay that sized `SLIPPAGE_BPS` used the - five-minute VWAP; spot moves those figures only marginally. + against a thin book. The drift replay that sized `SLIPPAGE_BPS` was rerun on spot + (2026-09-18, one-minute closes of Coinbase EURC-USDC as the midpoint's proxy vs the + Chainlink rounds, 2025-09-18 to 2026-09-18, 88% of minutes traded; historical bid/ask + is not public): weekend median −5.3 bps, p5 −26.5. Time a floor fill would breach the + oracle floor: at 40 bps 122 h/year over 12 weekends with ten weekend episodes longer + than the 2 h window; at 60 bps 48 h/year of which 47.8 h are the 2025-10-11/12 depeg + weekend (out of the 100 bps band anyway) and the rest six blips of one to five + minutes on three weekends. Spot is noisier than the VWAP at 40 bps and identical at + 60; the 60 bps decision stands. ## Final parameters (decided 2026-08-26 unless noted) diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 1942051ee..80e6aacfe 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -198,7 +198,7 @@ fee policy 12.5 bps target / 15 bps floor (B1). | Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | | Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint (spot, since 2026-09-18) — confirm that is what was meant | | Subsidy ladder calibration | Ops ↔ product | Launch ladder in P14; retune from the `deferring conversion` shortfall lines and the vault spend after the first weeks; raise the vault's per-swap cap to the ladder's top before enabling | -| Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 45 bps after the 2026-09-17 move to 60 bps: the twelve-month replay shows ~0.2 h/year of floor-cause deferral at that margin, so ordinary weekends no longer refund | +| Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 45 bps after the 2026-09-17 move to 60 bps: the twelve-month replay on spot (2026-09-18, ADR amendment 3) shows six minute-long blips a year at that margin outside the 2025-10 depeg weekend, so ordinary weekends do not refund; the depeg weekend (39.7 h out of the 100 bps band) does, by design | | Recovery wallet + float wallet | Ops ↔ Monerium | Onboard a Vortex/SatoshiPay company profile in the whitelabel app; link one dedicated address as `RECOVERY_WALLET` (immutable at implementation deploy) and one as the EURe float; fund the float; keys into the keeper's KMS before recovery is automated | | Refund automation | Ops | Implemented (`recovery.ts`): ship with `MONERIUM_B2B_AUTO_RECOVERY=alert`, observe one sandbox refund end to end, then `auto` with the recovery and float keys set; refunds of EUR 15,000 or more stay manual until G1 settles the supporting-document question | | Sandbox SEPA simulation: payer counterpart | Engineering (needs Marcel's sandbox login) | Capture one real issue-order webhook to confirm `counterpart.identifier.iban` / `details.name` arrive as the spec says (the refund target) | From 25dd2fe41dd4aece48b5eaa1d7aa62080b8b0bd9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 18 Sep 2026 12:06:15 +0200 Subject: [PATCH 42/44] feat(repo): settle a low reference to the Chainlink floor instead of reverting When the reference sits more than SLIPPAGE_BPS minus the policy floor below a stale Chainlink round (weekend drift), a fill at the client's floor used to revert and, under the promised window, refund. The Chainlink floor now bounds the fee target and the subsidy floor from below: the fee gives way first, then the keeper's tier-bounded subsidy lifts the net to the floor, within the vault's cap. A depeg beyond what the tier and the vault cover still reverts; the permissionless path still pays nothing and must clear the floor on its own. --- .../src/VortexForwarder.sol | 42 +++++++++++------ .../test/VortexForwarder.t.sol | 46 ++++++++++++++----- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index ed248d8a0..929625d70 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -374,15 +374,18 @@ contract VortexForwarder { uint256 oraclePrice = _oraclePrice(); uint256 referenceUsed = privileged ? _checkedReference(referenceRate, oraclePrice) : oraclePrice; + uint256 oracleFloor = _floorOut(amountIn, oraclePrice); uint256 usdcReceived = _swap(routeIndex, amountIn); - (uint256 fee, uint256 subsidy) = _settle(amountIn, usdcReceived, referenceUsed, privileged, maxSubsidy); + (uint256 fee, uint256 subsidy) = + _settle(amountIn, usdcReceived, referenceUsed, oracleFloor, privileged, maxSubsidy); // The oracle floor is enforced on the client's NET (fill - fee + subsidy), not on - // the raw fill: a subsidized fill may sit below it, and a subsidy must never - // paper over a depegged reference. Reverting here undoes the swap and the - // subsidy transfer alike. - if (usdcReceived - fee + subsidy < _floorOut(amountIn, oraclePrice)) revert InsufficientOutput(); + // the raw fill. A privileged swap is settled to at least this floor by `_settle` + // (fee first, then the tier-bounded subsidy) or reverts there; the permissionless + // path pays no subsidy, so its fill must clear the floor on its own. Reverting + // undoes the swap and any subsidy transfer alike. + if (usdcReceived - fee + subsidy < oracleFloor) revert InsufficientOutput(); _syncBatch(false); emit SwapExecuted(msg.sender, routeIndex, amountIn, usdcReceived, referenceUsed, fee, subsidy); @@ -411,21 +414,31 @@ contract VortexForwarder { if (eureBefore - EURE.balanceOf(address(this)) > amountIn) revert Overspend(); } - /// @dev Applies the fee bands (docs/architecture-monerium-b2b-onramp.md, "Fees, reference rate and subsidy"): - /// - fill above reference x (1 - targetPpm): the surplus is the fee, <= MAX_FEE_PPM; + /// @dev Applies the fee bands (docs/architecture-monerium-b2b-onramp.md, "Fees, reference rate and subsidy"), + /// with the Chainlink floor `oracleFloor` as a lower bound on both the target and the + /// floor, so that a reference sitting far below a stale Chainlink round costs Vortex + /// fee and subsidy instead of stopping the swap (amendment 2026-09-18): + /// - fill above max(reference x (1 - targetPpm), oracleFloor): the surplus is the + /// fee, <= MAX_FEE_PPM — the fee gives way before the client drops under the floor; /// - fill between the floor and the target: no fee, no subsidy; - /// - fill below reference x (1 - floorPpm): a privileged swap draws the shortfall - /// from the vault onto this clone; a permissionless swap pays nothing. + /// - fill below max(reference x (1 - floorPpm), oracleFloor): a privileged swap draws + /// the shortfall from the vault onto this clone; a permissionless swap pays nothing. /// The caller's `maxSubsidy` bounds the shortfall first; the vault reverts (and so /// does the swap) when its cap, budget, pause or balance cannot cover it, and the /// forwarder reverts unless exactly the shortfall arrived here — a swap is never - /// partially subsidized. - function _settle(uint256 amountIn, uint256 usdcReceived, uint256 referenceUsed, bool privileged, uint256 maxSubsidy) - internal - returns (uint256 fee, uint256 subsidy) - { + /// partially subsidized. A depeg beyond what the tier and the vault cover still + /// reverts. + function _settle( + uint256 amountIn, + uint256 usdcReceived, + uint256 referenceUsed, + uint256 oracleFloor, + bool privileged, + uint256 maxSubsidy + ) internal returns (uint256 fee, uint256 subsidy) { uint256 referenceOut = _usdcValue(amountIn, referenceUsed); uint256 targetOut = (referenceOut * (PPM - targetPpm)) / PPM; + if (targetOut < oracleFloor) targetOut = oracleFloor; if (usdcReceived > targetOut) { fee = usdcReceived - targetOut; uint256 maxFee = (usdcReceived * MAX_FEE_PPM) / PPM; @@ -434,6 +447,7 @@ contract VortexForwarder { return (fee, 0); } uint256 floorOut = (referenceOut * (PPM - floorPpm)) / PPM; + if (floorOut < oracleFloor) floorOut = oracleFloor; if (usdcReceived >= floorOut || !privileged) return (0, 0); subsidy = floorOut - usdcReceived; diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 80bf90464..75e525efc 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -871,29 +871,53 @@ contract VortexForwarderTest is Test { assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); } - function test_swap_depeggedReference_cannotBePaperedOverBySubsidy() public { + /// Amendment 2026-09-18: a reference far below a stale Chainlink round no longer stops + /// the swap — the client is settled to the Chainlink floor instead, at Vortex's cost, + /// within the keeper's tier and the vault's cap. + function test_swap_depeggedReference_isLiftedToTheOracleFloorWhenTheTierAndVaultAllow() public { _fund(1_000e18); uint256 lowReference = (REF * 9_910) / 10_000; // 90 bps below Chainlink: inside the band // The floor at that reference (~1128.05 USDC) is below Chainlink - 60 bps (1133.16): - // the vault would top the client up to it, and the swap must still revert. + // the subsidy tops the client up to 1133.16, not to 1128.05. router.setNextOut(1_127e6); + uint256 needed = ORACLE_FLOOR_1K - 1_127e6; // 6.16 USDC + + // The launch vault cap (50 bps of the reference value, ~5.65 USDC) cannot cover it. vm.prank(keeper); - vm.expectRevert(VortexForwarder.InsufficientOutput.selector); + vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); fwd.swap(lowReference, 0, 1_000e18, NO_CAP); - assertEq(usdc.balanceOf(address(vault)), 1_000e6, "subsidy transfer must be undone"); + + vault.setMaxSubsidyPpm(10_000); // the ladder's top: 100 bps + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyAboveCap.selector); + fwd.swap(lowReference, 0, 1_000e18, needed - 1); // the keeper's tier still binds + + vm.prank(keeper); + fwd.swap(lowReference, 0, 1_000e18, needed); + assertEq(usdc.balanceOf(address(fwd)), ORACLE_FLOOR_1K, "settled to the Chainlink floor"); + assertEq(usdc.balanceOf(address(vault)), 1_000e6 - needed); } - function test_swap_depeggedReference_feeBranchStillEnforcesOracleFloor() public { + /// A fill above the low reference's target but below the Chainlink floor: the fee + /// gives way first, so the client still lands on the floor. + function test_swap_depeggedReference_feeGivesWayBeforeTheOracleFloor() public { _fund(1_000e18); uint256 lowReference = (REF * 9_900) / 10_000; // 100 bps below Chainlink: the band's edge - // Above that reference's target (1_127_189_250): fee branch, fee 0.81 USDC, and the - // net 1_127_189_250 still sits below Chainlink - 60 bps (1_133_160_000). + // The reference target is 1_127_189_250; the fill of 1140 is above it, but the fee may + // only take what sits above the Chainlink floor (1_133_160_000). + router.setNextOut(1_140e6); + vm.prank(keeper); + fwd.swap(lowReference, 0, 1_000e18, 0); + assertEq(usdc.balanceOf(address(fwd)), ORACLE_FLOOR_1K); + assertEq(usdc.balanceOf(feeRecipient), 1_140e6 - ORACLE_FLOOR_1K); + assertEq(usdc.balanceOf(address(vault)), 1_000e6, "no subsidy was needed"); + + // Below the floor with a zero tier: the swap waits (reverts), it does not execute short. + _fund(1_000e18); router.setNextOut(1_128e6); vm.prank(keeper); - vm.expectRevert(VortexForwarder.InsufficientOutput.selector); - fwd.swap(lowReference, 0, 1_000e18, NO_CAP); - assertEq(usdc.balanceOf(feeRecipient), 0, "fee transfer must be undone"); - assertEq(eure.balanceOf(address(fwd)), 1_000e18); + vm.expectRevert(VortexForwarder.SubsidyAboveCap.selector); + fwd.swap(lowReference, 0, 1_000e18, 0); } function test_swap_referenceOutsideTheBandReverts() public { From 29a89b3e88008fcba827cfb5ddf3d8d274ee3424 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 18 Sep 2026 12:06:16 +0200 Subject: [PATCH 43/44] feat(api): project the Chainlink-bounded floor and target in the keeper --- .../monerium-b2b/conversion-executor.test.ts | 48 +++++++++++++------ .../monerium-b2b/conversion-executor.ts | 14 ++++-- 2 files changed, 42 insertions(+), 20 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 084ff1cd9..37e270e74 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 @@ -284,28 +284,46 @@ describe("projectSwap", () => { ); }); - it("defers when even the subsidized net sits below the oracle floor (depegged reference)", () => { - const projection = projectSwap({ ...base, quotedOut: 1_127n * USDC, referenceRaw: (114_000_000n * 9_910n) / 10_000n }); - expect(projection.subsidy).toBeGreaterThan(0n); - expect(projection.defer).toContain("oracle floor"); + // Mirrors test_swap_depeggedReference_isLiftedToTheOracleFloorWhenTheTierAndVaultAllow. + it("lifts a depegged reference's net to the oracle floor when the tier and the vault allow, else defers", () => { + const lowReference = (114_000_000n * 9_910n) / 10_000n; // 90 bps below Chainlink + const needed = 1_133_160_000n - 1_127n * USDC; // 6.16 USDC up to the Chainlink floor, not the reference floor + // The launch vault cap (50 bps of the reference value, ~5.65 USDC) cannot cover it. + expect(projectSwap({ ...base, quotedOut: 1_127n * USDC, referenceRaw: lowReference }).defer).toContain("per-swap cap"); + const roomy = { ...vault, maxSubsidyPpm: 10_000 }; + expect(projectSwap({ ...base, maxSubsidyRaw: needed - 1n, quotedOut: 1_127n * USDC, referenceRaw: lowReference, vault: roomy }).defer).toContain("current tier"); + expect(projectSwap({ ...base, quotedOut: 1_127n * USDC, referenceRaw: lowReference, vault: roomy })).toEqual({ + defer: null, + fee: 0n, + net: 1_133_160_000n, + subsidy: needed + }); }); - it("defers when a fee-band net sits below the oracle floor (depegged reference, fee side)", () => { - // Mirrors test_swap_depeggedReference_feeBranchStillEnforcesOracleFloor: reference 100 bps - // under Chainlink, fill above its target -> fee 0.81 USDC, net 1_127_189_250 < 1_133_160_000. - const projection = projectSwap({ ...base, quotedOut: 1_128n * USDC, referenceRaw: (114_000_000n * 9_900n) / 10_000n }); - expect(projection.fee).toBe(810_750n); - expect(projection.subsidy).toBe(0n); - expect(projection.net).toBe(1_127_189_250n); - expect(projection.defer).toContain("oracle floor"); + // Mirrors test_swap_depeggedReference_feeGivesWayBeforeTheOracleFloor. + it("lets the fee give way before the client drops under the oracle floor", () => { + const lowReference = (114_000_000n * 9_900n) / 10_000n; // 100 bps below Chainlink: the band's edge + expect(projectSwap({ ...base, quotedOut: 1_140n * USDC, referenceRaw: lowReference })).toEqual({ + defer: null, + fee: 1_140n * USDC - 1_133_160_000n, // only what sits above the Chainlink floor, not down to 1_127_189_250 + net: 1_133_160_000n, + subsidy: 0n + }); }); - it("tolerates a reference only SLIPPAGE_BPS - floorPpm (~45 bps) below Chainlink before the floor binds", () => { - // A fill exactly at the client's floor: no fee, no subsidy, net = reference x (1 - floorPpm). + it("pays the drift below ~45 bps under Chainlink from the tier instead of deferring", () => { + // A fill exactly at the client's reference floor: above the Chainlink floor it is untouched, + // below it the subsidy lifts the net to the Chainlink floor (amendment 2026-09-18). const floorFill = (referenceRaw: bigint) => (((base.amountIn * referenceRaw) / 10n ** 20n) * 998_500n) / 1_000_000n; const tooLow = (114_000_000n * 9_953n) / 10_000n; // 47 bps below const fine = (114_000_000n * 9_957n) / 10_000n; // 43 bps below - expect(projectSwap({ ...base, quotedOut: floorFill(tooLow), referenceRaw: tooLow }).defer).toContain("oracle floor"); + const lifted = projectSwap({ ...base, quotedOut: floorFill(tooLow), referenceRaw: tooLow }); + expect(lifted.defer).toBeNull(); + expect(lifted.subsidy).toBeGreaterThan(0n); + expect(lifted.net).toBe(1_133_160_000n); + expect(projectSwap({ ...base, maxSubsidyRaw: 0n, quotedOut: floorFill(tooLow), referenceRaw: tooLow }).defer).toContain( + "current tier" + ); expect(projectSwap({ ...base, quotedOut: floorFill(fine), referenceRaw: fine })).toMatchObject({ defer: null, fee: 0n, 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 0510f90c5..a2b9cc008 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -169,15 +169,20 @@ export interface SwapProjection { /** * Off-chain mirror of VortexForwarder's settlement for a quoted fill: the fee band, the - * subsidy band and the oracle floor on the client's net. The keeper defers — funds wait, + * subsidy band and the oracle floor on the client's net, where the Chainlink floor bounds + * both the target and the floor from below (a reference far under a stale round costs + * Vortex fee and subsidy instead of stopping the swap). The keeper defers — funds wait, * nothing is sent, no execution row is burnt — whenever the contract would revert or the - * vault could not cover the projected subsidy. + * tier or the vault could not cover the projected subsidy. */ export function projectSwap(input: SwapProjectionInput): SwapProjection { const scale = 10n ** BigInt(12 + input.oracleDecimals); const referenceOut = (input.amountIn * input.referenceRaw) / scale; - const targetOut = (referenceOut * (PPM - BigInt(input.targetPpm))) / PPM; - const floorOut = (referenceOut * (PPM - BigInt(input.floorPpm))) / PPM; + const oracleFloor = (((input.amountIn * input.oracleRaw) / scale) * (BPS - BigInt(input.slippageBps))) / BPS; + let targetOut = (referenceOut * (PPM - BigInt(input.targetPpm))) / PPM; + if (targetOut < oracleFloor) targetOut = oracleFloor; + let floorOut = (referenceOut * (PPM - BigInt(input.floorPpm))) / PPM; + if (floorOut < oracleFloor) floorOut = oracleFloor; let fee = 0n; let subsidy = 0n; @@ -207,7 +212,6 @@ export function projectSwap(input: SwapProjectionInput): SwapProjection { defer = `projected subsidy ${subsidy} exceeds the vault balance ${vault.balance}`; } } - const oracleFloor = (((input.amountIn * input.oracleRaw) / scale) * (BPS - BigInt(input.slippageBps))) / BPS; if (defer === null && net < oracleFloor) { defer = `projected net ${net} is below the oracle floor ${oracleFloor}`; } From 85b4c71aa1465c9cdd0b72880e5fc97ad23cfaab Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 18 Sep 2026 12:06:38 +0200 Subject: [PATCH 44/44] docs(repo): describe the Chainlink-bounded floor and target --- docs/adr-0005-monerium-b2b-onramp.md | 13 ++++++++++++- docs/architecture-monerium-b2b-onramp.md | 19 ++++++++++--------- docs/operations-monerium-b2b-rollout.md | 9 ++++++--- docs/operations-monerium-b2b-runbook.md | 2 +- .../05-integrations/monerium-b2b.md | 2 +- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 828b80c1e..af3b91865 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -216,7 +216,18 @@ for that refund is agreed commercially. Decisions (the proposal that led here is EURC-USDC bid/ask midpoint read just before the swap (P12): no averaging, no lag; the midpoint rather than the last trade because a last print can be one-sided or stale on a quiet weekend, and a spread above 50 bps makes the keeper defer rather than price - against a thin book. The drift replay that sized `SLIPPAGE_BPS` was rerun on spot + against a thin book. +- **Weekend drift is paid, not refunded.** The Chainlink floor (`SLIPPAGE_BPS`) now + bounds the fee target and the subsidy floor from below: when the reference sits more + than ~45 bps under a stale Chainlink round, the fee gives way first and then the + keeper's tier-bounded subsidy lifts the client's net to Chainlink − 60 bps, within the + vault's cap, instead of the swap reverting and the payment refunding after the window. + The client never gets less than the floor, occasionally more than the reference deal; + Vortex pays the difference, bounded by the ladder's tier and the vault. A depeg beyond + what the tier and the vault cover (the 2025-10 weekend needed ~440 bps) still reverts + and refunds; the permissionless path still pays nothing. `SLIPPAGE_BPS` thus stays the + hard line for what a compromised keeper can do to the client, and the ladder's top tier + becomes the runtime knob for how much drift Vortex absorbs. The drift replay that sized `SLIPPAGE_BPS` was rerun on spot (2026-09-18, one-minute closes of Coinbase EURC-USDC as the midpoint's proxy vs the Chainlink rounds, 2025-09-18 to 2026-09-18, 88% of minutes traded; historical bid/ask is not public): weekend median −5.3 bps, p5 −26.5. Time a floor fill would breach the diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 98be14665..4c27a081a 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -373,15 +373,16 @@ settles every fill into three bands against that reference (decisions: reverts the whole swap, and the clone reverts unless exactly the shortfall arrived on it — a swap is never partially subsidized, and the guardian cannot harm a swap by pointing the factory at a bad vault. The vault holds Vortex money only. -- **Floor on the net**: `SLIPPAGE_BPS` bounds fill − fee + subsidy against Chainlink, - not the raw fill. The router minimum is zero and the forwarder's post-condition is the - guard, so a subsidy can never paper over a depegged reference and the whole call, - subsidy transfer included, reverts when the floor fails. Because the client's floor is - 15 bps under the reference and the oracle floor 60 bps under Chainlink, a reference - more than ~45 bps below Chainlink fails the floor for every normal fill: that margin, - not the 100 bps band, is the operating tolerance against a stale Chainlink round — - sized so ordinary weekend drift defers (and, under the 2 h window, refunds) about - nothing, while a genuine depeg still does. +- **Floor on the net**: `SLIPPAGE_BPS` (60 bps) bounds fill − fee + subsidy against + Chainlink, not the raw fill, and since 2026-09-18 it also bounds the fee target and the + subsidy floor from below: when the reference sits more than ~45 bps under a stale + Chainlink round (weekend drift), the fee gives way first and the tier-bounded subsidy + then lifts the net to Chainlink − 60 bps instead of the swap reverting. The router + minimum is zero and the forwarder's post-condition is the guard; a depeg beyond what + the tier and the vault cover still reverts (and, past the window, refunds), and the + permissionless path pays no subsidy and must clear the floor on its own. The 60 bps + is therefore the hard line for what a compromised keeper can do to the client, while + the ladder's top tier decides how much drift Vortex absorbs. - **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 diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 80e6aacfe..53831434c 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -151,9 +151,12 @@ fee policy 12.5 bps target / 15 bps floor (B1). unsubsidized Chainlink-bounded terms below; the guarantee applies to conversions Vortex's keeper executes. - Hard bound (not a fee): no conversion ever delivers less than the Chainlink - EUR/USD rate minus 0.6% after fee and subsidy, or it does not execute. Enforced by - the contract assuming an honest oracle; not a principal guarantee under oracle - failure or a stablecoin collapse beyond the bound. + EUR/USD rate minus 0.6% after fee and subsidy, or it does not execute. When the + Coinbase reference sits below that bound, Vortex makes up the difference from its + own budget within the disclosed limits, so the client receives the bound rather + than the reference deal; when the difference exceeds those limits the conversion + waits. Enforced by the contract assuming an honest oracle; not a principal + guarantee under oracle failure or a stablecoin collapse beyond the bound. - Each payment converts on its own, in chunks when it exceeds the per-conversion cap, and reaches the payout address as a single transfer once every chunk is done; the chunks' rates, fees and subsidies are reported per chunk. diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 0e37eb3a1..d61d51bfc 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -310,7 +310,7 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- |---|---|---| | `DEPTH BELOW FLOOR — raw quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Even minimum-size fills land below Chainlink − 60 bps on every route before settlement. The floor is enforced on the client's net, so the keeper still executes while the vault covers the shortfall (up to the per-swap cap; beyond it the keeper defers and logs `deferring conversion`), but every swap of that size now costs a subsidy and the unsubsidized permissionless path would revert | Investigate pool state (LP exit, depeg) and watch the vault spend (§2.6); whitelist a better route or lower `perSwapCap`; global pause (§2.1) if it is a depeg or the vault is being drained; re-run the liquidity-baseline methodology before trusting the route again | | `raw quote impact at perSwapCap exceeds SLIPPAGE_BPS` | Cap-sized swaps would need a vault subsidy; availability and vault spend, not fund risk | Lower `perSwapCap`, add a route, or accept the subsidies; watch for escalation | -| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable, a malformed ticker, or `spread of N bps exceeds 50 bps` — a thin book; check egress and the venue), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `exceeds the current tier` (normal while the chunk waits for the market; the line names the shortfall and the tier), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (depeg — do not force), `no enabled swap route could be quoted` or `the factory has no enabled swap route` (§2.1 route lever) | Funds wait with the batch marker open; a deferral that outlives the 2 h window means the payment is refunded (§2.7) rather than converted late — communicate; after 24 h the permissionless path can execute unsubsidized | +| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable, a malformed ticker, or `spread of N bps exceeds 50 bps` — a thin book; check egress and the venue), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `exceeds the current tier` (normal while the chunk waits for the market; the line names the shortfall and the tier), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (only on the permissionless path since 2026-09-18: keeper swaps are settled up to the Chainlink floor by fee and tier-bounded subsidy, or defer on the tier/cap lines above), `no enabled swap route could be quoted` or `the factory has no enabled swap route` (§2.1 route lever) | Funds wait with the batch marker open; a deferral that outlives the 2 h window means the payment is refunded (§2.7) rather than converted late — communicate; after 24 h the permissionless path can execute unsubsidized | | `SUBSIDY VAULT —` (error) | Vault paused or empty: every below-floor swap defers | §2.6: fund or unpause; check why it emptied (budget too high for the market?) | | `subsidy vault ... refill before below-floor swaps start deferring` | Less than a day of budget left, or today's budget spent | §2.6 refill; consider the budget vs. observed spreads | | `no subsidy vault is configured on the factory` | `setSubsidyVault` never ran; below-floor swaps defer | §2.6 | diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 6cc7ab491..5f3361fff 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 minute: webhook inbox 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, 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 pending partner re-confirmation mechanics (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 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 drives at most one such row at a time; the keeper refuses a second `recover` while one is in flight (`activeRecoveryExists`). Every step re-derives its remaining work from the recovery 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 recovery 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). Amounts of EUR 15,000 and above, a missing payer, a rejected order, or a step failing five times park the deposit as `recovery_failed` with the phase preserved for the operator's retry. The recovery and float keys are env-only, never logged, required at startup in `auto` mode, and the recovery key must control the implementation's `RECOVERY_WALLET` (checked before any send). `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 fee is the surplus above `reference x (1 - targetPpm)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `reference x (1 - floorPpm)`, and `SLIPPAGE_BPS` is enforced on the client's net after both, so a subsidy can never paper over a depegged reference. 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. ## Monitoring