From 3cad47f67e336bbddc41247e34843ff967b0adf0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:03:03 +0200 Subject: [PATCH 01/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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 From 2c11b5f8646b2d712b3f73328409906d6ad78ad0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 15:15:28 +0200 Subject: [PATCH 45/74] docs(repo): add a high-level Monerium B2B flow overview with open questions Gives Vortex, SulPayments and Monerium one shared description of the onboarding, conversion and refund flow, and collects the questions each party still has to answer before the pilot. --- docs/README.md | 1 + docs/product-monerium-b2b-flow.md | 506 ++++++++++++++++++++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 docs/product-monerium-b2b-flow.md diff --git a/docs/README.md b/docs/README.md index bb192e6cc..1e20b15b3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`operations-monerium-interface.md`](operations-monerium-interface.md) | Monerium onramp boundary across the OAuth and white-label apps plus the profile, address, IBAN, and payment interface reference | | [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | +| [`product-monerium-b2b-flow.md`](product-monerium-b2b-flow.md) | High-level end-to-end flow of the Monerium B2B onramp for Vortex, SulPayments and Monerium, with the open questions per party | | [`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 | diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md new file mode 100644 index 000000000..706e9bba8 --- /dev/null +++ b/docs/product-monerium-b2b-flow.md @@ -0,0 +1,506 @@ +# Monerium B2B Onramp: End-to-End Flow + +> **Status:** living overview, draft for alignment. Last updated 2026-09-29. +> **Audience:** Vortex/SatoshiPay internally, SulPayments, and Monerium. +> **Scope:** the EUR to USDC onramp for SulPayments' business clients, as built for the +> pilot on the branch of PR #1375. It is not merged or deployed yet. Open questions carry +> an ID such as **[M1]** (Monerium), **[S1]** (SulPayments) or **[V1]** (Vortex internal) +> and are collected in [section 12](#12-open-questions), with space for the answers. + +## Contents + +1. [In one paragraph](#1-in-one-paragraph) +2. [Who is involved](#2-who-is-involved) +3. [The flow at a glance](#3-the-flow-at-a-glance) +4. [Client onboarding](#4-client-onboarding) +5. [Payment in: SEPA to EURe](#5-payment-in-sepa-to-eure) +6. [Conversion: EURe to USDC](#6-conversion-eure-to-usdc) +7. [Delivery: one USDC transfer per payment](#7-delivery-one-usdc-transfer-per-payment) +8. [Unhappy path: full EUR refund](#8-unhappy-path-full-eur-refund) +9. [Status, reporting and support](#9-status-reporting-and-support) +10. [What Vortex can and cannot do](#10-what-vortex-can-and-cannot-do) +11. [Key parameters](#11-key-parameters) +12. [Open questions](#12-open-questions) +13. [Related documents](#13-related-documents) + +## 1. In one paragraph + +A SulPayments business client sends EUR by SEPA to its own dedicated IBAN. Monerium +mints the same amount of EURe to a smart contract that Vortex deployed for that client, +the **forwarder**. Vortex converts the EURe to USDC on chain at a price tied to a public +market reference, and sends the whole payment to the client's wallet as **one USDC +transfer**. If a payment cannot be converted within **two hours**, Vortex refunds the +**full EUR amount** to the bank account it came from. The forwarder can only ever pay +three places: the client's fixed wallet, Vortex's fee treasury, and, for a refund after +the two-hour window, Vortex's recovery wallet. + +## 2. Who is involved + +| Party or component | Role | +|---|---| +| **SulPayments** | Partner. Brings the business clients, performs their KYB (Monerium relies on it), receives status webhooks, and hands each client its IBAN. | +| **Client** | The business that sends EUR and receives USDC in its own wallet, the **destination**. | +| **Monerium** | Licensed EURe issuer. Hosts each client's profile and IBAN in Vortex's white-label app, mints EURe for incoming SEPA payments, and pays out EUR on redemption. | +| **Vortex / SatoshiPay** | Operator. Deploys the contracts, runs the **keeper** service that converts and forwards, runs refunds, and reports status. | +| **Forwarder contract** | One per client on Ethereum. Receives the minted EURe, swaps it, holds the USDC until the payment is complete, then forwards it. | +| **Subsidy vault** | A Vortex-funded USDC pool that tops up a swap when the market delivers less than the client's guaranteed floor. | +| **Fee treasury** | Vortex multisig that receives the conversion fee. | +| **Recovery wallet and float wallet** | Two Vortex addresses on a Vortex/SatoshiPay company profile at Monerium, used only for refunds. | +| **Price sources** | Coinbase Exchange EURC-USDC market for the reference rate, Chainlink EUR/USD as the on-chain safety bound, Uniswap v3 where the swaps execute. | + +## 3. The flow at a glance + +```mermaid +flowchart LR + subgraph Bank["Banking"] + CB["Client's bank account"] + end + subgraph Mon["Monerium"] + IBAN["Client IBAN
points at the forwarder"] + RED["Redemption
EUR payout"] + end + subgraph Eth["Ethereum"] + FWD["Forwarder contract
one per client"] + UNI["Uniswap v3
whitelisted route"] + VAULT["Subsidy vault"] + TREAS["Fee treasury"] + DEST["Client wallet
fixed destination"] + REC["Vortex recovery wallet"] + end + subgraph Vx["Vortex"] + KEEP["Keeper service"] + end + SP["SulPayments"] + + CB -- "1 SEPA EUR" --> IBAN + IBAN -- "2 mint EURe" --> FWD + KEEP -- "3 swap in chunks" --> FWD + FWD <-- "EURe to USDC" --> UNI + VAULT -. "top-up below floor" .-> FWD + FWD -. "fee above target" .-> TREAS + FWD -- "4 one USDC transfer" --> DEST + KEEP -- "status webhooks" --> SP + FWD -. "not converted in 2 h" .-> REC + REC -. "refund EUR" .-> RED + RED -. "SEPA refund" .-> CB +``` + +Solid arrows are the happy path. Dotted arrows only happen when needed: a fee or +subsidy on a swap, or the refund path. + +## 4. Client onboarding + +### 4.1 What has to be true when onboarding ends + +- The client has a **KYB-approved corporate profile** in Vortex's white-label app at + Monerium, based on SulPayments' KYB under a reliance arrangement. +- A **forwarder contract** exists with the client's destination wallet written into it. + The destination cannot be changed later. A new wallet means a new forwarder and moving + the IBAN, on SulPayments' written instruction. +- The forwarder is **linked** to the client's Monerium profile, and the client's **IBAN + points at the forwarder**. Monerium mints to whatever address the IBAN points at, so + this is what routes every payment through the conversion. An IBAN pointing at the + client's own wallet would deliver EURe, not USDC. +- A **penny test** has shown that the destination credits USDC sent from a contract. The + client confirms receipt of about 5 USDC in writing. +- Vortex has mapped the client under **SulPayments' partner account**, so webhooks and + API reads reach SulPayments. + +### 4.2 Proposed onboarding flow + +This is the flow SatoshiPay proposed for the pilot. Steps marked with an ID still need +an answer. + +```mermaid +sequenceDiagram + autonumber + participant SP as SulPayments + participant M as Monerium + participant V as Vortex + participant C as Ethereum + participant CL as Client + + SP->>M: Client onboarding data incl. KYB [M1, M2, S2] + M->>M: Create white-label profile and review KYB + M-->>V: Profile created or approved notification [M6] + SP->>V: Destination wallet and Monerium profile ID [S3] + V->>V: Verify profile is approved and matches the SulPayments client + V->>C: Deploy forwarder with destination and fee policy + V->>M: Link forwarder to the profile with a contract signature [M3] + V->>M: IBAN on the forwarder, request new or move existing [M3] + M-->>V: IBAN issued or moved + CL->>M: Penny test, small SEPA payment + V->>CL: About 5 USDC to the destination, client confirms + V->>SP: Client active, IBAN visible in dashboard or API [S4, V3] + SP->>CL: Hand over IBAN, client starts sending EUR +``` + +Notes on the proposal: + +- **Destination on the Monerium profile.** The original proposal has Monerium add the + destination address to the profile, and Vortex swap it for the forwarder address + later. Linking an address at Monerium needs a signature from that address's owner, + which exchange deposit addresses cannot give. Vortex also does not need the + destination at Monerium, because it is stored in the forwarder contract. The simpler + route is for SulPayments to send the destination to Vortex directly **[S3]**. +- **IBAN before the forwarder exists.** Monerium issues one IBAN per profile. If + Monerium creates it before the forwarder is linked, it points at another address, and + Vortex must move it to the forwarder before anyone pays into it. Until then, a payment + would mint EURe somewhere other than the forwarder **[M3]**. +- **New-profile notification.** Monerium's white-label webhooks include + `profile.updated`, with no separate creation event **[M6]**. + +### 4.3 What is built today + +- A Vortex operator deploys the forwarder, then one admin call maps the client with its + Monerium profile ID, SulPayments' client ID, the destination and the fee policy. +- The keeper then links the forwarder and requests the IBAN automatically. The IBAN is + recorded when Monerium confirms it. +- After the penny test, the operator activates the account. +- SulPayments can read the account and its IBAN through the Vortex API. There is no + dashboard view yet **[V3]**. +- Adopting the proposal changes two things: onboarding starts from Monerium's profile + notification instead of an operator call, and the keeper moves an existing IBAN + instead of requesting a new one **[V1]**. + +### 4.4 Decisions behind onboarding + +- **One forwarder per client.** Each client gets its own contract, created as a cheap + clone of one shared, verified implementation. A published manifest lets anyone check + every deployed forwarder against it. +- **Fixed destination.** The destination is set at deployment and has no setter, so no + key, including Vortex's, can redirect a client's USDC. +- **Contract-signed link.** A contract cannot sign like a wallet. The forwarder + therefore proves ownership to Monerium by accepting a signature from a Vortex attestor + key, but only over Monerium's exact link message. That key has no power over the + contract's funds. + +## 5. Payment in: SEPA to EURe + +```mermaid +sequenceDiagram + participant B as Client's bank + participant M as Monerium + participant F as Forwarder + participant V as Vortex keeper + participant SP as SulPayments + + B->>M: SEPA transfer to the client's IBAN + M-->>V: order.created webhook, EUR arrived + Note over M: Compliance hold possible, or return before minting + M->>F: Mint EURe to the forwarder + M-->>V: order.updated webhook, order processed + V->>F: Watcher confirms the mint on chain + V->>V: Deposit minted, 2-hour clock starts + V-->>SP: DEPOSIT_RECEIVED webhook +``` + +- Vortex listens on **two channels**. Monerium's webhooks carry the order details: + amount, order ID, compliance holds, and the payer's IBAN and name. Vortex's own chain + watcher proves the EURe actually arrived on the forwarder. A deposit becomes eligible + for conversion only when both agree **[M7]**. +- The payer's **IBAN and name are stored** from the order. They are the target of any + refund. +- The **two-hour window starts at the mint**, not at the SEPA transfer, because Vortex + cannot act before the EURe exists. +- Monerium may **hold** a payment for compliance or **return** it before minting. These + are Monerium's decisions, and nothing reaches the forwarder. + +## 6. Conversion: EURe to USDC + +### 6.1 Chunks + +A payment larger than the per-swap cap of **€25,000** is converted in several chunks, a +few minutes apart. The chunks' USDC waits on the forwarder until the whole payment is +converted. Two payments are never mixed in one swap. A single client's payments are +converted one after the other, while different clients' payments convert in parallel. + +### 6.2 Price + +- **Reference rate:** the midpoint between the best bid and the best ask on the Coinbase + Exchange EURC-USDC market, read immediately before each chunk and recorded with it. A + chunk waits while that market's spread is wider than 0.5%. +- **Client target:** the reference minus 0.125%. Whatever the market delivers above the + target is Vortex's fee, capped at 1% on chain. +- **Client floor:** the reference minus 0.15%. If the market delivers less, Vortex tops + the chunk up from the subsidy vault, within limits. +- **Safety bound:** no chunk ever delivers less than the Chainlink EUR/USD rate minus + 0.6%, or it does not execute. If the Coinbase reference sits below that bound, the + client gets the bound, and Vortex pays the difference within its subsidy limits. +- The reference must also be within 1% of Chainlink, so a bad price feed cannot push a + swap far off market. + +### 6.3 Subsidy ladder + +How much Vortex is willing to top up grows with how long a chunk has waited for the +market. Waiting time counts per chunk, from when it became ready to swap. + +| Waiting time | Maximum top-up | +|---|---| +| 0 to 6 min | none | +| 6 to 8 min | 0.10% | +| 8 to 10 min | 0.20% | +| 10 to 12 min | 0.30% | +| 12 to 14 min | 0.40% | +| 14 to 16 min | 0.50% | +| from 16 min | 1.00% | + +The ladder is a keeper setting that Vortex can retune without touching the contracts. +The contract enforces the cap the keeper passes with each swap, and the vault enforces a +per-swap cap and a daily budget on top. + +### 6.4 Per-chunk decision + +```mermaid +flowchart TD + A["Chunk ready to swap"] --> B{"Coinbase spread
at most 0.5%?"} + B -- no --> W["Wait for the next cycle"] + B -- yes --> C{"Reference within 1%
of Chainlink?"} + C -- no --> W + C -- yes --> D["Quote whitelisted routes,
pick the best"] + D --> E{"Fill at or above
the client floor?"} + E -- yes --> X["Swap. Surplus above target
goes to the fee treasury"] + E -- no --> F{"Shortfall within the ladder
and the vault budget?"} + F -- yes --> Y["Swap. Vault tops up
to the floor"] + F -- no --> W + W --> G{"2 hours since the mint?"} + G -- no --> A + G -- yes --> R["Refund path, section 8"] +``` + +## 7. Delivery: one USDC transfer per payment + +- Once every chunk of a payment is converted, the keeper sends the whole USDC amount to + the client's destination in **one transfer**. +- After the transfer is 32 blocks deep, SulPayments receives **DEPOSIT_CONVERTED**. It + carries each chunk's reference rate, fee and subsidy, and the transaction hash of the + final transfer. +- **Without Vortex:** if the keeper stops for 24 hours, anyone can convert at the + Chainlink price without subsidy and forward the forwarder's USDC to the destination. A + client's funds never depend on Vortex staying online. +- **Dormancy:** after 60 days without a conversion, forwarding pauses until the + destination is re-confirmed in writing. + +## 8. Unhappy path: full EUR refund + +### 8.1 When a payment is refunded + +- It was **not converted within two hours** of the mint. Typical causes are a market + move beyond the bounds, thin liquidity, an exhausted subsidy budget, or an operational + fault. +- It, or its last remainder, is **below the €250 minimum swap**. +- An operator triggers it after a **compliance decision or an incident**. + +A payment is never partly delivered. If any part cannot be converted in time, the whole +payment is refunded, including chunks that were already converted. + +### 8.2 How a refund runs (as built) + +```mermaid +sequenceDiagram + participant V as Vortex keeper + participant F as Forwarder + participant R as Recovery wallet + participant U as Uniswap + participant FL as Float wallet + participant M as Monerium + participant B as Payer's bank + participant SP as SulPayments + + Note over F: 2 hours passed, payment not fully converted + V->>F: recover the payment's EURe and USDC + F->>R: EURe and USDC, contract refuses before 2 hours + R->>U: Swap USDC back to EURe + FL->>R: Top up to the exact original amount + R->>M: Redeem order, exact EUR amount to the payer's IBAN + M->>B: SEPA payout + M-->>V: order.updated, redemption processed + V-->>SP: DEPOSIT_RETURNED webhook +``` + +- The payer gets back the **exact EUR amount**. Losses from the round trip and fees + already taken on converted chunks are Vortex's cost, paid from the float wallet. +- The contract **enforces the two-hour window**. The forwarder cannot move funds to the + recovery wallet any earlier. +- The refund is sent from the **Vortex/SatoshiPay company account** at Monerium. The + payer's bank statement shows that sender, with a reference to the original payment. +- SEPA Instant is used when the payer's bank supports it, otherwise next business day. +- Refunds of **€15,000 or more stay manual** until Monerium confirms whether a supporting + document is required **[M9]**. +- Refunds run one at a time and survive a crash of the keeper midway. A refund that + fails its retries goes to **recovery failed** and is handed to Vortex operations. +- Rollout: the automation first runs in **alert mode**, where Vortex operators confirm + each refund. It switches to **automatic** after the first refund has been observed end + to end. + +### 8.3 Refund mechanism options + +The proposal raised whether the refund could come from the **client's own Monerium +IBAN**, and how to do that securely. The options: + +| | A. Vortex recovery wallet (built) | B. Contract-signed redemption from the client's profile | C. Monerium return facility | +|---|---|---|---| +| **Refund sent from** | Vortex/SatoshiPay company profile | The client's own profile and IBAN | The client's profile, by Monerium | +| **Custody** | Vortex holds the funds during the refund only | Funds stay on the client's forwarder until burned | Monerium | +| **Main risk** | Payer must trust Vortex to complete the refund | The contract cannot know who the payer was. A stolen keeper key could redeem to any IBAN unless the contract only allows an agreed list of IBANs per client | Depends on Monerium's process | +| **What it needs** | Nothing more to build. Monerium sign-off for one profile refunding many payers **[M8]** | Contract changes: an IBAN allowlist per client with delayed changes, reverse swap and top-up on the forwarder, redemption signature checks. Re-audit. Monerium accepting contract-signed redemptions **[M8]** | A native Monerium feature, not documented today **[M8]** | +| **Bank statement shows** | SatoshiPay / Monerium | The client's Monerium account | To be confirmed | + +Option B is only safe if clients pay from a small, known set of bank accounts, such as +their own business accounts **[S6]**. The decision is open **[V2]**. + +## 9. Status, reporting and support + +### 9.1 Deposit status + +```mermaid +stateDiagram-v2 + direction LR + [*] --> pending: Monerium order + pending --> held: compliance hold + pending --> returned: Monerium returns it + held --> returned + pending --> minted: EURe on the forwarder + held --> minted + minted --> converting: first chunk swapped + converting --> forwarded: one USDC transfer + minted --> recovering: refund path + converting --> recovering + recovering --> refunded: EUR back to payer + recovering --> recovery_failed + recovery_failed --> recovering: operator retry + forwarded --> [*] + returned --> [*] + refunded --> [*] +``` + +Statuses only move forward. A late or repeated webhook can never move a deposit back. + +### 9.2 What SulPayments receives + +| Event | When | Key content | +|---|---|---| +| `DEPOSIT_RECEIVED` | EURe minted to the client's forwarder | Deposit ID, account, amount, mint transaction | +| `DEPOSIT_CONVERTED` | The USDC transfer is 32 blocks deep | Per chunk: reference rate, fee, subsidy. The forward transaction hash | +| `DEPOSIT_RETURNED` | The refund was processed by Monerium | Refunded amount, masked payer IBAN, Monerium redemption ID, recovery transaction | + +- Webhooks are signed. SulPayments verifies each one against Vortex's published public + key and deduplicates on the event ID. +- **Fallback:** the deposits endpoint of the Vortex API returns the current status of + every deposit, for polling if a webhook is missed. +- **Reference IDs** in every event: the deposit ID, the account ID and the client's + profile ID, plus the mint, forward or recovery transaction hash and, on a refund, + Monerium's redemption ID. Vortex also keeps Monerium's order ID and SulPayments' own + client ID per deposit for support queries, and the refund memo carries the deposit ID. +- **Gaps:** there are no reason codes on a refund yet, and a payment Monerium returns + before minting triggers no webhook, only a status visible by polling **[V4]**. + +### 9.3 Exceptions and escalation + +- Vortex monitors stuck payments, refunds due, failed refunds, the subsidy budget, the + float balance, IBAN changes at Monerium, and the Coinbase market status. +- Operators can pause conversion, force a refund, or correct a deposit's status through + admin endpoints. The runbook covers each case. +- Named owners and the escalation path between Vortex, SulPayments and Monerium are + still to be agreed **[V5]**. + +## 10. What Vortex can and cannot do + +**Vortex can:** + +- Deploy forwarders, run conversions, and pause them. +- Choose the swap route from an on-chain whitelist. +- Change a client's fee policy within the 1% cap. Raising it takes effect only after a + 24-hour on-chain notice. Lowering it is immediate. +- Fund or limit its own subsidy budget. +- Move a payment to its recovery wallet, only after the two-hour window, to refund it. + +**Vortex cannot:** + +- Redirect funds. A forwarder pays only the client's fixed destination, the fee + treasury, and, after two hours, the recovery wallet. +- Deliver a conversion below the Chainlink rate minus 0.6%. +- Stop a client's conversion permanently. After 24 hours anyone can complete it. +- Prevent incoming SEPA payments. Payments made during a pause wait safely as EURe and + are converted or refunded later. + +## 11. Key parameters + +| Parameter | Value | Changeable | +|---|---|---| +| Refund window | 2 hours from the mint | Fixed in the contract | +| Permissionless fallback | 24 hours | Fixed in the contract | +| Client target and floor | Reference minus 0.125% and minus 0.15% | Per client, increases need 24 h notice | +| Fee cap | 1% | Fixed in the contract | +| Safety bound against Chainlink | 0.6% | Fixed in the contract | +| Reference band against Chainlink | 1% | Fixed in the contract | +| Maximum Coinbase spread | 0.5% | Keeper code | +| Subsidy ladder | none for 6 min, rising to 1% from 16 min | Keeper setting | +| Chunk size | up to €25,000 | Operational, ceiling €50,000 | +| Minimum swap | €250 | Operational, floor €25 | +| Manual refunds | €15,000 and above | Until [M9] is answered | +| Penny test | about 5 USDC | Per client | +| Dormancy pause | 60 days without a conversion | Keeper code | +| Pilot volume | 3 to 5 clients, €50,000 per client per day | Contractual | + +## 12. Open questions + +### 12.1 Monerium + +| ID | Question | Why it matters | Status | Answer | +|---|---|---|---|---| +| M1 | How should new clients be onboarded into our white-label app? | Defines the onboarding flow in section 4.2 | Asked 2026-09-29 on Telegram | | +| M2 | How are SulPayments' existing clients imported? How long does KYB take once SulPayments' data is in, and is that business hours? | Pilot timeline and client expectations | Open | | +| M3 | For a profile Monerium creates, can Vortex link the forwarder with its contract signature and put the IBAN on it? If the IBAN already exists on another address, may Vortex move it, and can Monerium promise not to move it back? | The IBAN must point at the forwarder before a client pays | Open | | +| M4 | Can our white-label app list all profiles Monerium creates for SulPayments' clients? The public spec lists the profiles an app "has access to", and in the sandbox profiles onboarded through another app were not visible. | Automated onboarding and reconciliation | Open | | +| M5 | Which profile data can we read? The public spec shows ID, name, kind, state, section states and a rejection or closure reason, plus each IBAN's BIC, linked address and chain, but not the submitted KYB details. | What the SulPayments dashboard view can show | Open | | +| M6 | Does `profile.updated` fire when Monerium creates and approves a profile in our app? | Trigger for automated onboarding | Open | | +| M7 | Do `order.created` and `order.updated` arrive as described in section 5, with the payer's IBAN and name on the order? To be confirmed with a sandbox SEPA simulation. | The payer IBAN is the refund target | Open, sandbox test pending | | +| M8 | Refund mechanism: do you accept option A, one Vortex company profile refunding many payers? Is there a native return-to-sender facility, option C? Would you accept contract-signed redemptions from the client's profile, option B? | Refund design, section 8.3 | Open | | +| M9 | Does a refund of €15,000 or more to the original payer need a supporting document? Could the original payment serve as one? | Whether large refunds can run automatically | Open | | +| M10 | Which outgoing limits, fees and cut-off times apply to redemptions from our company profile? | Refund timing promise | Open | | +| M11 | Onboarding of the Vortex/SatoshiPay company profile with a recovery address and a float address. | Needed before the contracts can be deployed, since the recovery address is fixed in them | Open | | +| M12 | Production white-label API credentials for the B2B app. | Needed before go-live | Open | | +| M13 | Written confirmation of the items agreed verbally so far: the attestor-signed link, the redemption-limitation disclosure, the issuer recovery backstop, what authorizes an IBAN move or new address link, SEPA recall and fraud loss allocation, per-IBAN suspension, and advance notice of changes to the link message. | Launch gate | Open | | + +### 12.2 SulPayments + +| ID | Question | Why it matters | Status | Answer | +|---|---|---|---|---| +| S1 | Onboarding volume: an initial batch of existing clients, then new clients one by one? How many, and when? | Operational planning, manual versus automated onboarding | Open | | +| S2 | Does SulPayments send client onboarding data, including KYB, directly to Monerium? Who are the contacts on each side? | Onboarding flow, section 4.2 | Open | | +| S3 | How is each client's destination wallet and Monerium profile ID given to Vortex: Vortex dashboard or Vortex API? Who at SulPayments approves it? | The destination is fixed in the contract, so it must be right the first time | Open | | +| S4 | Plan: each client's IBAN is shown in the Vortex dashboard and API. Is that how SulPayments hands IBANs to clients? | Dashboard scope | Open | | +| S5 | Confirm that the smart-contract address on each client's Monerium profile is managed by Vortex, and that clients never link or change it themselves. | Only the forwarder may receive the minted EURe | Open | | +| S6 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target, and whether option B is viable | Open | | +| S7 | Are destinations self-custody wallets or exchange deposit addresses? | Exchange addresses need an attestation that they do not rotate and accept contract transfers | Open | | +| S8 | Are the three webhooks plus polling enough? Are reason codes needed on refunds? Webhook endpoint and support contacts. | Status reporting, section 9 | Open | | +| S9 | Is a two-hour window before a full refund right for your clients? Is a refund sent from SatoshiPay's account acceptable? | Refund promise in the agreement | Open | | +| S10 | The agreement names a "Coinbase EURC oracle". The implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint. Is that what was meant? | Pricing terms | Open | | + +### 12.3 Vortex internal + +| ID | Question or task | Depends on | Status | +|---|---|---|---| +| V1 | Adapt onboarding to the proposal: start from Monerium's profile notification, and move an existing IBAN to the forwarder instead of requesting a new one. | M1 to M6 | Open | +| V2 | Final refund mechanism, option A as built or option B. | M8, S6 | Open | +| V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Today this is API only. | S4, M5 | Open | +| V4 | Reason codes on refunds, and a webhook for payments Monerium returns before minting. | S8 | Open | +| V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium. | Meeting | Open | + +## 13. Related documents + +For Vortex readers who need the detail behind this overview: + +- [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md): the + complete technical architecture. +- [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md): decisions, final + parameters and accepted risks. +- [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md): launch + gates, deploy checklist and agreement inputs. +- [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md): operator + procedures for onboarding, refunds and incidents. +- [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md): + security invariants. +- [`api/pages/07-webhooks.md`](api/pages/07-webhooks.md): the partner webhook format and + signature check. From d79eb863fea7c6593f2db6705fc5b70c7a0e8cc5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 15:50:16 +0200 Subject: [PATCH 46/74] docs(repo): lower the swap minimum to 1 EUR and the chunk cap to 10k EUR The team decided not to enforce a meaningful minimum amount, and smaller chunks cut price impact and per-chunk subsidy exposure. Both are deploy and guardian settings, so only the parameter registry and the documents that quote them change. --- docs/adr-0005-monerium-b2b-onramp.md | 25 ++++++++++++++++++++++-- docs/architecture-monerium-b2b-onramp.md | 4 ++-- docs/operations-monerium-b2b-rollout.md | 12 +++++++----- docs/operations-monerium-b2b-runbook.md | 4 ++-- docs/product-monerium-b2b-flow.md | 8 ++++---- 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index af3b91865..de44aab6d 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -237,6 +237,27 @@ for that refund is agreed commercially. Decisions (the proposal that led here is minutes on three weekends. Spot is noisier than the VWAP at 40 bps and identical at 60; the 60 bps decision stands. +## Amendment 2026-09-29: pilot parameter changes + +- **No practical swap minimum.** The team decided not to enforce a meaningful minimum + amount. The immutable `MIN_SWAP_FLOOR` and the operational `minSwapAmount` are both + €1 (P6; €25 and €250 before). Almost every payment now converts instead of waiting + for the refund path as sub-minimum. Accepted consequences: a very small payment costs + Vortex more gas than it earns; a small payment forwarded to an exchange address can + land below that exchange's minimum deposit, which now sits with the client under B5 + instead of Vortex's minimum-forward diligence; and unsolicited EURe from €1 upward arms + a clone's batch timers (from €25 before), which the stranded-balance monitor still + reports. The guardian can raise the operational minimum at any time without a + redeploy; it can never go below €1. +- **€10k chunks.** `perSwapCap` is €10k (P7; €25k before). The €25k was an operational + choice from the pre-deploy liquidity baseline, not a limit. Smaller chunks mean less + price impact per swap and a smaller top-up per chunk at the ladder's top (about + 115 USDC instead of 285), at the cost of more transactions and more time for large + payments. When the market needs the ladder's top tier, each chunk can wait up to + 16 minutes, so a payment above roughly €70k could reach the two-hour window; the + €50k per client per day pilot limit (B4) keeps a payment at five chunks or fewer. The + guardian can change the cap at any time up to the €50k ceiling. + ## Final parameters (decided 2026-08-26 unless noted) | ID | Parameter | Value | @@ -252,8 +273,8 @@ for that refund is agreed commercially. Decisions (the proposal that led here is | 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** | -| P7 | `perSwapCap` | operational **€25k** / ceiling €50k (re-measure liquidity at the deploy block before raising) | +| P6 | `minSwapAmount` | floor **€1** (immutable) / operational **€1**: no practical minimum (amended 2026-09-29; €25 / €250 before) | +| P7 | `perSwapCap` | operational **€10k** / ceiling €50k (amended 2026-09-29; €25k before; 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 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) | diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 4c27a081a..38bff645b 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -296,8 +296,8 @@ 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; + remainder when the last two chunks can share it (`planChunk`). A €45k deposit at a + €10k 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 diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 53831434c..c838c0bd4 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -62,15 +62,15 @@ fee policy 12.5 bps target / 15 bps floor (B1). immutable in the implementation. Confirm guardian key custody plan (EOA acceptable for pilot; hardware/multisig at GA). 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 + the liquidity baseline quote methodology (T6); confirm `perSwapCap` €10k 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, 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. + €1 floor/€50k ceiling, initial 5 bps/5 bps route); set operational `minSwapAmount` + €1 and `perSwapCap` €10k; 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. @@ -117,8 +117,10 @@ fee policy 12.5 bps target / 15 bps floor (B1). of changes before further deposits; client/partner bears rotation/closure/ 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. The destination is + the 60-day dormancy gate, and never sending unconverted EURe to the destination. + Vortex enforces no practical minimum (€1, ADR amendment 2026-09-29), so keeping + payments above an exchange destination's minimum deposit is the client's + responsibility. 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: diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index d61d51bfc..9cd1bc047 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -239,8 +239,8 @@ cast send $VAULT "withdraw(uint256)" --rpc-url $RPC --private-key $G 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 vault's is the ceiling. At the €10k per-swap cap a top-up at the ladder's top is +about 115 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 diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index 706e9bba8..3471b84b3 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -210,7 +210,7 @@ sequenceDiagram ### 6.1 Chunks -A payment larger than the per-swap cap of **€25,000** is converted in several chunks, a +A payment larger than the per-swap cap of **€10,000** is converted in several chunks, a few minutes apart. The chunks' USDC waits on the forwarder until the whole payment is converted. Two payments are never mixed in one swap. A single client's payments are converted one after the other, while different clients' payments convert in parallel. @@ -288,7 +288,7 @@ flowchart TD - It was **not converted within two hours** of the mint. Typical causes are a market move beyond the bounds, thin liquidity, an exhausted subsidy budget, or an operational fault. -- It, or its last remainder, is **below the €250 minimum swap**. +- It is **below the €1 minimum swap**. - An operator triggers it after a **compliance decision or an incident**. A payment is never partly delivered. If any part cannot be converted in time, the whole @@ -436,8 +436,8 @@ Statuses only move forward. A late or repeated webhook can never move a deposit | Reference band against Chainlink | 1% | Fixed in the contract | | Maximum Coinbase spread | 0.5% | Keeper code | | Subsidy ladder | none for 6 min, rising to 1% from 16 min | Keeper setting | -| Chunk size | up to €25,000 | Operational, ceiling €50,000 | -| Minimum swap | €250 | Operational, floor €25 | +| Chunk size | up to €10,000 | Operational, ceiling €50,000 | +| Minimum swap | €1 | Operational, can be raised but never below €1 | | Manual refunds | €15,000 and above | Until [M9] is answered | | Penny test | about 5 USDC | Per client | | Dormancy pause | 60 days without a conversion | Keeper code | From 34aaeab756e4d2c637f4023ee5ddb5ebbcaec7ab Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 15:50:49 +0200 Subject: [PATCH 47/74] docs(repo): make the Monerium B2B penny test optional The penny test was never agreed as a mandatory onboarding step. It stays a recommended check for exchange destinations and no longer appears in the partner-facing flow overview. --- .../admin/moneriumB2b.controller.ts | 4 +-- .../api/routes/v1/admin/monerium-b2b.route.ts | 2 +- .../api/services/monerium-b2b/onboarding.ts | 4 +-- docs/adr-0005-monerium-b2b-onramp.md | 10 +++++-- docs/architecture-monerium-b2b-onramp.md | 6 ++-- docs/operations-monerium-b2b-rollout.md | 8 ++--- docs/operations-monerium-b2b-runbook.md | 30 +++++++++---------- docs/product-monerium-b2b-flow.md | 7 +---- 8 files changed, 35 insertions(+), 36 deletions(-) diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts index e8c844318..1ad5712ec 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -125,8 +125,8 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st }); return; } - // Activation requires the issued IBAN: the penny test (runbook §7) cannot have - // happened without it, and the association monitor needs the reference state. + // Activation requires the issued IBAN: the client cannot pay in without it, and the + // association monitor needs the reference state. if (status === MoneriumAccountStatus.Active && account.iban === null) { res.status(httpStatus.CONFLICT).json({ error: { 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 16f383c2a..88f963b5c 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 @@ -15,7 +15,7 @@ router.use(adminAuth); // deployed forwarder as a B2B onramp account. Idempotent. router.post("/accounts", postMoneriumB2bAccount); -// Operator lifecycle transitions (activate after the penny test, suspend, close). +// Operator lifecycle transitions (activate, suspend, close). router.patch("/accounts/:accountId/status", patchMoneriumB2bAccountStatus); // Refund path (runbook §2.7): mark a settling deposit for recovery — the keeper moves diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.ts b/apps/api/src/api/services/monerium-b2b/onboarding.ts index 8e3738e3e..cd3e5813f 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.ts @@ -128,8 +128,8 @@ async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainN * Advances every mapped account still in onboarding: links its forwarder to the * Monerium profile with the attestor signature, then requests IBAN issuance. Both * provider writes run through the profile-scoped financial-operation ledger, so a - * crash or retry never repeats a claimed call. Activation (after the penny test) - * stays a manual operator step. + * crash or retry never repeats a claimed call. Activation stays a manual operator + * step. */ export async function advanceOnboardingAccounts(deps: OnboardingDeps = defaultDeps): Promise { if (!isOnboardingConfigured()) { diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index de44aab6d..942ca0c5f 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -257,13 +257,17 @@ for that refund is agreed commercially. Decisions (the proposal that led here is 16 minutes, so a payment above roughly €70k could reach the two-hour window; the €50k per client per day pilot limit (B4) keeps a payment at five chunks or fewer. The guardian can change the cap at any time up to the €50k ceiling. +- **Penny test optional.** The penny test (B2) was never agreed as a mandatory step. It + is an optional check, recommended for exchange destinations, and not an activation + requirement. A wrong or rotated destination is now caught by the penny test only when + one is run, otherwise by the dormancy gate; the loss allocation under B5 is unchanged. ## Final parameters (decided 2026-08-26 unless noted) | ID | Parameter | Value | |---|---|---| | 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 | +| B2 | Penny-test amount | 5 USDC, **optional** (amended 2026-09-29): recommended for exchange destinations, not an activation requirement | | 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 | @@ -315,7 +319,7 @@ example (oversized-deposit allocation). client-side: association monitor is the detective control; Monerium-side authorization requirements are the G1 ask; response = rotate + suspend (runbook). - **CEX destination rotation.** Not verifiable on-chain; carried contractually (B5) - with penny test, dormancy gate, and minimum-forward diligence. Silent-loss risk + with the dormancy gate and an optional penny test. Silent-loss risk converts to a pause via the dormancy gate. - **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 @@ -324,7 +328,7 @@ example (oversized-deposit allocation). 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 + caught by an optional penny test or by 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 diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 38bff645b..7aa8507b6 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -127,7 +127,7 @@ sequenceDiagram K->>M: POST /addresses (attestor-signed link) [exactly-once] K->>M: POST /ibans for the forwarder address [exactly-once] M-->>K: iban.updated webhook -> IBAN recorded - Op->>M: penny test (simulated/real small SEPA) + Op->>M: optional penny test (simulated/real small SEPA) Op->>Adm: PATCH .../accounts/:id/status "active" (refused without IBAN) ``` @@ -146,7 +146,7 @@ Steps in prose: 4. **Keeper automation** links the forwarder (attestor signature) and requests the IBAN, each exactly-once through the profile-scoped `financial_operations` ledger; the `iban.updated` webhook records the IBAN. -5. **Penny test**, then activation via the admin status endpoint. +5. **Optional penny test**, then activation via the admin status endpoint. ## Deposit-to-payout sequence @@ -265,7 +265,7 @@ stateDiagram-v2 direction LR state "Account (monerium_accounts)" as acc { [*] --> onboarding : admin mapping - onboarding --> active : penny test + admin PATCH (needs IBAN) + onboarding --> active : admin PATCH (needs IBAN) active --> suspended suspended --> active active --> closed diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index c838c0bd4..68c82591f 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -97,8 +97,8 @@ fee policy 12.5 bps target / 15 bps floor (B1). 11. Set `MONERIUM_B2B_ENABLED=true` on only the designated `mykobo` keeper backend and restart. Startup must fail if any required B2B setting is absent. Confirm the routes, raw webhook parser, and keeper are active before accepting a deposit. -12. Per client: runbook §1 (deploy clone → map → automated link/IBAN → penny test → - activate). +12. Per client: runbook §1 (deploy clone → map → automated link/IBAN → optional penny + test → activate). ## Terms & disclosure inputs (engineering-accurate; G2/partner own final wording) @@ -116,8 +116,8 @@ fee policy 12.5 bps target / 15 bps floor (B1). warrants the destination is valid and under the client's control and notifies Vortex of changes before further deposits; client/partner bears rotation/closure/ 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, and never sending unconverted EURe to the destination. + attestation. Vortex's diligence consideration: an optional 5 USDC penny test before + activation, recommended for exchange destinations, the 60-day dormancy gate, and never sending unconverted EURe to the destination. Vortex enforces no practical minimum (€1, ADR amendment 2026-09-29), so keeping payments above an exchange destination's minimum deposit is the client's responsibility. The destination is diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 9cd1bc047..1e20835c7 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -31,7 +31,8 @@ Ground rules that shape every procedure here: ## 1. Client onboarding -Deploy → manifest → verify → map → (automated: link + IBAN) → penny test → activate. +Deploy → manifest → verify → map → (automated: link + IBAN) → optional penny test → +activate. 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, @@ -50,7 +51,7 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag (rotation risk — terms). - (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.) + §5 — so get it right; a penny test is recommended for exchange destinations.) - `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). @@ -114,21 +115,19 @@ with the attestor signature (`POST /addresses` — HTTP 201, `state: linked`, ze interaction), then requests IBAN issuance (`POST /ibans`, async 202). The IBAN lands on the account row via the `iban.updated` webhook; from then on the association monitor treats the DB record as the reference state. Nothing to do manually — verify the row -has its IBAN before the penny test, and check the logs if it stays empty for more than +has its IBAN before activation, and check the logs if it stays empty for more than a few cycles. -### 1.6 Penny test +### 1.6 Penny test (optional) -Prove the destination actually credits contract-originated USDC transfers (CEXes can -rotate or mis-credit) before real volume flows: +Optional, and recommended for exchange destinations (ADR amendment 2026-09-29): prove +the destination actually credits contract-originated USDC transfers (CEXes can rotate +or mis-credit) before real volume flows. Skipping it does not block activation. 1. Send a small SEPA deposit to the new IBAN (sandbox: dashboard → Receive → "Simulate bank transfer"). Target forward amount: 5 USDC (ADR B2). -2. The keeper converts automatically once the balance reaches `minSwapAmount`; for a - sub-minimum penny test, temporarily lower `minSwapAmount` (guardian, bounded by the - floor) or fund up to the minimum. -3. **Partner/client confirms credit at the destination** in writing (a terms diligence - commitment). +2. The keeper converts it like any other payment (the minimum swap is €1). +3. **Partner/client confirms credit at the destination** in writing. ### 1.7 Activate @@ -200,7 +199,7 @@ Suspected vulnerability in `VortexForwarder`/factory: 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 + clones, re-link, move IBANs, optionally penny-test, republish the manifest. Old clones stay paused; residual balances leave through the refund path. 7. **Unpause / decommission** only contracts confirmed unaffected. @@ -347,7 +346,8 @@ 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, 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 +Vortex must never redirect — and re-running the penny test is recommended for CEX +destinations. Archive the confirmation. **Un-pause (both steps, always):** @@ -378,8 +378,8 @@ monitor's alerts are expected, then: account row's forwarder is repointed, or manual `POST /addresses`). 4. Move the IBAN: `PATCH /ibans/{iban}` with the new address — this is the S1-sensitive operation; it must only ever happen inside an announced migration. -5. Update the `monerium_accounts` row (forwarder address), penny-test the new clone, - re-activate. +5. Update the `monerium_accounts` row (forwarder address), optionally penny-test the new + clone, re-activate. There is no unlink at Monerium and no custodial parking position: EURe always mints to the IBAN's current default address; the old clone stays linked but inert. diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index 3471b84b3..c33a02e84 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -101,8 +101,6 @@ subsidy on a swap, or the refund path. points at the forwarder**. Monerium mints to whatever address the IBAN points at, so this is what routes every payment through the conversion. An IBAN pointing at the client's own wallet would deliver EURe, not USDC. -- A **penny test** has shown that the destination credits USDC sent from a contract. The - client confirms receipt of about 5 USDC in writing. - Vortex has mapped the client under **SulPayments' partner account**, so webhooks and API reads reach SulPayments. @@ -129,8 +127,6 @@ sequenceDiagram V->>M: Link forwarder to the profile with a contract signature [M3] V->>M: IBAN on the forwarder, request new or move existing [M3] M-->>V: IBAN issued or moved - CL->>M: Penny test, small SEPA payment - V->>CL: About 5 USDC to the destination, client confirms V->>SP: Client active, IBAN visible in dashboard or API [S4, V3] SP->>CL: Hand over IBAN, client starts sending EUR ``` @@ -156,7 +152,7 @@ Notes on the proposal: Monerium profile ID, SulPayments' client ID, the destination and the fee policy. - The keeper then links the forwarder and requests the IBAN automatically. The IBAN is recorded when Monerium confirms it. -- After the penny test, the operator activates the account. +- The operator then activates the account. - SulPayments can read the account and its IBAN through the Vortex API. There is no dashboard view yet **[V3]**. - Adopting the proposal changes two things: onboarding starts from Monerium's profile @@ -439,7 +435,6 @@ Statuses only move forward. A late or repeated webhook can never move a deposit | Chunk size | up to €10,000 | Operational, ceiling €50,000 | | Minimum swap | €1 | Operational, can be raised but never below €1 | | Manual refunds | €15,000 and above | Until [M9] is answered | -| Penny test | about 5 USDC | Per client | | Dormancy pause | 60 days without a conversion | Keeper code | | Pilot volume | 3 to 5 clients, €50,000 per client per day | Contractual | From 8352e93ee57a5a6544bbef1a26d6262848175748 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 17:10:21 +0200 Subject: [PATCH 48/74] docs(repo): answer Monerium flow questions and propose per-client refund addresses Monerium's public API spec already answers several open questions, and it shows that any address linked to a profile can pay out through that profile's IBAN. That lets a refund leave from the client's own IBAN without a contract change, and lets SulPayments register destinations with Vortex instead of routing them through Monerium. --- docs/product-monerium-b2b-flow.md | 200 ++++++++++++++++++++---------- 1 file changed, 132 insertions(+), 68 deletions(-) diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index c33a02e84..b2d5435dc 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -6,6 +6,9 @@ > pilot on the branch of PR #1375. It is not merged or deployed yet. Open questions carry > an ID such as **[M1]** (Monerium), **[S1]** (SulPayments) or **[V1]** (Vortex internal) > and are collected in [section 12](#12-open-questions), with space for the answers. +> Changes proposed but not built yet are marked **Proposed**. Answers taken from +> Monerium's public API spec (version 2.0.0) are marked as such until Monerium confirms +> them. ## Contents @@ -45,7 +48,8 @@ the two-hour window, Vortex's recovery wallet. | **Forwarder contract** | One per client on Ethereum. Receives the minted EURe, swaps it, holds the USDC until the payment is complete, then forwards it. | | **Subsidy vault** | A Vortex-funded USDC pool that tops up a swap when the market delivers less than the client's guaranteed floor. | | **Fee treasury** | Vortex multisig that receives the conversion fee. | -| **Recovery wallet and float wallet** | Two Vortex addresses on a Vortex/SatoshiPay company profile at Monerium, used only for refunds. | +| **Recovery wallet and float wallet** | Two Vortex wallets used only for refunds. The recovery wallet receives a payment whose window was missed, and the float covers round-trip losses. | +| **Refund address** | **Proposed.** One Vortex address per client, linked to the client's Monerium profile, from which a refund is paid out through the client's own IBAN. | | **Price sources** | Coinbase Exchange EURC-USDC market for the reference rate, Chainlink EUR/USD as the on-chain safety bound, Uniswap v3 where the swaps execute. | ## 3. The flow at a glance @@ -65,7 +69,7 @@ flowchart LR VAULT["Subsidy vault"] TREAS["Fee treasury"] DEST["Client wallet
fixed destination"] - REC["Vortex recovery wallet"] + REC["Vortex recovery wallet
and refund address"] end subgraph Vx["Vortex"] KEEP["Keeper service"] @@ -101,50 +105,72 @@ subsidy on a swap, or the refund path. points at the forwarder**. Monerium mints to whatever address the IBAN points at, so this is what routes every payment through the conversion. An IBAN pointing at the client's own wallet would deliver EURe, not USDC. +- **Proposed:** a Vortex **refund address** is linked to the client's profile as well, so + a refund can leave from the client's own IBAN (section 8.3). - Vortex has mapped the client under **SulPayments' partner account**, so webhooks and API reads reach SulPayments. ### 4.2 Proposed onboarding flow -This is the flow SatoshiPay proposed for the pilot. Steps marked with an ID still need -an answer. +SulPayments registers each client and its destination with Vortex, and sends the KYB +data to Monerium. A profile ID that Vortex issues ties the two together. Steps marked with +an ID still need an answer. ```mermaid sequenceDiagram autonumber participant SP as SulPayments - participant M as Monerium participant V as Vortex + participant M as Monerium participant C as Ethereum participant CL as Client - SP->>M: Client onboarding data incl. KYB [M1, M2, S2] - M->>M: Create white-label profile and review KYB - M-->>V: Profile created or approved notification [M6] - SP->>V: Destination wallet and Monerium profile ID [S3] - V->>V: Verify profile is approved and matches the SulPayments client + SP->>V: Register client and destination wallet [S3, V6] + V-->>SP: Profile ID for Monerium to use [M14] + SP->>M: KYB data and reliance attestation, with that profile ID [M1, S2] + M->>M: Create the profile in Vortex's white-label app and review KYB + M-->>V: profile.updated, state approved + V->>V: Match the profile ID to the registered client V->>C: Deploy forwarder with destination and fee policy - V->>M: Link forwarder to the profile with a contract signature [M3] - V->>M: IBAN on the forwarder, request new or move existing [M3] - M-->>V: IBAN issued or moved - V->>SP: Client active, IBAN visible in dashboard or API [S4, V3] + V->>M: Link forwarder and refund address to the profile [M3, M8] + V->>M: Request the IBAN for the forwarder [M3] + M-->>V: iban.updated, IBAN issued + V->>SP: Client active, IBAN in the API, later the dashboard [S4, V3] SP->>CL: Hand over IBAN, client starts sending EUR ``` -Notes on the proposal: - -- **Destination on the Monerium profile.** The original proposal has Monerium add the - destination address to the profile, and Vortex swap it for the forwarder address - later. Linking an address at Monerium needs a signature from that address's owner, - which exchange deposit addresses cannot give. Vortex also does not need the - destination at Monerium, because it is stored in the forwarder contract. The simpler - route is for SulPayments to send the destination to Vortex directly **[S3]**. +Notes on the flow: + +- **The destination goes to Vortex, not Monerium.** Monerium's profile API has no field + that could carry it, linking an address needs a signature from its owner, which + exchange deposit addresses cannot give, and Monerium does not need it: only the + forwarder contract uses the destination. +- **A new partner API call carries the destination (proposed).** SulPayments can already + create client profiles through the Vortex API with its own client ID. One new call + attaches the destination to such a client **[V6]**. The destination is create-only, + because it is fixed in the contract; a change means a new account on SulPayments' + written instruction. Vortex validates the address, rejects zero, token and contract + addresses, and requires SulPayments to confirm that an exchange address does not + rotate. The call carries no KYB data. Activation stays a Vortex operator step in the + pilot, since the destination decides where a client's USDC goes. +- **Pilot without the new call.** For the first 3 to 5 clients, SulPayments can put the + destination in the signed onboarding form, and Vortex operations enters it through the + existing admin endpoint. The new call is worth building once the size of the + existing-client batch is known **[S1]**. +- **Matching by profile ID.** Monerium's create-profile call accepts a profile ID chosen + by the creator. If Monerium creates the profile with the ID Vortex issued, the approval + notification matches the registered client without a manual step. Otherwise + SulPayments sends Vortex the Monerium profile ID once it exists **[M14]**. +- **KYB goes from SulPayments to Monerium.** Monerium's spec reserves the endpoint for + submitting company details to partners under its KYC reliance model, and KYC sharing + through Sumsub covers personal profiles only. SulPayments performs the KYB and gives + Monerium a reliance attestation per client. Vortex submits no KYB data, which matches + the security spec **[M1]**. - **IBAN before the forwarder exists.** Monerium issues one IBAN per profile. If Monerium creates it before the forwarder is linked, it points at another address, and - Vortex must move it to the forwarder before anyone pays into it. Until then, a payment - would mint EURe somewhere other than the forwarder **[M3]**. -- **New-profile notification.** Monerium's white-label webhooks include - `profile.updated`, with no separate creation event **[M6]**. + Vortex must move it to the forwarder before anyone pays into it **[M3]**. +- **Approval notification.** Per Monerium's spec, `profile.updated` fires on every + profile state change, including approval, and is subscribed by default **[M6]**. ### 4.3 What is built today @@ -155,8 +181,10 @@ Notes on the proposal: - The operator then activates the account. - SulPayments can read the account and its IBAN through the Vortex API. There is no dashboard view yet **[V3]**. -- Adopting the proposal changes two things: onboarding starts from Monerium's profile - notification instead of an operator call, and the keeper moves an existing IBAN +- Adopting the proposal changes four things. SulPayments supplies the destination + through the new API call **[V6]**. Onboarding starts from Monerium's approval + notification, matched by profile ID **[V1]**. The keeper links a refund address next + to the forwarder **[V2]**. If Monerium creates the IBAN first, the keeper moves it instead of requesting a new one **[V1]**. ### 4.4 Decisions behind onboarding @@ -194,7 +222,10 @@ sequenceDiagram - Vortex listens on **two channels**. Monerium's webhooks carry the order details: amount, order ID, compliance holds, and the payer's IBAN and name. Vortex's own chain watcher proves the EURe actually arrived on the forwarder. A deposit becomes eligible - for conversion only when both agree **[M7]**. + for conversion only when both agree. +- Per Monerium's spec, `order.created` arrives when the order is placed and + `order.updated` once it is processed or rejected. There is no separate event when + minting starts **[M7]**. - The payer's **IBAN and name are stored** from the order. They are the target of any refund. - The **two-hour window starts at the mint**, not at the SEPA transfer, because Vortex @@ -290,7 +321,7 @@ flowchart TD A payment is never partly delivered. If any part cannot be converted in time, the whole payment is refunded, including chunks that were already converted. -### 8.2 How a refund runs (as built) +### 8.2 How a refund runs ```mermaid sequenceDiagram @@ -299,6 +330,7 @@ sequenceDiagram participant R as Recovery wallet participant U as Uniswap participant FL as Float wallet + participant RA as Client's refund address participant M as Monerium participant B as Payer's bank participant SP as SulPayments @@ -308,42 +340,66 @@ sequenceDiagram F->>R: EURe and USDC, contract refuses before 2 hours R->>U: Swap USDC back to EURe FL->>R: Top up to the exact original amount - R->>M: Redeem order, exact EUR amount to the payer's IBAN - M->>B: SEPA payout + R->>RA: Exact EURe amount, proposed step + RA->>M: Redeem order to the payer's IBAN + M->>B: SEPA payout from the client's IBAN M-->>V: order.updated, redemption processed V-->>SP: DEPOSIT_RETURNED webhook ``` +Everything up to the top-up is built. The hop through the client's refund address is the +proposal in section 8.3. As built today, the recovery wallet places the redeem itself, +from the Vortex/SatoshiPay company profile. + - The payer gets back the **exact EUR amount**. Losses from the round trip and fees already taken on converted chunks are Vortex's cost, paid from the float wallet. - The contract **enforces the two-hour window**. The forwarder cannot move funds to the recovery wallet any earlier. -- The refund is sent from the **Vortex/SatoshiPay company account** at Monerium. The - payer's bank statement shows that sender, with a reference to the original payment. +- With the proposal, the refund leaves from the **client's own IBAN**, in the client's + name, with a reference to the original payment. - SEPA Instant is used when the payer's bank supports it, otherwise next business day. -- Refunds of **€15,000 or more stay manual** until Monerium confirms whether a supporting - document is required **[M9]**. +- Monerium requires a **supporting document**, an invoice or an agreement, on + redemptions above €15,000. Refunds of €15,000 or more stay manual until Monerium + confirms which document it accepts for a refund **[M9]**. - Refunds run one at a time and survive a crash of the keeper midway. A refund that fails its retries goes to **recovery failed** and is handed to Vortex operations. - Rollout: the automation first runs in **alert mode**, where Vortex operators confirm each refund. It switches to **automatic** after the first refund has been observed end to end. -### 8.3 Refund mechanism options +### 8.3 Proposed: refund from the client's own IBAN -The proposal raised whether the refund could come from the **client's own Monerium -IBAN**, and how to do that securely. The options: +A Monerium profile can have several linked addresses, and per Monerium's spec any linked +address can use the profile's IBAN for outgoing payments. An address belongs to exactly +one profile. The proposal builds on that: -| | A. Vortex recovery wallet (built) | B. Contract-signed redemption from the client's profile | C. Monerium return facility | -|---|---|---|---| -| **Refund sent from** | Vortex/SatoshiPay company profile | The client's own profile and IBAN | The client's profile, by Monerium | -| **Custody** | Vortex holds the funds during the refund only | Funds stay on the client's forwarder until burned | Monerium | -| **Main risk** | Payer must trust Vortex to complete the refund | The contract cannot know who the payer was. A stolen keeper key could redeem to any IBAN unless the contract only allows an agreed list of IBANs per client | Depends on Monerium's process | -| **What it needs** | Nothing more to build. Monerium sign-off for one profile refunding many payers **[M8]** | Contract changes: an IBAN allowlist per client with delayed changes, reverse swap and top-up on the forwarder, redemption signature checks. Re-audit. Monerium accepting contract-signed redemptions **[M8]** | A native Monerium feature, not documented today **[M8]** | -| **Bank statement shows** | SatoshiPay / Monerium | The client's Monerium account | To be confirmed | +- At onboarding, Vortex links a second address to each client profile next to the + forwarder: a Vortex-controlled **refund address**, one per client. +- For a refund, the recovery wallet sends the exact EURe amount to that client's refund + address, which places the redeem order. The payer receives the refund from the + client's own IBAN, in the client's name. +- The client authorizes Vortex to send these refunds in the SulPayments terms. -Option B is only safe if clients pay from a small, known set of bank accounts, such as -their own business accounts **[S6]**. The decision is open **[V2]**. +What it changes: + +- **No contract change.** The backend derives one refund key per client from a single + seed, links it at onboarding, and adds one transfer to the refund steps **[V2]**. +- **No company profile needed for refunds.** The recovery and float wallets no longer + need to be linked at Monerium, so the Vortex/SatoshiPay company profile stops being a + prerequisite for deploying the contracts **[M11]**. +- **Custody unchanged.** Vortex holds a payment only between the missed window and the + payout. A refund address only ever holds the payment being refunded. + +Status: proposed to Monerium on 2026-09-29, not built yet **[M8]**. + +Alternatives considered: + +- **Built today:** the redeem is placed from the Vortex/SatoshiPay company profile. It + works without further changes, but the payer sees SatoshiPay as the sender, and one + company profile pays many unrelated payers. +- **Redeem signed by the forwarder contract.** The refund would also leave from the + client's IBAN, but it needs contract changes, an allowlist of payer IBANs per client, + and a new audit. ## 9. Status, reporting and support @@ -410,6 +466,8 @@ Statuses only move forward. A late or repeated webhook can never move a deposit 24-hour on-chain notice. Lowering it is immediate. - Fund or limit its own subsidy budget. - Move a payment to its recovery wallet, only after the two-hour window, to refund it. + As proposed, the refund is then paid out from the refund address Vortex holds on the + client's profile. **Vortex cannot:** @@ -442,46 +500,52 @@ Statuses only move forward. A late or repeated webhook can never move a deposit ### 12.1 Monerium +Status "Follow-up" means the question is in the endpoint-referenced follow-up to +Monerium of 2026-09-29. + | ID | Question | Why it matters | Status | Answer | |---|---|---|---|---| -| M1 | How should new clients be onboarded into our white-label app? | Defines the onboarding flow in section 4.2 | Asked 2026-09-29 on Telegram | | -| M2 | How are SulPayments' existing clients imported? How long does KYB take once SulPayments' data is in, and is that business hours? | Pilot timeline and client expectations | Open | | -| M3 | For a profile Monerium creates, can Vortex link the forwarder with its contract signature and put the IBAN on it? If the IBAN already exists on another address, may Vortex move it, and can Monerium promise not to move it back? | The IBAN must point at the forwarder before a client pays | Open | | -| M4 | Can our white-label app list all profiles Monerium creates for SulPayments' clients? The public spec lists the profiles an app "has access to", and in the sandbox profiles onboarded through another app were not visible. | Automated onboarding and reconciliation | Open | | -| M5 | Which profile data can we read? The public spec shows ID, name, kind, state, section states and a rejection or closure reason, plus each IBAN's BIC, linked address and chain, but not the submitted KYB details. | What the SulPayments dashboard view can show | Open | | -| M6 | Does `profile.updated` fire when Monerium creates and approves a profile in our app? | Trigger for automated onboarding | Open | | -| M7 | Do `order.created` and `order.updated` arrive as described in section 5, with the payer's IBAN and name on the order? To be confirmed with a sandbox SEPA simulation. | The payer IBAN is the refund target | Open, sandbox test pending | | -| M8 | Refund mechanism: do you accept option A, one Vortex company profile refunding many payers? Is there a native return-to-sender facility, option C? Would you accept contract-signed redemptions from the client's profile, option B? | Refund design, section 8.3 | Open | | -| M9 | Does a refund of €15,000 or more to the original payer need a supporting document? Could the original payment serve as one? | Whether large refunds can run automatically | Open | | -| M10 | Which outgoing limits, fees and cut-off times apply to redemptions from our company profile? | Refund timing promise | Open | | -| M11 | Onboarding of the Vortex/SatoshiPay company profile with a recovery address and a float address. | Needed before the contracts can be deployed, since the recovery address is fixed in them | Open | | +| M1 | How does the KYB data reach Monerium, and who creates the profile in our white-label app? | Defines onboarding, section 4.2 | Asked 2026-09-29 on Telegram, follow-up | Partly, from the spec: submitting company details is reserved for partners under Monerium's KYC reliance model, and KYC sharing through Sumsub supports personal profiles only. Expected: SulPayments sends the KYB under its reliance arrangement, Monerium creates the profile, Vortex submits no KYB data. | +| M2 | How are SulPayments' existing clients imported? How long does KYB take once SulPayments' data is in, and is that business hours? | Pilot timeline and client expectations | Follow-up | | +| M3 | For a profile Monerium creates, can Vortex link the forwarder with its contract signature and put the IBAN on it? If Monerium creates the IBAN first, may Vortex move it, and can Monerium promise not to move it back? | The IBAN must point at the forwarder before a client pays | Follow-up | Partly, from the spec: a contract can be linked to a given profile with an ERC-1271 signature, an IBAN can be requested for an address, and an existing IBAN can be moved. Open: who creates the IBAN, and the commitment not to move it. | +| M4 | Can our white-label app list all profiles Monerium creates for SulPayments' clients? | Automated onboarding and reconciliation | Follow-up | Yes for profiles in our app: the profile list returns every profile our app has access to, filterable by state and kind. Profiles created through another app are not visible, as seen in the sandbox on 2026-09-14. Open: confirm SulPayments' clients are created in our app. | +| M5 | Which profile data can we read? | What the SulPayments dashboard view can show | Answered from the spec | Per profile: ID, name, kind, state, section states, and a rejection or closure reason. Per IBAN: IBAN, BIC, connected address and chain. Linked addresses per profile. Orders per profile, with the payer's IBAN and name. Submitted company details are not returned. | +| M6 | Does `profile.updated` fire when Monerium creates and approves a profile in our app? | Trigger for automated onboarding | Answered from the spec | It fires on every profile state change, including approval, and is subscribed by default. | +| M7 | Do `order.created` and `order.updated` arrive as described in section 5, with the payer's IBAN and name on the order? | The payer IBAN is the refund target | Answered from the spec, sandbox test pending | `order.created` when the order is placed, `order.updated` when it is processed or rejected, no separate event when minting starts. The order carries the payer's IBAN and name. To confirm with a sandbox SEPA simulation. | +| M8 | Is a refund from a Vortex refund address linked to each client profile feasible and acceptable, section 8.3? Does it always leave from the client's IBAN in the client's name? | Refund design | Follow-up | Partly, from the spec: any linked address can use the profile's IBAN for outgoing payments, and an address has a single owning profile. Open: Monerium's acceptance. | +| M9 | Which supporting document does Monerium accept for a refund of more than €15,000 to the original payer? Could one standing agreement per client be reused? | Whether large refunds can run automatically | Follow-up | Partly, from the spec: redemptions above €15,000 need a supporting document, an invoice or an agreement, uploaded once and referenced by ID. | +| M10 | Which outgoing limits, fees and cut-off times apply to the refund redemptions? | Refund timing promise | Follow-up | | +| M11 | Onboarding of a Vortex/SatoshiPay company profile with a recovery address and a float address. | Needed for refunds as built, and before deploy, since the recovery address is fixed in the contracts | Depends on M8 | Not needed if the refund addresses of section 8.3 are accepted. | | M12 | Production white-label API credentials for the B2B app. | Needed before go-live | Open | | | M13 | Written confirmation of the items agreed verbally so far: the attestor-signed link, the redemption-limitation disclosure, the issuer recovery backstop, what authorizes an IBAN move or new address link, SEPA recall and fraud loss allocation, per-IBAN suspension, and advance notice of changes to the link message. | Launch gate | Open | | +| M14 | Can Monerium create each profile with a profile ID Vortex issues? The create-profile call accepts a partner-supplied ID. | Automatic matching of approved profiles to registered clients | Follow-up | | +| M15 | Are there fees per profile or per KYB review? | Commercial planning | Follow-up | | ### 12.2 SulPayments | ID | Question | Why it matters | Status | Answer | |---|---|---|---|---| -| S1 | Onboarding volume: an initial batch of existing clients, then new clients one by one? How many, and when? | Operational planning, manual versus automated onboarding | Open | | -| S2 | Does SulPayments send client onboarding data, including KYB, directly to Monerium? Who are the contacts on each side? | Onboarding flow, section 4.2 | Open | | -| S3 | How is each client's destination wallet and Monerium profile ID given to Vortex: Vortex dashboard or Vortex API? Who at SulPayments approves it? | The destination is fixed in the contract, so it must be right the first time | Open | | -| S4 | Plan: each client's IBAN is shown in the Vortex dashboard and API. Is that how SulPayments hands IBANs to clients? | Dashboard scope | Open | | -| S5 | Confirm that the smart-contract address on each client's Monerium profile is managed by Vortex, and that clients never link or change it themselves. | Only the forwarder may receive the minted EURe | Open | | -| S6 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target, and whether option B is viable | Open | | +| S1 | Onboarding volume: an initial batch of existing clients, then new clients one by one? How many, and when? | Decides when the destination API call is worth building | Open | | +| S2 | Does SulPayments send client onboarding data, including KYB, directly to Monerium? Who are the contacts on each side? | Onboarding flow, section 4.2 | Open | Expected yes, see M1. | +| S3 | How does SulPayments give Vortex each client's destination wallet? Who at SulPayments approves it? | The destination is fixed in the contract, so it must be right the first time | Proposal to confirm | Proposed: a new Vortex API call per client, section 4.2. For the pilot, the signed onboarding form. | +| S4 | Plan: each client's IBAN is shown in the Vortex API and dashboard. Is that how SulPayments hands IBANs to clients? | Dashboard scope | Proposal to confirm | The IBAN is readable through the Vortex API today. A dashboard view is planned **[V3]**. | +| S5 | Confirm that the Vortex addresses on each client's Monerium profile are managed by Vortex, and that clients never link or change them. | Only the forwarder may receive the minted EURe | Answered by design | Yes. Vortex links and manages the forwarder and, as proposed, the refund address. Clients never touch them. | +| S6 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target and the refund wording in the terms | Open | | | S7 | Are destinations self-custody wallets or exchange deposit addresses? | Exchange addresses need an attestation that they do not rotate and accept contract transfers | Open | | | S8 | Are the three webhooks plus polling enough? Are reason codes needed on refunds? Webhook endpoint and support contacts. | Status reporting, section 9 | Open | | -| S9 | Is a two-hour window before a full refund right for your clients? Is a refund sent from SatoshiPay's account acceptable? | Refund promise in the agreement | Open | | +| S9 | Is a two-hour window before a full refund right for your clients? Will clients authorize Vortex to send refunds from their IBAN, as proposed in section 8.3? | Refund promise in the agreement | Open | | | S10 | The agreement names a "Coinbase EURC oracle". The implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint. Is that what was meant? | Pricing terms | Open | | ### 12.3 Vortex internal | ID | Question or task | Depends on | Status | |---|---|---|---| -| V1 | Adapt onboarding to the proposal: start from Monerium's profile notification, and move an existing IBAN to the forwarder instead of requesting a new one. | M1 to M6 | Open | -| V2 | Final refund mechanism, option A as built or option B. | M8, S6 | Open | +| V1 | Adapt onboarding: start from Monerium's approval notification matched by profile ID, and move an existing IBAN to the forwarder only if Monerium creates it first. | M1, M3, M14 | Open | +| V2 | Refunds through per-client refund addresses: one refund key per client derived from one seed, linked at onboarding, one extra transfer per refund. No contract change. | M8 | Proposed, build after Monerium confirms | | V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Today this is API only. | S4, M5 | Open | | V4 | Reason codes on refunds, and a webhook for payments Monerium returns before minting. | S8 | Open | | V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium. | Meeting | Open | +| V6 | Partner API call to register a client's destination: create-only, validated, no KYB data, operator approval in the pilot. The pilot can run without it through the signed onboarding form. | S1, S3 | Proposed | ## 13. Related documents From cc5261668a4ba3b414fbdc3798bf4ef3047cbd12 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 11:04:33 +0200 Subject: [PATCH 49/74] docs(repo): record Monerium's answers in the B2B flow overview Monerium confirmed that the client profiles live in SulPayments' white-label app and answered most onboarding, notification and refund questions. The overview now reflects that setup, the destination handover by profile ID, and the remaining open points per party. --- docs/product-monerium-b2b-flow.md | 271 ++++++++++++++++++------------ 1 file changed, 160 insertions(+), 111 deletions(-) diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index b2d5435dc..52c1ee032 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -1,14 +1,15 @@ # Monerium B2B Onramp: End-to-End Flow -> **Status:** living overview, draft for alignment. Last updated 2026-09-29. +> **Status:** living overview, draft for alignment. Last updated 2026-09-30, including +> Monerium's written answers of that day. > **Audience:** Vortex/SatoshiPay internally, SulPayments, and Monerium. > **Scope:** the EUR to USDC onramp for SulPayments' business clients, as built for the > pilot on the branch of PR #1375. It is not merged or deployed yet. Open questions carry > an ID such as **[M1]** (Monerium), **[S1]** (SulPayments) or **[V1]** (Vortex internal) > and are collected in [section 12](#12-open-questions), with space for the answers. -> Changes proposed but not built yet are marked **Proposed**. Answers taken from -> Monerium's public API spec (version 2.0.0) are marked as such until Monerium confirms -> them. +> Changes proposed but not built yet are marked **Proposed**. Facts from Monerium's +> public API spec (version 2.0.0) and guides are marked as such where Monerium has not +> confirmed them in writing. ## Contents @@ -41,15 +42,15 @@ the two-hour window, Vortex's recovery wallet. | Party or component | Role | |---|---| -| **SulPayments** | Partner. Brings the business clients, performs their KYB (Monerium relies on it), receives status webhooks, and hands each client its IBAN. | +| **SulPayments** | Partner. Brings the business clients, owns the white-label app at Monerium in which their profiles live, performs and submits their KYB under its reliance agreement with Monerium, receives status webhooks, and hands each client its IBAN. | | **Client** | The business that sends EUR and receives USDC in its own wallet, the **destination**. | -| **Monerium** | Licensed EURe issuer. Hosts each client's profile and IBAN in Vortex's white-label app, mints EURe for incoming SEPA payments, and pays out EUR on redemption. | -| **Vortex / SatoshiPay** | Operator. Deploys the contracts, runs the **keeper** service that converts and forwards, runs refunds, and reports status. | +| **Monerium** | Licensed EURe issuer. Hosts each client's profile and IBAN in SulPayments' white-label app, mints EURe for incoming SEPA payments, and pays out EUR on redemption. | +| **Vortex / SatoshiPay** | Operator. Deploys the contracts, runs the **keeper** service that converts and forwards, runs refunds, and reports status. Uses SulPayments' white-label app credentials for everything after KYB. | | **Forwarder contract** | One per client on Ethereum. Receives the minted EURe, swaps it, holds the USDC until the payment is complete, then forwards it. | | **Subsidy vault** | A Vortex-funded USDC pool that tops up a swap when the market delivers less than the client's guaranteed floor. | | **Fee treasury** | Vortex multisig that receives the conversion fee. | | **Recovery wallet and float wallet** | Two Vortex wallets used only for refunds. The recovery wallet receives a payment whose window was missed, and the float covers round-trip losses. | -| **Refund address** | **Proposed.** One Vortex address per client, linked to the client's Monerium profile, from which a refund is paid out through the client's own IBAN. | +| **Refund address** | **Proposed.** One Vortex address per client, linked to the client's Monerium profile, from which a refund is paid out through the client's own IBAN. Confirmed as technically possible by Monerium. | | **Price sources** | Coinbase Exchange EURC-USDC market for the reference rate, Chainlink EUR/USD as the on-chain safety bound, Uniswap v3 where the swaps execute. | ## 3. The flow at a glance @@ -96,8 +97,8 @@ subsidy on a swap, or the refund path. ### 4.1 What has to be true when onboarding ends -- The client has a **KYB-approved corporate profile** in Vortex's white-label app at - Monerium, based on SulPayments' KYB under a reliance arrangement. +- The client has a **KYB-approved corporate profile** in SulPayments' white-label app at + Monerium, submitted by SulPayments under its reliance agreement. - A **forwarder contract** exists with the client's destination wallet written into it. The destination cannot be changed later. A new wallet means a new forwarder and moving the IBAN, on SulPayments' written instruction. @@ -112,65 +113,73 @@ subsidy on a swap, or the refund path. ### 4.2 Proposed onboarding flow -SulPayments registers each client and its destination with Vortex, and sends the KYB -data to Monerium. A profile ID that Vortex issues ties the two together. Steps marked with -an ID still need an answer. +SulPayments owns the white-label app at Monerium and submits each client's KYB there. +Monerium returns the new profile's ID, and SulPayments passes it to Vortex together with +the client's destination wallet. Vortex does everything after KYB with the same app's +credentials. ```mermaid sequenceDiagram autonumber participant SP as SulPayments - participant V as Vortex participant M as Monerium + participant V as Vortex participant C as Ethereum participant CL as Client - SP->>V: Register client and destination wallet [S3, V6] - V-->>SP: Profile ID for Monerium to use [M14] - SP->>M: KYB data and reliance attestation, with that profile ID [M1, S2] - M->>M: Create the profile in Vortex's white-label app and review KYB + SP->>M: Create the client profile in SulPayments' app + M-->>SP: Profile ID + SP->>M: Company details, form and verifications + SP->>V: Destination wallet for this profile ID [V6] + V->>M: Check the profile exists in SulPayments' app M-->>V: profile.updated, state approved - V->>V: Match the profile ID to the registered client V->>C: Deploy forwarder with destination and fee policy - V->>M: Link forwarder and refund address to the profile [M3, M8] - V->>M: Request the IBAN for the forwarder [M3] + V->>M: Link forwarder and refund address to the profile [M1] + V->>M: Request the IBAN for the forwarder M-->>V: iban.updated, IBAN issued + V->>M: Read the IBAN back from the API [V7] V->>SP: Client active, IBAN in the API, later the dashboard [S4, V3] SP->>CL: Hand over IBAN, client starts sending EUR ``` Notes on the flow: -- **The destination goes to Vortex, not Monerium.** Monerium's profile API has no field - that could carry it, linking an address needs a signature from its owner, which - exchange deposit addresses cannot give, and Monerium does not need it: only the - forwarder contract uses the destination. -- **A new partner API call carries the destination (proposed).** SulPayments can already - create client profiles through the Vortex API with its own client ID. One new call - attaches the destination to such a client **[V6]**. The destination is create-only, - because it is fixed in the contract; a change means a new account on SulPayments' - written instruction. Vortex validates the address, rejects zero, token and contract - addresses, and requires SulPayments to confirm that an exchange address does not - rotate. The call carries no KYB data. Activation stays a Vortex operator step in the - pilot, since the destination decides where a client's USDC goes. -- **Pilot without the new call.** For the first 3 to 5 clients, SulPayments can put the - destination in the signed onboarding form, and Vortex operations enters it through the - existing admin endpoint. The new call is worth building once the size of the - existing-client batch is known **[S1]**. -- **Matching by profile ID.** Monerium's create-profile call accepts a profile ID chosen - by the creator. If Monerium creates the profile with the ID Vortex issued, the approval - notification matches the registered client without a manual step. Otherwise - SulPayments sends Vortex the Monerium profile ID once it exists **[M14]**. -- **KYB goes from SulPayments to Monerium.** Monerium's spec reserves the endpoint for - submitting company details to partners under its KYC reliance model, and KYC sharing - through Sumsub covers personal profiles only. SulPayments performs the KYB and gives - Monerium a reliance attestation per client. Vortex submits no KYB data, which matches - the security spec **[M1]**. -- **IBAN before the forwarder exists.** Monerium issues one IBAN per profile. If - Monerium creates it before the forwarder is linked, it points at another address, and - Vortex must move it to the forwarder before anyone pays into it **[M3]**. -- **Approval notification.** Per Monerium's spec, `profile.updated` fires on every - profile state change, including approval, and is subscribed by default **[M6]**. +- **SulPayments' app, shared credentials.** Monerium's reliance agreement is with + SulPayments, so the client profiles live in a white-label app in SulPayments' Monerium + account. Only the app that onboarded a profile can read it or act on it, so Vortex + uses the same app's client ID and secret. Monerium's guides describe one credential + pair per app **[M2]**. +- **Division of work.** SulPayments creates profiles and submits KYB. Vortex links + addresses, requests the IBAN, places refunds, and registers its own webhook + subscription. SulPayments must never link addresses, request or move IBANs, place + orders, or change Vortex's webhook subscription. Closing a profile also closes its + IBAN, so SulPayments coordinates closures with Vortex **[S2]**. +- **KYB.** Companies must use Monerium's reliance route: SulPayments submits company + details, form and verifications directly. Approval takes seconds when the data follows + Monerium's corporate KYB guide. Vortex never handles KYB data, which matches the + security spec. Reading a profile returns only the company name, so KYB details stay + with SulPayments. +- **Destination handover by profile ID (proposed).** Creating a profile returns its ID. + SulPayments then calls a new Vortex endpoint with that ID, the destination, its own + client reference and a contact email **[V6]**. Vortex checks the profile exists in + SulPayments' app, stores the destination, and deploys the forwarder once the profile + is approved. The destination is create-only, because it is fixed in the contract; a + change means a new account on SulPayments' written instruction. Vortex rejects zero, + token and contract addresses, and exchange addresses need SulPayments' confirmation + that they do not rotate. Until the endpoint exists, the destination can come on the + signed onboarding form. +- **Why the destination does not go through Monerium.** Monerium's profile API has no + field for it, linking an address needs a signature from its owner, which exchange + deposit addresses cannot give, and only the forwarder contract uses the destination. +- **IBAN order.** Monerium issues an IBAN only for an address already linked to the + profile, so the forwarder is linked first. A profile has one IBAN, which can be moved + to another linked address. +- **Webhooks.** Vortex registers its own subscription on SulPayments' app; Monerium + lists an app's subscriptions together. Monerium's guide says that list includes each + subscription's secret, while the response schema has no secret field. Either way, + Vortex reads the IBAN and the payer's IBAN back from Monerium's API instead of relying + on webhook payloads alone **[V7]**. +- **Fee.** Monerium charges €10 per corporate account under its agreement **[M3, S10]**. ### 4.3 What is built today @@ -181,11 +190,13 @@ Notes on the flow: - The operator then activates the account. - SulPayments can read the account and its IBAN through the Vortex API. There is no dashboard view yet **[V3]**. -- Adopting the proposal changes four things. SulPayments supplies the destination - through the new API call **[V6]**. Onboarding starts from Monerium's approval - notification, matched by profile ID **[V1]**. The keeper links a refund address next - to the forwarder **[V2]**. If Monerium creates the IBAN first, the keeper moves it - instead of requesting a new one **[V1]**. +- The backend uses one Monerium credential pair, shared with Vortex's retail EUR onramp. +- Adopting the proposal changes five things. The B2B module gets its own credentials for + SulPayments' app **[V8]**. SulPayments supplies the destination by profile ID through + the new endpoint **[V6]**. Onboarding starts once the profile is approved and the + destination is registered **[V1]**. The keeper links a refund address next to the + forwarder **[V2]**. The IBAN and the payer's IBAN are read back from Monerium's API + **[V7]**. ### 4.4 Decisions behind onboarding @@ -211,7 +222,7 @@ sequenceDiagram B->>M: SEPA transfer to the client's IBAN M-->>V: order.created webhook, EUR arrived - Note over M: Compliance hold possible, or return before minting + Note over M: Compliance review possible, or rejection before minting M->>F: Mint EURe to the forwarder M-->>V: order.updated webhook, order processed V->>F: Watcher confirms the mint on chain @@ -220,18 +231,26 @@ sequenceDiagram ``` - Vortex listens on **two channels**. Monerium's webhooks carry the order details: - amount, order ID, compliance holds, and the payer's IBAN and name. Vortex's own chain + amount, order ID, and the payer's IBAN and name. Vortex's own chain watcher proves the EURe actually arrived on the forwarder. A deposit becomes eligible for conversion only when both agree. -- Per Monerium's spec, `order.created` arrives when the order is placed and - `order.updated` once it is processed or rejected. There is no separate event when - minting starts **[M7]**. +- Monerium confirmed that `order.created` arrives when the payment hits the IBAN and + `order.updated` when the order changes state. While an order is **pending**, it is + either minting or under compliance review, and Monerium does not tell the two apart. + Reviews happen during business hours and add time before the two-hour window starts. - The payer's **IBAN and name are stored** from the order. They are the target of any refund. - The **two-hour window starts at the mint**, not at the SEPA transfer, because Vortex cannot act before the EURe exists. -- Monerium may **hold** a payment for compliance or **return** it before minting. These - are Monerium's decisions, and nothing reaches the forwarder. +- Monerium may **reject** a payment before minting, with a reason on the order. That is + Monerium's decision, and nothing reaches the forwarder **[M5]**. +- **Memo routing.** A payer can write a chain and address into the SEPA memo. If that + address is linked to the client's profile, Monerium mints there instead of to the + forwarder. Monerium's guide describes only that case, which implies an unlinked + address is ignored and the payment mints to the forwarder as usual. The only other + address linked to a client profile is the proposed refund address, which Vortex + controls. Clients are unlikely to use this, so it stays enabled, and a mint to a + refund address is handled by operations **[V9]**. ## 6. Conversion: EURe to USDC @@ -358,9 +377,12 @@ from the Vortex/SatoshiPay company profile. - With the proposal, the refund leaves from the **client's own IBAN**, in the client's name, with a reference to the original payment. - SEPA Instant is used when the payer's bank supports it, otherwise next business day. -- Monerium requires a **supporting document**, an invoice or an agreement, on - redemptions above €15,000. Refunds of €15,000 or more stay manual until Monerium - confirms which document it accepts for a refund **[M9]**. +- Monerium requires a **supporting document** on redemptions above €15,000 and accepts + the same agreement every time, so one standing document per client can be uploaded + once and reused. Until the refund automation attaches it, refunds of €15,000 or more + stay manual **[V10]**. +- Monerium sets **no limits and charges no fees** on refunds. A refund may be reviewed + by Monerium during business hours before it is sent **[M4]**. - Refunds run one at a time and survive a crash of the keeper midway. A refund that fails its retries goes to **recovery failed** and is handed to Vortex operations. - Rollout: the automation first runs in **alert mode**, where Vortex operators confirm @@ -386,11 +408,15 @@ What it changes: seed, links it at onboarding, and adds one transfer to the refund steps **[V2]**. - **No company profile needed for refunds.** The recovery and float wallets no longer need to be linked at Monerium, so the Vortex/SatoshiPay company profile stops being a - prerequisite for deploying the contracts **[M11]**. + prerequisite for deploying the contracts. - **Custody unchanged.** Vortex holds a payment only between the missed window and the payout. A refund address only ever holds the payment being refunded. -Status: proposed to Monerium on 2026-09-29, not built yet **[M8]**. +Status: Monerium confirmed on 2026-09-30 that an address belongs to exactly one +profile, so one refund address per client is needed, and that a redeem from it leaves +from the profile's IBAN. Monerium also asked whether Vortex controls the funds. It does, +on the refund path only. Vortex accepts that custody risk for the pilot, and Monerium's +compliance acceptance is still open **[M1]**. Not built yet **[V2]**. Alternatives considered: @@ -409,11 +435,8 @@ Alternatives considered: stateDiagram-v2 direction LR [*] --> pending: Monerium order - pending --> held: compliance hold - pending --> returned: Monerium returns it - held --> returned + pending --> returned: Monerium rejects it pending --> minted: EURe on the forwarder - held --> minted minted --> converting: first chunk swapped converting --> forwarded: one USDC transfer minted --> recovering: refund path @@ -427,6 +450,9 @@ stateDiagram-v2 ``` Statuses only move forward. A late or repeated webhook can never move a deposit back. +Monerium does not report a separate compliance-review state, so a payment under review +shows as pending. The `held` status in the API is therefore never set and will be +removed **[V11]**. ### 9.2 What SulPayments receives @@ -492,60 +518,83 @@ Statuses only move forward. A late or repeated webhook can never move a deposit | Subsidy ladder | none for 6 min, rising to 1% from 16 min | Keeper setting | | Chunk size | up to €10,000 | Operational, ceiling €50,000 | | Minimum swap | €1 | Operational, can be raised but never below €1 | -| Manual refunds | €15,000 and above | Until [M9] is answered | +| Manual refunds | €15,000 and above | Until the refund automation attaches the standing agreement [V10] | +| Monerium account fee | €10 per corporate account | Monerium agreement | | Dormancy pause | 60 days without a conversion | Keeper code | | Pilot volume | 3 to 5 clients, €50,000 per client per day | Contractual | ## 12. Open questions -### 12.1 Monerium +### 12.1 Assumptions (2026-09-30) -Status "Follow-up" means the question is in the endpoint-referenced follow-up to -Monerium of 2026-09-29. +- Vortex accepts the custody risk on the refund path for the pilot, without a legal + opinion. Monerium's acceptance is still needed **[M1]**. +- SulPayments submits KYB directly in its own white-label app. Vortex never handles KYB + data. +- No SulPayments client has an existing Monerium profile. +- Monerium does not need to know or screen the client's final wallet. +- Memo routing stays enabled, because clients are unlikely to use it. +- Testing runs in Vortex's own Monerium sandbox. + +### 12.2 Answered by Monerium (2026-09-30) + +| Topic | Answer | +|---|---| +| Whose white-label app | A dedicated app in SulPayments' Monerium account, under SulPayments' reliance agreement. Vortex uses that app's client ID and secret. | +| Onboarding steps | Confirmed: create profile, submit details, form and verifications, wait for `profile.updated` approved, link the forwarder and the refund address, request the IBAN. | +| KYB route and speed | Corporates use the reliance endpoints. Approval takes seconds when the data follows Monerium's guidelines. | +| Profile visibility | Only the credentials of the app that onboarded a profile can read it. | +| Readable profile data | Only the bare minimum, such as the name. Full KYB details are not returned. | +| Payment notifications | `order.created` when the payment hits the IBAN, `order.updated` on state changes. Pending covers both minting and compliance review. | +| IBAN | Only for an address already linked to the profile. It can be moved to another linked address. | +| Batching | None. Each client is its own request. | +| Refund address | One address per profile, so one refund address per client. A redeem from it leaves from the profile's IBAN. | +| Supporting document above €15,000 | The same agreement can be reused every time. | +| Refund limits and fees | None. Some refunds are reviewed during business hours. | +| Account fee | €10 per corporate account, per the agreement. | + +### 12.3 Monerium | ID | Question | Why it matters | Status | Answer | |---|---|---|---|---| -| M1 | How does the KYB data reach Monerium, and who creates the profile in our white-label app? | Defines onboarding, section 4.2 | Asked 2026-09-29 on Telegram, follow-up | Partly, from the spec: submitting company details is reserved for partners under Monerium's KYC reliance model, and KYC sharing through Sumsub supports personal profiles only. Expected: SulPayments sends the KYB under its reliance arrangement, Monerium creates the profile, Vortex submits no KYB data. | -| M2 | How are SulPayments' existing clients imported? How long does KYB take once SulPayments' data is in, and is that business hours? | Pilot timeline and client expectations | Follow-up | | -| M3 | For a profile Monerium creates, can Vortex link the forwarder with its contract signature and put the IBAN on it? If Monerium creates the IBAN first, may Vortex move it, and can Monerium promise not to move it back? | The IBAN must point at the forwarder before a client pays | Follow-up | Partly, from the spec: a contract can be linked to a given profile with an ERC-1271 signature, an IBAN can be requested for an address, and an existing IBAN can be moved. Open: who creates the IBAN, and the commitment not to move it. | -| M4 | Can our white-label app list all profiles Monerium creates for SulPayments' clients? | Automated onboarding and reconciliation | Follow-up | Yes for profiles in our app: the profile list returns every profile our app has access to, filterable by state and kind. Profiles created through another app are not visible, as seen in the sandbox on 2026-09-14. Open: confirm SulPayments' clients are created in our app. | -| M5 | Which profile data can we read? | What the SulPayments dashboard view can show | Answered from the spec | Per profile: ID, name, kind, state, section states, and a rejection or closure reason. Per IBAN: IBAN, BIC, connected address and chain. Linked addresses per profile. Orders per profile, with the payer's IBAN and name. Submitted company details are not returned. | -| M6 | Does `profile.updated` fire when Monerium creates and approves a profile in our app? | Trigger for automated onboarding | Answered from the spec | It fires on every profile state change, including approval, and is subscribed by default. | -| M7 | Do `order.created` and `order.updated` arrive as described in section 5, with the payer's IBAN and name on the order? | The payer IBAN is the refund target | Answered from the spec, sandbox test pending | `order.created` when the order is placed, `order.updated` when it is processed or rejected, no separate event when minting starts. The order carries the payer's IBAN and name. To confirm with a sandbox SEPA simulation. | -| M8 | Is a refund from a Vortex refund address linked to each client profile feasible and acceptable, section 8.3? Does it always leave from the client's IBAN in the client's name? | Refund design | Follow-up | Partly, from the spec: any linked address can use the profile's IBAN for outgoing payments, and an address has a single owning profile. Open: Monerium's acceptance. | -| M9 | Which supporting document does Monerium accept for a refund of more than €15,000 to the original payer? Could one standing agreement per client be reused? | Whether large refunds can run automatically | Follow-up | Partly, from the spec: redemptions above €15,000 need a supporting document, an invoice or an agreement, uploaded once and referenced by ID. | -| M10 | Which outgoing limits, fees and cut-off times apply to the refund redemptions? | Refund timing promise | Follow-up | | -| M11 | Onboarding of a Vortex/SatoshiPay company profile with a recovery address and a float address. | Needed for refunds as built, and before deploy, since the recovery address is fixed in the contracts | Depends on M8 | Not needed if the refund addresses of section 8.3 are accepted. | -| M12 | Production white-label API credentials for the B2B app. | Needed before go-live | Open | | -| M13 | Written confirmation of the items agreed verbally so far: the attestor-signed link, the redemption-limitation disclosure, the issuer recovery backstop, what authorizes an IBAN move or new address link, SEPA recall and fraud loss allocation, per-IBAN suspension, and advance notice of changes to the link message. | Launch gate | Open | | -| M14 | Can Monerium create each profile with a profile ID Vortex issues? The create-profile call accepts a partner-supplied ID. | Automatic matching of approved profiles to registered clients | Follow-up | | -| M15 | Are there fees per profile or per KYB review? | Commercial planning | Follow-up | | - -### 12.2 SulPayments +| M1 | Is Monerium's compliance fine with Vortex holding a refund address on each client profile and signing refunds from it, on the refund path only? | Refund design, section 8.3 | Open, raised by Monerium 2026-09-30 | | +| M2 | Can SulPayments' app have separate credentials for Vortex and SulPayments, or can linking addresses, requesting or moving IBANs, and managing webhooks be restricted to Vortex? If not, is there an audit log or notification for those actions? | Whoever holds the app credentials can redirect future mints | Open | | +| M3 | Is the €10 per corporate account billed to SulPayments or to Vortex, and is it one-off or recurring? | Commercial planning | Open | | +| M4 | What triggers a review on a refund, and can refunds to the original payer be cleared in advance? | Refund timing promise | Open | | +| M5 | When an incoming payment is rejected before minting, is the EUR returned to the payer automatically, and how fast? | The `returned` status and client communication | Open | | +| M6 | Monerium's corporate KYB guide lists "third-party payments" among prohibited business activities, and companies in restricted countries need their first payment in their own name. Does anything restrict incoming payments from third parties to a client's IBAN? | Who may pay in, and who a refund goes to | Open | | +| M7 | Written confirmation of the items agreed verbally so far: the redemption-limitation disclosure, the issuer recovery backstop, SEPA recall and fraud loss allocation, per-IBAN suspension, and advance notice of changes to the link message. | Launch gate | Open | | -| ID | Question | Why it matters | Status | Answer | +### 12.4 SulPayments + +| ID | Question or item to agree | Why it matters | Status | Answer | |---|---|---|---|---| -| S1 | Onboarding volume: an initial batch of existing clients, then new clients one by one? How many, and when? | Decides when the destination API call is worth building | Open | | -| S2 | Does SulPayments send client onboarding data, including KYB, directly to Monerium? Who are the contacts on each side? | Onboarding flow, section 4.2 | Open | Expected yes, see M1. | -| S3 | How does SulPayments give Vortex each client's destination wallet? Who at SulPayments approves it? | The destination is fixed in the contract, so it must be right the first time | Proposal to confirm | Proposed: a new Vortex API call per client, section 4.2. For the pilot, the signed onboarding form. | -| S4 | Plan: each client's IBAN is shown in the Vortex API and dashboard. Is that how SulPayments hands IBANs to clients? | Dashboard scope | Proposal to confirm | The IBAN is readable through the Vortex API today. A dashboard view is planned **[V3]**. | -| S5 | Confirm that the Vortex addresses on each client's Monerium profile are managed by Vortex, and that clients never link or change them. | Only the forwarder may receive the minted EURe | Answered by design | Yes. Vortex links and manages the forwarder and, as proposed, the refund address. Clients never touch them. | -| S6 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target and the refund wording in the terms | Open | | -| S7 | Are destinations self-custody wallets or exchange deposit addresses? | Exchange addresses need an attestation that they do not rotate and accept contract transfers | Open | | -| S8 | Are the three webhooks plus polling enough? Are reason codes needed on refunds? Webhook endpoint and support contacts. | Status reporting, section 9 | Open | | -| S9 | Is a two-hour window before a full refund right for your clients? Will clients authorize Vortex to send refunds from their IBAN, as proposed in section 8.3? | Refund promise in the agreement | Open | | -| S10 | The agreement names a "Coinbase EURC oracle". The implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint. Is that what was meant? | Pricing terms | Open | | - -### 12.3 Vortex internal +| S1 | How many clients, and when? | Planning, and when the destination endpoint is needed | Open | | +| S2 | Share the white-label app's production credentials with Vortex, and agree the usage rules in section 4.2: no address links, IBAN requests or moves, orders, or changes to Vortex's webhook subscription, and profile closures coordinated with Vortex. | Protects where client payments are minted | Open | | +| S3 | Hand over each destination through the new Vortex endpoint by Monerium profile ID. Who at SulPayments approves a destination? | The destination is fixed in the contract | Proposal to confirm | | +| S4 | Each client's IBAN is shown in the Vortex API, and later the dashboard. Is that how SulPayments hands IBANs to clients? | Dashboard scope | Proposal to confirm | | +| S5 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target, see M6 | Open | | +| S6 | Are destinations self-custody wallets or exchange deposit addresses? | Exchange addresses need an attestation that they do not rotate and accept contract transfers | Open | | +| S7 | Are the three webhooks plus polling enough? Are reason codes needed on refunds? Webhook endpoint and support contacts. | Status reporting, section 9 | Open | | +| S8 | Is a two-hour window before a full refund right? Will clients authorize Vortex to refund from their IBAN? A Monerium review can delay a refund within business hours. | Refund terms in the agreement | Open | | +| S9 | The agreement names a "Coinbase EURC oracle". The implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint. Is that what was meant? | Pricing terms | Open | | +| S10 | Who bears Monerium's €10 per corporate account? | Commercial | Open | | + +### 12.5 Vortex internal | ID | Question or task | Depends on | Status | |---|---|---|---| -| V1 | Adapt onboarding: start from Monerium's approval notification matched by profile ID, and move an existing IBAN to the forwarder only if Monerium creates it first. | M1, M3, M14 | Open | -| V2 | Refunds through per-client refund addresses: one refund key per client derived from one seed, linked at onboarding, one extra transfer per refund. No contract change. | M8 | Proposed, build after Monerium confirms | -| V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Today this is API only. | S4, M5 | Open | -| V4 | Reason codes on refunds, and a webhook for payments Monerium returns before minting. | S8 | Open | +| V1 | Start onboarding once the profile is approved and the destination is registered, whichever comes last. | V6, V8 | Open | +| V2 | Refunds through per-client refund addresses: one refund key per client derived from one seed, linked at onboarding, one extra transfer per refund. No contract change. | M1 | Proposed, build after Monerium confirms | +| V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Today this is API only. | S4 | Open | +| V4 | Reason codes on refunds, and a webhook for payments Monerium rejects before minting, using Monerium's rejection reason. | S7 | Open | | V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium. | Meeting | Open | -| V6 | Partner API call to register a client's destination: create-only, validated, no KYB data, operator approval in the pilot. The pilot can run without it through the signed onboarding form. | S1, S3 | Proposed | +| V6 | Endpoint for SulPayments to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Proposed | +| V7 | Read the IBAN and the payer's IBAN back from Monerium's API instead of trusting webhook payloads, and check in the sandbox whether listing subscriptions exposes their secrets. | None | Open | +| V8 | Separate Monerium credentials for the B2B module, apart from the retail onramp, and later one set per partner. | S2 | Open | +| V9 | Detect a mint to a refund address routed by payment memo, and handle it as a refund. | V2 | Open | +| V10 | Attach the standing agreement to refunds above €15,000 so they can run automatically. | V2 | Open | +| V11 | Remove the unused `held` status. | None | Open | ## 13. Related documents From c3cc88f68115c429126e080525465de65201c0df Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 11:13:49 +0200 Subject: [PATCH 50/74] docs(repo): add SulPayments' lifecycle and API-first requirements to the flow SulPayments wants every deposit stage with IDs, amounts, timestamps and hold or failure status, and fetches IBANs from the Vortex API. The overview now maps those requirements against what is built and proposes the event and API changes that close the gaps. --- docs/product-monerium-b2b-flow.md | 97 +++++++++++++++++++++++++------ 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index 52c1ee032..a9d6b428f 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -138,7 +138,7 @@ sequenceDiagram V->>M: Request the IBAN for the forwarder M-->>V: iban.updated, IBAN issued V->>M: Read the IBAN back from the API [V7] - V->>SP: Client active, IBAN in the API, later the dashboard [S4, V3] + V->>SP: Client active, IBAN readable through the API [S4, V4, V12] SP->>CL: Hand over IBAN, client starts sending EUR ``` @@ -188,8 +188,8 @@ Notes on the flow: - The keeper then links the forwarder and requests the IBAN automatically. The IBAN is recorded when Monerium confirms it. - The operator then activates the account. -- SulPayments can read the account and its IBAN through the Vortex API. There is no - dashboard view yet **[V3]**. +- SulPayments can read the account and its IBAN through the Vortex API with its manager + key. There is no dashboard view **[V3]**. - The backend uses one Monerium credential pair, shared with Vortex's retail EUR onramp. - Adopting the proposal changes five things. The B2B module gets its own credentials for SulPayments' app **[V8]**. SulPayments supplies the destination by profile ID through @@ -454,7 +454,7 @@ Monerium does not report a separate compliance-review state, so a payment under shows as pending. The `held` status in the API is therefore never set and will be removed **[V11]**. -### 9.2 What SulPayments receives +### 9.2 What SulPayments receives today | Event | When | Key content | |---|---|---| @@ -462,18 +462,80 @@ removed **[V11]**. | `DEPOSIT_CONVERTED` | The USDC transfer is 32 blocks deep | Per chunk: reference rate, fee, subsidy. The forward transaction hash | | `DEPOSIT_RETURNED` | The refund was processed by Monerium | Refunded amount, masked payer IBAN, Monerium redemption ID, recovery transaction | +| API call | Returns | +|---|---| +| Account, per client | IBAN, account status, destination, forwarder address, fee policy | +| Deposits, per client | Every deposit with status, amount and mint transaction, each conversion chunk with its pricing and transaction, the forward transaction, and the refund once started | + +- SulPayments calls both with its manager API key plus a header naming the client's + Vortex profile ID, or with a key issued to the client itself. - Webhooks are signed. SulPayments verifies each one against Vortex's published public key and deduplicates on the event ID. -- **Fallback:** the deposits endpoint of the Vortex API returns the current status of - every deposit, for polling if a webhook is missed. -- **Reference IDs** in every event: the deposit ID, the account ID and the client's - profile ID, plus the mint, forward or recovery transaction hash and, on a refund, - Monerium's redemption ID. Vortex also keeps Monerium's order ID and SulPayments' own - client ID per deposit for support queries, and the refund memo carries the deposit ID. -- **Gaps:** there are no reason codes on a refund yet, and a payment Monerium returns - before minting triggers no webhook, only a status visible by polling **[V4]**. +- **Fallback:** the deposits call returns the current status of every deposit, for + polling if a webhook is missed. +- **Reference IDs** in every event today: the deposit ID, the account ID, the client's + Vortex profile ID, the relevant transaction hash and, on a refund, Monerium's + redemption ID. Vortex stores Monerium's order ID and SulPayments' client reference per + deposit but does not send them yet **[V4]**. +- Amounts are in base units: 18 decimals for EUR and EURe, 6 for USDC. + +### 9.3 What SulPayments asked for + +SulPayments' requirements of 2026-09-30: -### 9.3 Exceptions and escalation +- The API and webhooks expose the **full lifecycle**, from deposit through conversion to + delivery, including IDs, amounts, timestamps, and hold or failure status. +- **API-first:** SulPayments' frontend fetches each sub-account's IBAN from the Vortex + backend. + +Gaps against what is built: + +| Stage | Webhook today | API today | Gap | +|---|---|---|---| +| Payment arrived at Monerium, not minted yet | None | Status pending | No event. Monerium cannot say whether it is minting or under review | +| Rejected by Monerium before minting | None | Status returned | No event, no reason | +| EURe minted to the forwarder | `DEPOSIT_RECEIVED` | Status minted | No mint timestamp, Monerium order ID, payer or payment reference | +| Conversion chunk executed | None | Chunk with pricing and transaction | No event, no timestamp per chunk | +| Conversion waiting on the market | None | None | No waiting status or reason | +| Delivered as one USDC transfer | `DEPOSIT_CONVERTED` | Status forwarded | No delivery timestamp | +| Refund started | None | Status recovering | No event, no reason | +| Refunded | `DEPOSIT_RETURNED` | Status refunded | No reason, no timestamp | +| Refund needs an operator | None | Status recovery failed | No event | +| Account active with its IBAN | None | Account call | No event. Lookup only by Vortex profile ID, no list of all sub-accounts | + +**Proposed** to close the gaps **[V4, V12]**: + +- **One snapshot event.** Every deposit status change and every confirmed chunk sends a + `DEPOSIT_UPDATED` event carrying the full deposit, in the same shape as the deposits + call. SulPayments upserts one object and cannot miss a stage. The three milestone + events can stay or be dropped, since nothing is live yet. +- **IDs:** deposit, account, Vortex profile, Monerium profile, Monerium order, + SulPayments' client reference, each conversion, and every transaction hash: mint, + swap, forward, recovery, and the refund's redemption order. +- **Amounts:** the EUR amount as a decimal and in base units; per chunk the EURe in, USDC + gross, fee, subsidy, net and reference rate; the USDC delivered; the refund amount. +- **Timestamps:** received at Monerium, minted, each chunk executed, delivered, refund + started, refunded. +- **Hold status.** A waiting block with a start time and a reason while a payment waits: + pending at Monerium, market below the floor beyond the subsidy, spread too wide, + reference out of band, or Coinbase unavailable. Monerium does not tell review apart + from minting, so "pending at Monerium" is the only hold Vortex can report before the + mint. +- **Failure status.** A reason code on every refund: window missed, below minimum, + compliance or incident. Monerium's own reason when it rejects a payment. An event when + a refund needs an operator. +- **Account event.** `ACCOUNT_UPDATED` when the IBAN is issued and when the account + becomes active, suspended or closed. +- **IBAN through the API.** The account call stays the source of the IBAN. The + destination endpoint returns the Vortex profile ID, or the account call accepts the + Monerium profile ID, and a list call returns all sub-accounts with IBAN and status. + SulPayments could also read the IBAN from Monerium with its app credentials, but only + Vortex knows when the account is ready. +- **Docs fix.** Some partner-facing field descriptions still describe the older design: + the subsidy now goes to the forwarder, not the destination, and each conversion + belongs to exactly one deposit. + +### 9.4 Exceptions and escalation - Vortex monitors stuck payments, refunds due, failed refunds, the subsidy budget, the float balance, IBAN changes at Monerium, and the Coinbase market status. @@ -572,10 +634,10 @@ removed **[V11]**. | S1 | How many clients, and when? | Planning, and when the destination endpoint is needed | Open | | | S2 | Share the white-label app's production credentials with Vortex, and agree the usage rules in section 4.2: no address links, IBAN requests or moves, orders, or changes to Vortex's webhook subscription, and profile closures coordinated with Vortex. | Protects where client payments are minted | Open | | | S3 | Hand over each destination through the new Vortex endpoint by Monerium profile ID. Who at SulPayments approves a destination? | The destination is fixed in the contract | Proposal to confirm | | -| S4 | Each client's IBAN is shown in the Vortex API, and later the dashboard. Is that how SulPayments hands IBANs to clients? | Dashboard scope | Proposal to confirm | | +| S4 | How does SulPayments get each client's IBAN? | API and dashboard scope | Answered 2026-09-30 | API-first: SulPayments' frontend fetches the IBAN from the Vortex API, section 9.3. | | S5 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target, see M6 | Open | | | S6 | Are destinations self-custody wallets or exchange deposit addresses? | Exchange addresses need an attestation that they do not rotate and accept contract transfers | Open | | -| S7 | Are the three webhooks plus polling enough? Are reason codes needed on refunds? Webhook endpoint and support contacts. | Status reporting, section 9 | Open | | +| S7 | Does the proposed event model in section 9.3 cover the lifecycle requirement? Webhook endpoint and support contacts. | Status reporting | Requirement received 2026-09-30, proposal to confirm | SulPayments wants the full lifecycle with IDs, amounts, timestamps, and hold and failure status. | | S8 | Is a two-hour window before a full refund right? Will clients authorize Vortex to refund from their IBAN? A Monerium review can delay a refund within business hours. | Refund terms in the agreement | Open | | | S9 | The agreement names a "Coinbase EURC oracle". The implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint. Is that what was meant? | Pricing terms | Open | | | S10 | Who bears Monerium's €10 per corporate account? | Commercial | Open | | @@ -586,8 +648,8 @@ removed **[V11]**. |---|---|---|---| | V1 | Start onboarding once the profile is approved and the destination is registered, whichever comes last. | V6, V8 | Open | | V2 | Refunds through per-client refund addresses: one refund key per client derived from one seed, linked at onboarding, one extra transfer per refund. No contract change. | M1 | Proposed, build after Monerium confirms | -| V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Today this is API only. | S4 | Open | -| V4 | Reason codes on refunds, and a webhook for payments Monerium rejects before minting, using Monerium's rejection reason. | S7 | Open | +| V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Optional, since SulPayments integrates API-first. | S4 | Deprioritized | +| V4 | Full lifecycle deposit events, section 9.3: snapshot event on every change, IDs, amounts, timestamps, hold and failure reasons, an account event, and the docs fix. | S7 | Proposed | | V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium. | Meeting | Open | | V6 | Endpoint for SulPayments to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Proposed | | V7 | Read the IBAN and the payer's IBAN back from Monerium's API instead of trusting webhook payloads, and check in the sandbox whether listing subscriptions exposes their secrets. | None | Open | @@ -595,6 +657,7 @@ removed **[V11]**. | V9 | Detect a mint to a refund address routed by payment memo, and handle it as a refund. | V2 | Open | | V10 | Attach the standing agreement to refunds above €15,000 so they can run automatically. | V2 | Open | | V11 | Remove the unused `held` status. | None | Open | +| V12 | Partner account API: return the Vortex profile ID from the destination endpoint or accept the Monerium profile ID, and list all sub-accounts with IBAN and status. | S4, V6 | Proposed | ## 13. Related documents From 988eb1c9a06f951dfd448f68d5718932f6563d4e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 09:09:03 +0200 Subject: [PATCH 51/74] docs(repo): record the Monerium call outcomes in the B2B flow overview Captures the per-partner app setup, how Monerium handles held and rejected payments, third-party payers, the refund approach agreed for the pilot, and SulPayments' onboarding status. --- docs/product-monerium-b2b-flow.md | 72 ++++++++++++++++++------------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index a9d6b428f..efffb8201 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -1,11 +1,11 @@ # Monerium B2B Onramp: End-to-End Flow -> **Status:** living overview, draft for alignment. Last updated 2026-09-30, including -> Monerium's written answers of that day. +> **Status:** living overview, draft for alignment. Last updated 2026-10-01, including +> Monerium's written answers and the call of 2026-09-30. > **Audience:** Vortex/SatoshiPay internally, SulPayments, and Monerium. > **Scope:** the EUR to USDC onramp for SulPayments' business clients, as built for the > pilot on the branch of PR #1375. It is not merged or deployed yet. Open questions carry -> an ID such as **[M1]** (Monerium), **[S1]** (SulPayments) or **[V1]** (Vortex internal) +> an ID such as **[M2]** (Monerium), **[S1]** (SulPayments) or **[V1]** (Vortex internal) > and are collected in [section 12](#12-open-questions), with space for the answers. > Changes proposed but not built yet are marked **Proposed**. Facts from Monerium's > public API spec (version 2.0.0) and guides are marked as such where Monerium has not @@ -134,7 +134,7 @@ sequenceDiagram V->>M: Check the profile exists in SulPayments' app M-->>V: profile.updated, state approved V->>C: Deploy forwarder with destination and fee policy - V->>M: Link forwarder and refund address to the profile [M1] + V->>M: Link forwarder and refund address to the profile V->>M: Request the IBAN for the forwarder M-->>V: iban.updated, IBAN issued V->>M: Read the IBAN back from the API [V7] @@ -148,14 +148,16 @@ Notes on the flow: SulPayments, so the client profiles live in a white-label app in SulPayments' Monerium account. Only the app that onboarded a profile can read it or act on it, so Vortex uses the same app's client ID and secret. Monerium's guides describe one credential - pair per app **[M2]**. + pair per app **[M2]**. Each partner gets its own white-label app, so a new partner + means a new app and a new credential pair for Vortex **[V8]**. - **Division of work.** SulPayments creates profiles and submits KYB. Vortex links addresses, requests the IBAN, places refunds, and registers its own webhook subscription. SulPayments must never link addresses, request or move IBANs, place orders, or change Vortex's webhook subscription. Closing a profile also closes its IBAN, so SulPayments coordinates closures with Vortex **[S2]**. -- **KYB.** Companies must use Monerium's reliance route: SulPayments submits company - details, form and verifications directly. Approval takes seconds when the data follows +- **KYB.** Companies must use Monerium's reliance route: SulPayments delivers company + details, form, verifications and files to Monerium directly. Vortex could later proxy + those calls as SulPayments' tech provider. Approval takes seconds when the data follows Monerium's corporate KYB guide. Vortex never handles KYB data, which matches the security spec. Reading a profile returns only the company name, so KYB details stay with SulPayments. @@ -242,8 +244,12 @@ sequenceDiagram refund. - The **two-hour window starts at the mint**, not at the SEPA transfer, because Vortex cannot act before the EURe exists. -- Monerium may **reject** a payment before minting, with a reason on the order. That is - Monerium's decision, and nothing reaches the forwarder **[M5]**. +- Monerium's monitoring may **hold** a payment for review during office hours. If it + needs documents, Monerium contacts the payer directly. If it cannot mint the payment, + it returns the funds to the payer, with a reason on the order. None of this reaches + the forwarder. +- **Third-party payers** are allowed. Monerium watches transaction patterns to make + sure accounts are not misused. - **Memo routing.** A payer can write a chain and address into the SEPA memo. If that address is linked to the client's profile, Monerium mints there instead of to the forwarder. Monerium's guide describes only that case, which implies an unlinked @@ -409,14 +415,13 @@ What it changes: - **No company profile needed for refunds.** The recovery and float wallets no longer need to be linked at Monerium, so the Vortex/SatoshiPay company profile stops being a prerequisite for deploying the contracts. -- **Custody unchanged.** Vortex holds a payment only between the missed window and the - payout. A refund address only ever holds the payment being refunded. +- **Short holding time.** A refund address only ever holds the payment being refunded, + between the missed window and the payout. -Status: Monerium confirmed on 2026-09-30 that an address belongs to exactly one -profile, so one refund address per client is needed, and that a redeem from it leaves -from the profile's IBAN. Monerium also asked whether Vortex controls the funds. It does, -on the refund path only. Vortex accepts that custody risk for the pilot, and Monerium's -compliance acceptance is still open **[M1]**. Not built yet **[V2]**. +Status: agreed with Monerium on 2026-09-30 for the pilot. An address belongs to exactly +one profile, so one refund address per client is needed, and a redeem from it leaves +from the profile's IBAN. Not built yet **[V2]**. Later, each client may instead name a +fixed refund IBAN at onboarding, so every refund follows the same path **[V13]**. Alternatives considered: @@ -541,7 +546,8 @@ Gaps against what is built: float balance, IBAN changes at Monerium, and the Coinbase market status. - Operators can pause conversion, force a refund, or correct a deposit's status through admin endpoints. The runbook covers each case. -- Named owners and the escalation path between Vortex, SulPayments and Monerium are +- A joint Slack channel with Monerium is the agreed channel for payment questions. + Named owners and the escalation path between Vortex, SulPayments and Monerium are still to be agreed **[V5]**. ## 10. What Vortex can and cannot do @@ -589,10 +595,10 @@ Gaps against what is built: ### 12.1 Assumptions (2026-09-30) -- Vortex accepts the custody risk on the refund path for the pilot, without a legal - opinion. Monerium's acceptance is still needed **[M1]**. -- SulPayments submits KYB directly in its own white-label app. Vortex never handles KYB - data. +- Refunds start with per-client refund addresses. A fixed refund IBAN per client may + replace them later **[V13]**. +- SulPayments delivers KYB directly to its own white-label app. Vortex never handles + KYB data. - No SulPayments client has an existing Monerium profile. - Monerium does not need to know or screen the client's final wallet. - Memo routing stays enabled, because clients are unlikely to use it. @@ -614,17 +620,20 @@ Gaps against what is built: | Supporting document above €15,000 | The same agreement can be reused every time. | | Refund limits and fees | None. Some refunds are reviewed during business hours. | | Account fee | €10 per corporate account, per the agreement. | +| Partner apps | Each partner gets its own white-label app. SulPayments delivers KYB data and files directly; Vortex could proxy those calls later as tech provider. | +| Held and rejected payments | Monerium's monitoring holds payments for review during office hours, contacts the payer directly if it needs documents, and returns the funds if it cannot mint them. | +| Third-party payers | Allowed. Monerium watches transaction patterns so accounts are not misused. | +| Refund address approach | Agreed for the pilot. A fixed refund IBAN per client may replace it later. | +| Communication | A joint Slack channel with Monerium is to be set up. | +| SulPayments' onboarding at Monerium | Documents in review on 2026-09-30, onboarding starting 2026-10-01. | ### 12.3 Monerium | ID | Question | Why it matters | Status | Answer | |---|---|---|---|---| -| M1 | Is Monerium's compliance fine with Vortex holding a refund address on each client profile and signing refunds from it, on the refund path only? | Refund design, section 8.3 | Open, raised by Monerium 2026-09-30 | | -| M2 | Can SulPayments' app have separate credentials for Vortex and SulPayments, or can linking addresses, requesting or moving IBANs, and managing webhooks be restricted to Vortex? If not, is there an audit log or notification for those actions? | Whoever holds the app credentials can redirect future mints | Open | | -| M3 | Is the €10 per corporate account billed to SulPayments or to Vortex, and is it one-off or recurring? | Commercial planning | Open | | +| M2 | Can SulPayments' app have separate credentials for Vortex and SulPayments, or can linking addresses, requesting or moving IBANs, and managing webhooks be restricted to Vortex? If not, is there an audit log or notification for those actions? | Whoever holds the app credentials can redirect future mints | Partly answered 2026-09-30 | One app per partner. Separate credentials within one app not confirmed. | +| M3 | Is the €10 per corporate account billed to SulPayments or to Vortex, and is it one-off or recurring? | Commercial planning | Renegotiation ongoing | A discount for onboarding all SulPayments clients is being negotiated. | | M4 | What triggers a review on a refund, and can refunds to the original payer be cleared in advance? | Refund timing promise | Open | | -| M5 | When an incoming payment is rejected before minting, is the EUR returned to the payer automatically, and how fast? | The `returned` status and client communication | Open | | -| M6 | Monerium's corporate KYB guide lists "third-party payments" among prohibited business activities, and companies in restricted countries need their first payment in their own name. Does anything restrict incoming payments from third parties to a client's IBAN? | Who may pay in, and who a refund goes to | Open | | | M7 | Written confirmation of the items agreed verbally so far: the redemption-limitation disclosure, the issuer recovery backstop, SEPA recall and fraud loss allocation, per-IBAN suspension, and advance notice of changes to the link message. | Launch gate | Open | | ### 12.4 SulPayments @@ -632,10 +641,10 @@ Gaps against what is built: | ID | Question or item to agree | Why it matters | Status | Answer | |---|---|---|---|---| | S1 | How many clients, and when? | Planning, and when the destination endpoint is needed | Open | | -| S2 | Share the white-label app's production credentials with Vortex, and agree the usage rules in section 4.2: no address links, IBAN requests or moves, orders, or changes to Vortex's webhook subscription, and profile closures coordinated with Vortex. | Protects where client payments are minted | Open | | +| S2 | Share the white-label app's production credentials with Vortex, and agree the usage rules in section 4.2: no address links, IBAN requests or moves, orders, or changes to Vortex's webhook subscription, and profile closures coordinated with Vortex. | Protects where client payments are minted | Waiting on SulPayments' Monerium onboarding, starting 2026-10-01 | | | S3 | Hand over each destination through the new Vortex endpoint by Monerium profile ID. Who at SulPayments approves a destination? | The destination is fixed in the contract | Proposal to confirm | | | S4 | How does SulPayments get each client's IBAN? | API and dashboard scope | Answered 2026-09-30 | API-first: SulPayments' frontend fetches the IBAN from the Vortex API, section 9.3. | -| S5 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target, see M6 | Open | | +| S5 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target. Monerium allows third-party payers | Open | | | S6 | Are destinations self-custody wallets or exchange deposit addresses? | Exchange addresses need an attestation that they do not rotate and accept contract transfers | Open | | | S7 | Does the proposed event model in section 9.3 cover the lifecycle requirement? Webhook endpoint and support contacts. | Status reporting | Requirement received 2026-09-30, proposal to confirm | SulPayments wants the full lifecycle with IDs, amounts, timestamps, and hold and failure status. | | S8 | Is a two-hour window before a full refund right? Will clients authorize Vortex to refund from their IBAN? A Monerium review can delay a refund within business hours. | Refund terms in the agreement | Open | | @@ -647,16 +656,17 @@ Gaps against what is built: | ID | Question or task | Depends on | Status | |---|---|---|---| | V1 | Start onboarding once the profile is approved and the destination is registered, whichever comes last. | V6, V8 | Open | -| V2 | Refunds through per-client refund addresses: one refund key per client derived from one seed, linked at onboarding, one extra transfer per refund. No contract change. | M1 | Proposed, build after Monerium confirms | +| V2 | Refunds through per-client refund addresses: one refund key per client derived from one seed, linked at onboarding, one extra transfer per refund. No contract change. | M1 | Agreed for the pilot, not built | | V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Optional, since SulPayments integrates API-first. | S4 | Deprioritized | | V4 | Full lifecycle deposit events, section 9.3: snapshot event on every change, IDs, amounts, timestamps, hold and failure reasons, an account event, and the docs fix. | S7 | Proposed | -| V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium. | Meeting | Open | +| V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium, including the joint Slack channel with Monerium. | Meeting | Open | | V6 | Endpoint for SulPayments to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Proposed | | V7 | Read the IBAN and the payer's IBAN back from Monerium's API instead of trusting webhook payloads, and check in the sandbox whether listing subscriptions exposes their secrets. | None | Open | -| V8 | Separate Monerium credentials for the B2B module, apart from the retail onramp, and later one set per partner. | S2 | Open | +| V8 | Separate Monerium credentials for the B2B module, apart from the retail onramp, with one app and credential pair per partner. | S2 | Open | | V9 | Detect a mint to a refund address routed by payment memo, and handle it as a refund. | V2 | Open | | V10 | Attach the standing agreement to refunds above €15,000 so they can run automatically. | V2 | Open | | V11 | Remove the unused `held` status. | None | Open | +| V13 | Later: a fixed refund IBAN per client, given at onboarding, replacing the per-client refund addresses. | V2 | Later | | V12 | Partner account API: return the Vortex profile ID from the destination endpoint or accept the Monerium profile ID, and list all sub-accounts with IBAN and status. | S4, V6 | Proposed | ## 13. Related documents From 776d357c7a0a7a863329480737ceac19a30ec0e1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 09:25:08 +0200 Subject: [PATCH 52/74] feat(api): add lifecycle snapshot webhooks and a manager account list SulPayments needs every deposit stage with IDs, amounts, timestamps and hold or failure reasons, and fetches client IBANs from the API. DEPOSIT_UPDATED and ACCOUNT_UPDATED send the full deposit or account snapshot whenever it changes, the deposits and account calls return the same shapes, and GET /v1/monerium-b2b/accounts lists a manager's accounts, filterable by provider profile ID. --- .../admin/moneriumB2b.controller.test.ts | 18 +- .../admin/moneriumB2b.controller.ts | 14 +- .../controllers/monerium-b2b.controller.ts | 143 ++++----- .../src/api/routes/v1/monerium-b2b.route.ts | 15 +- .../monerium-b2b/conversion-executor.test.ts | 20 +- .../monerium-b2b/conversion-executor.ts | 34 +- .../monerium-b2b/deposit-processor.test.ts | 6 +- .../monerium-b2b/deposit-processor.ts | 15 +- .../monerium-b2b/manager-events.test.ts | 139 ++++++++ .../services/monerium-b2b/manager-events.ts | 239 +++++++++++++- .../src/api/services/monerium-b2b/recovery.ts | 2 +- .../085-add-monerium-lifecycle-events.ts | 33 ++ apps/api/src/models/moneriumAccount.model.ts | 9 + .../src/models/moneriumFiatDeposit.model.ts | 60 ++++ ...erium-b2b-account-read.integration.test.ts | 47 ++- docs/api/apidog/page-manifest.json | 1 + docs/api/openapi/vortex.openapi.d.ts | 160 +++++++++- docs/api/openapi/vortex.openapi.json | 239 +++++++++++++- docs/api/pages/07-webhooks.md | 86 ++++- docs/api/pages/14-managed-profiles.md | 2 +- docs/api/wire-contract.snapshot.md | 297 +++++++++++++++++- .../05-integrations/monerium-b2b.md | 4 +- .../shared/src/endpoints/webhook.endpoints.ts | 138 +++++++- 23 files changed, 1566 insertions(+), 155 deletions(-) create mode 100644 apps/api/src/database/migrations/085-add-monerium-lifecycle-events.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 0726f88cb..d4742d959 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -337,10 +337,24 @@ describe("monerium b2b account mapping admin route", () => { expect(await blocked.json()).toMatchObject({ error: { message: expect.stringContaining("pending execution") } }); await pending.update({ status: MoneriumConversionExecutionStatus.Failed }); - const marked = await recover(deposit.id); + const badReason = await fetch(`${baseUrl}/deposits/${deposit.id}/recover`, { + body: JSON.stringify({ reason: "because" }), + headers: ADMIN_HEADERS, + method: "POST" + }); + expect(badReason.status).toBe(400); + + const marked = await fetch(`${baseUrl}/deposits/${deposit.id}/recover`, { + body: JSON.stringify({ reason: "compliance" }), + headers: ADMIN_HEADERS, + method: "POST" + }); 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); + const markedRow = await MoneriumFiatDeposit.findByPk(deposit.id); + expect(markedRow?.status).toBe(MoneriumFiatDepositStatus.Recovering); + expect(markedRow?.refundReason).toBe("compliance"); + expect(markedRow?.refundStartedAt).not.toBeNull(); // Forward-only: a recovering deposit cannot be marked again, but closes or retries. expect((await recover(deposit.id)).status).toBe(409); diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts index 1ad5712ec..4536185e5 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -168,7 +168,19 @@ export async function postMoneriumB2bDepositRecovery(req: Request<{ depositId: s }); return; } - const refusal = await markDepositForRecovery(req.params.depositId); + // Partner-visible reason (DEPOSIT_UPDATED refund.reason); a missed window is marked by the deadline job. + const reason = req.body?.reason ?? "operator"; + if (!["compliance", "incident", "operator"].includes(reason)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MONERIUM_B2B_INVALID_INPUT", + message: "reason must be compliance, incident or operator", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + const refusal = await markDepositForRecovery(req.params.depositId, reason); 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 } diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index dc4c008c6..196f2f5d5 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -3,17 +3,14 @@ import httpStatus from "http-status"; import { Op } from "sequelize"; import logger from "../../config/logger"; import { config } from "../../config/vars"; +import ManagedProfile from "../../models/managedProfile.model"; +import ManagedProfileManager from "../../models/managedProfileManager.model"; import MoneriumAccount from "../../models/moneriumAccount.model"; -import MoneriumConversionExecution, { - MoneriumConversionExecutionKind, - 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 { getAuthenticatedProfileId, getEffectiveUserId } from "../middlewares/effectiveUser"; import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; -import { executionPricing } from "../services/monerium-b2b/manager-events"; +import { accountSnapshot, depositSnapshots, findRelationship } from "../services/monerium-b2b/manager-events"; import { UNATTRIBUTED_ORDER_PREFIX } from "../services/monerium-b2b/mint-watcher"; import { MONERIUM_ID_HEADER, @@ -95,25 +92,14 @@ export const getMoneriumB2bAccount = async (req: Request, res: Response, next: N accountNotFound(res); return; } - res.status(httpStatus.OK).json({ - account: { - accountId: account.id, - createdAt: account.createdAt, - destination: account.destination, - dormantSince: account.dormantSince, - floorPpm: account.floorPpm, - forwarderAddress: account.forwarderAddress, - iban: account.iban, - status: account.status, - targetPpm: account.targetPpm - } - }); + res.status(httpStatus.OK).json({ account: accountSnapshot(account, await findRelationship(account)) }); } catch (error) { next(error); } }; const DEPOSIT_LIST_MAX_LIMIT = 100; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * GET /v1/monerium-b2b/deposits — the acting profile's EUR deposits, newest first, @@ -142,67 +128,68 @@ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: where: { accountId: account.id, moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` } } }); - const executions = rows.length - ? await MoneriumConversionExecution.findAll({ - order: [["created_at", "ASC"]], - 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) ?? []; - grouped.push(execution); - executionsByDeposit.set(execution.depositId as string, grouped); + res.status(httpStatus.OK).json({ + deposits: await depositSnapshots(account, await findRelationship(account), rows), + pagination: { limit, offset, total: count } + }); + } catch (error) { + next(error); + } +}; + +/** + * GET /v1/monerium-b2b/accounts — every onramp account of the calling manager's active + * managed profiles, newest first, optionally narrowed to one Monerium profile. Manager + * credential only: no delegation header, no child credential. + */ +export const listMoneriumB2bAccounts = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const managerProfileId = getAuthenticatedProfileId(req); + const manager = managerProfileId ? await ManagedProfileManager.findByPk(managerProfileId) : null; + if ( + !manager?.isActive || + !manager.allowedCorridors.includes("EU") || + (manager.allowedCustomerTypes !== null && !manager.allowedCustomerTypes.includes("business")) + ) { + res.status(httpStatus.FORBIDDEN).json({ + error: { + code: "MANAGED_PROFILE_ACCESS_DENIED", + message: "The authenticated profile does not manage business EUR onramp accounts", + status: httpStatus.FORBIDDEN + } + }); + return; + } + const moneriumProfileId = req.query.moneriumProfileId; + if (moneriumProfileId !== undefined && (typeof moneriumProfileId !== "string" || !UUID_PATTERN.test(moneriumProfileId))) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MONERIUM_B2B_INVALID_INPUT", + message: "moneriumProfileId must be a UUID", + status: httpStatus.BAD_REQUEST + } + }); + return; } + const rawLimit = Number(req.query.limit ?? 20); + const rawOffset = Number(req.query.offset ?? 0); + const limit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, DEPOSIT_LIST_MAX_LIMIT) : 20; + const offset = Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0; + // ponytail: the manager's children go into one IN list; page the relationship query if a partner ever has thousands. + const relationships = await ManagedProfile.findAll({ where: { managerProfileId: manager.profileId, status: "active" } }); + const byProfile = new Map(relationships.map(relationship => [relationship.profileId, relationship])); + const { count, rows } = await MoneriumAccount.findAndCountAll({ + limit, + offset, + order: [["created_at", "DESC"]], + where: { + vortexProfileId: { [Op.in]: [...byProfile.keys()] }, + ...(moneriumProfileId ? { profileId: moneriumProfileId } : {}) + } + }); res.status(httpStatus.OK).json({ - deposits: rows.map(row => { - 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 - ); - 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 => ({ - 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, - // 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 - .filter(execution => execution.status === MoneriumConversionExecutionStatus.Confirmed) - .reduce((sum, execution) => sum + BigInt(execution.usdcNetRaw ?? "0"), 0n) - .toString() - }; - }), + accounts: rows.map(account => accountSnapshot(account, byProfile.get(account.vortexProfileId as string))), pagination: { limit, offset, total: count } }); } catch (error) { diff --git a/apps/api/src/api/routes/v1/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/monerium-b2b.route.ts index 8c8705438..6be2253ed 100644 --- a/apps/api/src/api/routes/v1/monerium-b2b.route.ts +++ b/apps/api/src/api/routes/v1/monerium-b2b.route.ts @@ -1,7 +1,11 @@ import { Router } from "express"; import * as moneriumB2bController from "../../controllers/monerium-b2b.controller"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; -import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; +import { + authorizeManagedProfile, + rejectDirectManagedCredential, + rejectManagedProfileSelection +} from "../../middlewares/managedProfileAuth"; const router = Router(); @@ -16,4 +20,13 @@ const accountAuth = [requirePartnerOrUserAuth(), authorizeManagedProfile({ corri router.get("/account", ...accountAuth, moneriumB2bController.getMoneriumB2bAccount); router.get("/deposits", ...accountAuth, moneriumB2bController.listMoneriumB2bDeposits); +// Manager-level read: every onramp account of the caller's managed profiles (manager key only). +router.get( + "/accounts", + requirePartnerOrUserAuth(), + rejectDirectManagedCredential, + rejectManagedProfileSelection, + moneriumB2bController.listMoneriumB2bAccounts +); + export default router; 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 37e270e74..07273249d 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 @@ -634,22 +634,30 @@ describe("pricePlannedSwap", () => { it("defers on a non-positive Chainlink answer", async () => { arrange({ oracleAnswer: 0n }); - expect(await price()).toEqual({ kind: "defer", reason: "Chainlink EUR/USD answered 0" }); + expect(await price()).toEqual({ code: "oracle_unavailable", 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") }); + expect(await price()).toMatchObject({ + code: "reference_unavailable", + 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") }); + expect(await price()).toMatchObject({ + code: "reference_out_of_band", + 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" }); + expect(await price()).toEqual({ code: "no_route", kind: "defer", reason: "the factory has no enabled swap route" }); }); it("uses the first enabled route unprojected off mainnet, still carrying the tier cap", async () => { @@ -659,7 +667,7 @@ describe("pricePlannedSwap", () => { 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" }); + expect(await price()).toEqual({ code: "no_route", kind: "defer", reason: "no enabled swap route could be quoted" }); }); it("picks the route with the highest quote and projects its settlement", async () => { @@ -680,7 +688,7 @@ describe("pricePlannedSwap", () => { 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") }); + expect(await price(0)).toMatchObject({ code: "below_floor", 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 a2b9cc008..e4843a46e 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1,3 +1,4 @@ +import type { DepositRefundReason, DepositWaitingReason } from "@vortexfi/shared"; import { Op, Transaction } from "sequelize"; import { Address, encodeFunctionData, Hex, parseEventLogs, TransactionReceipt, TransactionReceiptNotFoundError } from "viem"; import sequelize from "../../../config/database"; @@ -725,7 +726,7 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans // ------------------------------------------------------------------ pricing export type PlannedSwap = - | { kind: "defer"; reason: string } + | { kind: "defer"; code: DepositWaitingReason; reason: string } | { kind: "ready"; /** The tier cap in USDC (6 decimals): the `maxSubsidy` argument of the swap. */ @@ -735,8 +736,8 @@ export type PlannedSwap = routeIndex: number; }; -function deferSwap(reason: string): PlannedSwap { - return { kind: "defer", reason }; +function deferSwap(code: DepositWaitingReason, reason: string): PlannedSwap { + return { code, kind: "defer", reason }; } /** Quotes every enabled route on the mainnet QuoterV2; a route that cannot be quoted is skipped with a warning. */ @@ -778,17 +779,18 @@ export async function pricePlannedSwap( ]); const oracleRaw = roundData[1]; if (oracleRaw <= 0n) { - return deferSwap(`Chainlink EUR/USD answered ${oracleRaw}`); + return deferSwap("oracle_unavailable", `Chainlink EUR/USD answered ${oracleRaw}`); } let reference: ReferenceQuote; try { reference = await fetchCoinbaseReference(immutables.oracleDecimals); } catch (error) { - return deferSwap(`reference rate unavailable: ${errorText(error)}`); + return deferSwap("reference_unavailable", `reference rate unavailable: ${errorText(error)}`); } if (!isWithinReferenceBand(reference.rateRaw, oracleRaw, immutables.maxReferenceDeviationBps)) { return deferSwap( + "reference_out_of_band", `reference ${reference.price} is outside the ${immutables.maxReferenceDeviationBps} bps band around Chainlink ${oracleRaw}` ); } @@ -798,14 +800,14 @@ export async function pricePlannedSwap( const routes = await readEnabledRoutes(factory); if (routes.length === 0) { - return deferSwap("the factory has no enabled swap route"); + return deferSwap("no_route", "the factory has no enabled swap route"); } if ((await getChainId()) !== 1) { return { kind: "ready", maxSubsidyRaw, 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"); + return deferSwap("no_route", "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); @@ -826,6 +828,7 @@ export async function pricePlannedSwap( // Calibration data for the ladder: the shortfall this attempt would have needed. const shortfallBps = referenceOut > 0n ? Number((projection.subsidy * BPS) / referenceOut) : 0; return deferSwap( + "below_floor", `${projection.defer} (route ${best.index} quoted ${best.quotedOut}, shortfall ${shortfallBps} bps, tier ${maxSubsidyBps} bps)` ); } @@ -1017,11 +1020,21 @@ export async function runConversionExecutor(accountId: string): Promise { logger.warn( `monerium-b2b: deferring conversion for account ${account.id} (chunk waited ${planned.elapsedSeconds}s): ${plan.reason}` ); + // Partner-visible hold reason (DEPOSIT_UPDATED); written only when it changes. + if (planned.deposit.waitingReason !== plan.code) { + await planned.deposit.update({ + waitingReason: plan.code, + waitingSince: planned.deposit.waitingSince ?? new Date() + }); + } if (pokeNeeded) { await sendPoke(forwarder); } return; } + if (planned.deposit.waitingReason) { + await planned.deposit.update({ waitingReason: null, waitingSince: null }); + } } const readyPlan = plan; const call = executionCall(planned, readyPlan); @@ -1251,7 +1264,7 @@ async function sendPoke(forwarder: Address): Promise { * 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 { +export async function markDepositForRecovery(depositId: string, reason: DepositRefundReason): Promise { const deposit = await MoneriumFiatDeposit.findByPk(depositId); if (!deposit) return "deposit not found"; const account = await MoneriumAccount.findByPk(deposit.accountId); @@ -1272,7 +1285,10 @@ export async function markDepositForRecovery(depositId: string): Promise 0) { return "deposit has a pending execution; retry once it settled"; } - await current.update({ status: MoneriumFiatDepositStatus.Recovering }, { transaction }); + await current.update( + { refundReason: reason, refundStartedAt: new Date(), 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 2bddfb883..9dac2b561 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 @@ -118,6 +118,7 @@ describe("parseOrderEvent", () => { state: "processed", payerIban: null, payerName: null, + rejectedReason: null, txHash: "0xabc" }); }); @@ -457,7 +458,9 @@ describe("order-event inbox processing (end to end)", () => { 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"] } }) + payload: orderEvent("rejected", { + meta: { placedAt: "2026-08-26T00:00:00Z", rejectedReason: "Payer verification failed", txHashes: ["0xmint"] } + }) }); await processMoneriumWebhookInbox(PROCESSOR_DEPS); @@ -467,6 +470,7 @@ describe("order-event inbox processing (end to end)", () => { expect(await MoneriumFiatDeposit.count()).toBe(2); expect(providerDeposit).toMatchObject({ blockNumber: null, + rejectedReason: "Payer verification failed", status: MoneriumFiatDepositStatus.Returned, txHash: "0xmint" }); 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 4f996251b..3b6f03012 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -107,6 +107,8 @@ interface ParsedOrderEvent { /** The payer's IBAN and name from the order's counterpart (Monerium "Issue orders" details), when present. */ payerIban: string | null; payerName: string | null; + /** Monerium's reason on a rejected order. */ + rejectedReason: string | null; } export interface ParsedIbanEvent { @@ -194,6 +196,7 @@ export function parseOrderEvent(payload: unknown): ParsedOrderEvent | null { : null, payerName: typeof details.name === "string" && details.name.trim() ? details.name.trim().slice(0, 140) : null, profileId: data.profile, + rejectedReason: data.meta.rejectedReason?.trim().slice(0, 500) || null, state: data.state, txHash: data.meta.txHashes?.length === 1 ? data.meta.txHashes[0] : null }; @@ -365,13 +368,20 @@ async function processInboxRow(row: MoneriumWebhookEvent, deps: DepositProcessor moneriumOrderId: event.orderId, payerIban: event.payerIban, payerName: event.payerName, + rejectedReason: targetStatus === MoneriumFiatDepositStatus.Returned ? event.rejectedReason : null, status: targetStatus ?? MoneriumFiatDepositStatus.Pending, txHash: event.txHash }, { transaction } ); } else { - const updates: { payerIban?: string; payerName?: string; status?: MoneriumFiatDepositStatus; txHash?: string } = {}; + const updates: { + payerIban?: string; + payerName?: string; + rejectedReason?: 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; @@ -379,6 +389,9 @@ async function processInboxRow(row: MoneriumWebhookEvent, deps: DepositProcessor if (targetStatus && targetStatus !== existing.status && !alreadyPastMint) { if (isForwardTransition(existing.status, targetStatus)) { updates.status = targetStatus; + if (targetStatus === MoneriumFiatDepositStatus.Returned && event.rejectedReason) { + updates.rejectedReason = event.rejectedReason; + } } else { logger.warn( `monerium-b2b: ignoring backward status transition ${existing.status} -> ${targetStatus} for order ${event.orderId}` 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 1a3e1c659..f2707981e 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 @@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test" import { WebhookEventType } from "@vortexfi/shared"; import { config } from "../../../config/vars"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { MoneriumConversionExecutionKind, MoneriumConversionExecutionStatus @@ -14,6 +15,7 @@ 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 { markDepositForRecovery } from "./conversion-executor"; import { emitMoneriumDepositEvents, maskIban } from "./manager-events"; const FORWARDER = "0x1111111111111111111111111111111111111111"; @@ -363,4 +365,141 @@ describe("monerium b2b manager events", () => { expect(deliveries).toHaveLength(1); expect((deliveries[0] as WebhookDelivery & { webhook: Webhook }).webhook.url).toBe("https://manager.example.com/hook"); }); + + it("sends DEPOSIT_UPDATED with the full snapshot on every change, and only then", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_UPDATED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + currency: "eur", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Pending + }); + const snapshots = async () => + (await WebhookDelivery.findAll({ order: [["created_at", "ASC"]] })).map(delivery => { + expect(delivery.webhookId).toBe(webhook?.id as string); + return (delivery.payload as unknown as { payload: Record }).payload; + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await emitMoneriumDepositEvents(depsAtBlock(null)); + expect(await snapshots()).toHaveLength(1); + expect((await snapshots())[0]).toMatchObject({ + amount: "100.00", + depositId: deposit.id, + externalSubjectId: "client-1", + moneriumOrderId: "order-1", + moneriumProfileId: MONERIUM_PROFILE, + profileId: mapped.profileId, + refund: null, + status: "pending", + waiting: { reason: "monerium_pending" } + }); + + // Minted, then held by the keeper on the market: two more snapshots. + await deposit.update({ + blockNumber: 100, + chainId: 11155111, + logIndex: 1, + mintedAt: new Date(), + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + await emitMoneriumDepositEvents(depsAtBlock(null)); + await deposit.update({ waitingReason: "below_floor", waitingSince: new Date() }); + await emitMoneriumDepositEvents(depsAtBlock(null)); + let sent = await snapshots(); + expect(sent).toHaveLength(3); + expect(sent[1]).toMatchObject({ status: "minted", txHash: "0xmint", waiting: null }); + expect(sent[2]).toMatchObject({ status: "minted", waiting: { reason: "below_floor" } }); + + // The refund path carries its reason and start time; the market hold no longer applies. + expect(await markDepositForRecovery(deposit.id, "window_missed")).toBeNull(); + await emitMoneriumDepositEvents(depsAtBlock(null)); + sent = await snapshots(); + expect(sent).toHaveLength(4); + expect(sent[3]).toMatchObject({ + refund: { amount: "100.00", reason: "window_missed", startedAt: expect.any(String) }, + status: "recovering", + waiting: null + }); + const eventIds = (await WebhookDelivery.findAll()).map(delivery => delivery.eventId); + expect(new Set(eventIds).size).toBe(4); + }); + + it("reports a forwarded deposit through DEPOSIT_UPDATED only at confirmation depth", async () => { + const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_UPDATED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 999, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Converting, + txHash: "0xmint" + }); + await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1000, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: "100000000000000000000", + feeRaw: "0", + referenceRateRaw: "108140000", + status: MoneriumConversionExecutionStatus.Confirmed, + subsidyRaw: "0", + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + await emitMoneriumDepositEvents(depsAtBlock(null)); + await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1001, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: "100000000000000000000", + kind: MoneriumConversionExecutionKind.Forward, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xforward", + usdcNetRaw: "108000000" + }); + await deposit.update({ status: MoneriumFiatDepositStatus.Forwarded }); + + await emitMoneriumDepositEvents(depsAtBlock(1001n)); + expect(await WebhookDelivery.count()).toBe(1); + await emitMoneriumDepositEvents(depsAtBlock(1001n + BigInt(NOTIFY_CONFIRMATION_DEPTH))); + await emitMoneriumDepositEvents(depsAtBlock(1001n + BigInt(NOTIFY_CONFIRMATION_DEPTH))); + const deliveries = await WebhookDelivery.findAll({ order: [["created_at", "ASC"]] }); + expect(deliveries).toHaveLength(2); + expect((deliveries[1].payload as { payload: unknown }).payload).toMatchObject({ + conversions: [{ confirmedAt: expect.any(String), status: "confirmed", txHash: "0xswap", usdcNetRaw: "108000000" }], + deliveredAt: expect.any(String), + forwardTxHash: "0xforward", + status: "forwarded", + usdcNetRaw: "108000000" + }); + }); + + it("sends ACCOUNT_UPDATED when the account snapshot changes", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.ACCOUNT_UPDATED]); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await emitMoneriumDepositEvents(depsAtBlock(null)); + await MoneriumAccount.update({ iban: "EE123456789012345678" }, { where: { id: mapped.accountId } }); + await emitMoneriumDepositEvents(depsAtBlock(null)); + + const deliveries = await WebhookDelivery.findAll({ order: [["created_at", "ASC"]] }); + expect(deliveries).toHaveLength(2); + expect(deliveries.every(delivery => delivery.webhookId === webhook?.id)).toBe(true); + expect((deliveries[0].payload as { payload: unknown }).payload).toMatchObject({ + accountId: mapped.accountId, + externalSubjectId: "client-1", + iban: null, + moneriumProfileId: MONERIUM_PROFILE, + profileId: mapped.profileId + }); + expect((deliveries[1].payload as { payload: unknown }).payload).toMatchObject({ iban: "EE123456789012345678" }); + }); }); 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 6758b9dbc..c3b46bb5f 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -1,11 +1,17 @@ +import { createHash } from "node:crypto"; import { + type AccountSnapshot, type ConversionExecutionPricing, + type DepositRefundReason, + type DepositSnapshot, DepositStatus, + type DepositWaitingReason, type DepositWebhookPayloadBase, WebhookEventType, 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"; @@ -63,18 +69,133 @@ function depositPayloadBase(deposit: MoneriumFiatDeposit, account: MoneriumAccou } /** - * Resolves the controlling manager for an account's deposit events. Returns null when - * the account is unmapped or the managed relationship is gone — the event is then - * marked emitted with no deliveries, so history is never replayed to late subscribers. + * The active managed relationship behind an account: its controlling manager and the + * partner's client reference. Null when the account is unmapped or the relationship is + * gone; events are then marked emitted with no deliveries, so history is never replayed + * to late subscribers. */ -async function resolveManagerProfileId(account: MoneriumAccount): Promise { +export async function findRelationship(account: MoneriumAccount): Promise { if (!account.vortexProfileId) return null; - const relationship = await ManagedProfile.findOne({ - where: { profileId: account.vortexProfileId, status: "active" } + return ManagedProfile.findOne({ where: { profileId: account.vortexProfileId, status: "active" } }); +} + +async function resolveManagerProfileId(account: MoneriumAccount): Promise { + return (await findRelationship(account))?.managerProfileId ?? null; +} + +const iso = (date: Date | null | undefined): string | null => (date ? date.toISOString() : null); + +export function accountSnapshot(account: MoneriumAccount, relationship: ManagedProfile | null | undefined): AccountSnapshot { + return { + accountId: account.id, + createdAt: account.createdAt.toISOString(), + destination: account.destination, + dormantSince: iso(account.dormantSince), + externalSubjectId: relationship?.externalSubjectId ?? null, + floorPpm: account.floorPpm, + forwarderAddress: account.forwarderAddress, + iban: account.iban, + moneriumProfileId: account.profileId, + profileId: account.vortexProfileId, + status: account.status, + targetPpm: account.targetPpm + }; +} + +function waitingOf(deposit: MoneriumFiatDeposit): DepositSnapshot["waiting"] { + if (deposit.status === MoneriumFiatDepositStatus.Pending || deposit.status === MoneriumFiatDepositStatus.Held) { + return { reason: "monerium_pending", since: deposit.createdAt.toISOString() }; + } + const converting = + deposit.status === MoneriumFiatDepositStatus.Minted || deposit.status === MoneriumFiatDepositStatus.Converting; + if (!converting || !deposit.waitingReason) return null; + return { + reason: deposit.waitingReason as DepositWaitingReason, + since: iso(deposit.waitingSince) ?? deposit.createdAt.toISOString() + }; +} + +/** A confirmed execution row is terminal, so its last update is its confirmation. */ +const confirmedAt = (execution: MoneriumConversionExecution): string | null => + execution.status === MoneriumConversionExecutionStatus.Confirmed ? execution.updatedAt.toISOString() : null; + +/** + * Snapshots of one account's deposits, in the given order. The deposits endpoint and the + * DEPOSIT_UPDATED event share this shape. + */ +export async function depositSnapshots( + account: MoneriumAccount, + relationship: ManagedProfile | null | undefined, + deposits: MoneriumFiatDeposit[] +): Promise { + if (deposits.length === 0) return []; + const depositIds = deposits.map(deposit => deposit.id); + const [executions, recoveries] = await Promise.all([ + MoneriumConversionExecution.findAll({ + order: [["created_at", "ASC"]], + where: { depositId: depositIds, status: { [Op.ne]: MoneriumConversionExecutionStatus.Failed } } + }), + MoneriumRecovery.findAll({ where: { depositId: depositIds } }) + ]); + return deposits.map(deposit => { + const own = executions.filter(execution => execution.depositId === deposit.id); + const confirmed = (kind: MoneriumConversionExecutionKind) => + own.find(execution => execution.kind === kind && execution.status === MoneriumConversionExecutionStatus.Confirmed); + const swaps = own.filter(execution => execution.kind === MoneriumConversionExecutionKind.Swap); + const forward = confirmed(MoneriumConversionExecutionKind.Forward); + const recover = confirmed(MoneriumConversionExecutionKind.Recover); + const recovery = recoveries.find(row => row.depositId === deposit.id); + return { + accountId: account.id, + amount: eurAmountFromRaw(deposit.amountRaw), + amountRaw: deposit.amountRaw, + conversions: swaps.map(execution => ({ + confirmedAt: confirmedAt(execution), + eureInRaw: execution.eureInRaw, + execution: executionPricing(execution), + executionId: execution.id, + sentAt: execution.createdAt.toISOString(), + status: execution.status as "pending" | "confirmed", + txHash: execution.txHash, + usdcNetRaw: execution.usdcNetRaw ?? "0" + })), + currency: deposit.currency, + deliveredAt: forward ? confirmedAt(forward) : null, + depositId: deposit.id, + externalSubjectId: relationship?.externalSubjectId ?? null, + forwardTxHash: forward?.txHash ?? null, + mintedAt: iso(deposit.mintedAt), + moneriumOrderId: deposit.moneriumOrderId, + moneriumProfileId: account.profileId, + profileId: account.vortexProfileId as string, + receivedAt: deposit.createdAt.toISOString(), + refund: + deposit.refundStartedAt || recovery || recover + ? { + amount: recovery?.refundAmount ?? eurAmountFromRaw(deposit.amountRaw), + payerIbanMasked: deposit.payerIban ? maskIban(deposit.payerIban) : null, + reason: deposit.refundReason as DepositRefundReason | null, + recoverTxHash: recover?.txHash ?? null, + redeemOrderId: recovery?.redeemOrderId ?? null, + refundedAt: deposit.status === MoneriumFiatDepositStatus.Refunded ? iso(recovery?.updatedAt) : null, + startedAt: iso(deposit.refundStartedAt) + } + : null, + rejectedReason: deposit.rejectedReason, + status: deposit.status as unknown as DepositStatus, + txHash: deposit.txHash, + usdcNetRaw: swaps + .filter(execution => execution.status === MoneriumConversionExecutionStatus.Confirmed) + .reduce((sum, execution) => sum + BigInt(execution.usdcNetRaw ?? "0"), 0n) + .toString(), + waiting: waitingOf(deposit) + }; }); - return relationship?.managerProfileId ?? null; } +const snapshotHash = (snapshot: AccountSnapshot | DepositSnapshot): string => + createHash("sha256").update(JSON.stringify(snapshot)).digest("hex"); + async function enqueueForManager( eventType: WebhookEventType, managerProfileId: string | null, @@ -221,7 +342,7 @@ async function emitReturnedEvents(): Promise { payload: { ...depositPayloadBase(deposit, account), refund: { - amount: recovery?.refundAmount ?? refundAmountFromRaw(deposit.amountRaw), + amount: recovery?.refundAmount ?? eurAmountFromRaw(deposit.amountRaw), payerIbanMasked: deposit.payerIban ? maskIban(deposit.payerIban) : "", recoverTxHash: recoverExecution?.txHash ?? null, redeemOrderId: recovery?.redeemOrderId ?? null @@ -237,17 +358,111 @@ async function emitReturnedEvents(): Promise { } } -/** The issue amount to the cent, for a refund closed by hand before a recovery row recorded it. */ -function refundAmountFromRaw(amountRaw: string): string { +/** An 18-decimal EUR amount to the cent ("1234.56"). */ +function eurAmountFromRaw(amountRaw: string): string { const cents = BigInt(amountRaw) / 10n ** 16n; return `${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`; } +const SETTLED_STATUSES = [ + MoneriumFiatDepositStatus.Forwarded, + MoneriumFiatDepositStatus.Returned, + MoneriumFiatDepositStatus.Refunded +]; + +/** Same depth gate as DEPOSIT_CONVERTED: "forwarded" is reported once the forward cannot reorg away. */ +async function forwardIsDeep(depositId: string, head: bigint | null): Promise { + if (head === null) return false; + const forward = await MoneriumConversionExecution.findOne({ + where: { + depositId, + kind: MoneriumConversionExecutionKind.Forward, + status: MoneriumConversionExecutionStatus.Confirmed + } + }); + return ( + forward?.blockNumber !== null && + forward?.blockNumber !== undefined && + head >= BigInt(forward.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH) + ); +} + +/** + * DEPOSIT_UPDATED: the full snapshot whenever it changed since the last one sent. Unsettled + * deposits are re-evaluated every pass; a settled one once more after its last change. + */ +async function emitDepositUpdatedEvents(deps: ManagerEventDeps): Promise { + // ponytail: oldest BATCH_LIMIT unsettled deposits per pass; page through them if a + // partner ever has more than that in flight at once. + const deposits = await MoneriumFiatDeposit.findAll({ + limit: BATCH_LIMIT, + order: [["created_at", "ASC"]], + where: { + [Op.or]: [ + { status: { [Op.notIn]: SETTLED_STATUSES } }, + { lifecycleEventAt: null }, + sequelize.where(sequelize.col("updated_at"), Op.gt, sequelize.col("lifecycle_event_at")) + ], + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` } + } + }); + let head: bigint | null | undefined; + for (const deposit of deposits) { + try { + if (deposit.status === MoneriumFiatDepositStatus.Forwarded) { + if (head === undefined) head = await deps.getBlockNumber(); + if (!(await forwardIsDeep(deposit.id, head))) continue; + } + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account?.vortexProfileId) continue; + const relationship = await findRelationship(account); + const [snapshot] = await depositSnapshots(account, relationship, [deposit]); + const hash = snapshotHash(snapshot); + if (hash !== deposit.lifecycleEventHash) { + await enqueueForManager(WebhookEventType.DEPOSIT_UPDATED, relationship?.managerProfileId ?? null, { + eventId: `deposit-updated:${deposit.id}:${hash.slice(0, 16)}`, + eventType: WebhookEventType.DEPOSIT_UPDATED, + payload: snapshot, + timestamp: new Date().toISOString() + }); + } + // Silent: the marker must not bump updated_at, which is what re-queues a settled deposit. + await deposit.update({ lifecycleEventAt: new Date(), lifecycleEventHash: hash }, { silent: true }); + } catch (error) { + logger.error(`monerium-b2b: DEPOSIT_UPDATED emission failed for deposit ${deposit.id}:`, error); + } + } +} + +/** ACCOUNT_UPDATED: the account snapshot whenever it changed (IBAN issued, status, dormancy). */ +async function emitAccountUpdatedEvents(): Promise { + // ponytail: scans every mapped account each pass; fine at per-partner pilot scale. + const accounts = await MoneriumAccount.findAll({ where: { vortexProfileId: { [Op.ne]: null } } }); + for (const account of accounts) { + try { + const relationship = await findRelationship(account); + const snapshot = accountSnapshot(account, relationship); + const hash = snapshotHash(snapshot); + if (hash === account.lifecycleEventHash) continue; + await enqueueForManager(WebhookEventType.ACCOUNT_UPDATED, relationship?.managerProfileId ?? null, { + eventId: `account-updated:${account.id}:${hash.slice(0, 16)}`, + eventType: WebhookEventType.ACCOUNT_UPDATED, + payload: snapshot, + timestamp: new Date().toISOString() + }); + await account.update({ lifecycleEventHash: hash }, { silent: true }); + } catch (error) { + logger.error(`monerium-b2b: ACCOUNT_UPDATED emission failed for account ${account.id}:`, error); + } + } +} + /** * 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_RETURNED once a deposit that missed the promised window was refunded. + * DEPOSIT_RETURNED once a deposit that missed the promised window was refunded, and + * DEPOSIT_UPDATED / ACCOUNT_UPDATED whenever a deposit's or account's snapshot changed. * Emission markers make each event fire exactly once regardless of the advancing component. */ export async function emitMoneriumDepositEvents(deps: ManagerEventDeps = defaultDeps): Promise { @@ -255,6 +470,8 @@ export async function emitMoneriumDepositEvents(deps: ManagerEventDeps = default await emitReceivedEvents(); await emitConvertedEvents(deps); await emitReturnedEvents(); + await emitDepositUpdatedEvents(deps); + await emitAccountUpdatedEvents(); } catch (error) { logger.error("monerium-b2b: manager event emission failed:", error); } diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index 1ef4f8a80..b4276aa48 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -283,7 +283,7 @@ export async function runRecoveryDeadlines(now: number = Date.now()): Promise { + await queryInterface.addColumn("monerium_fiat_deposits", "waiting_reason", { allowNull: true, type: DataTypes.STRING(32) }); + await queryInterface.addColumn("monerium_fiat_deposits", "waiting_since", { allowNull: true, type: DataTypes.DATE }); + await queryInterface.addColumn("monerium_fiat_deposits", "rejected_reason", { allowNull: true, type: DataTypes.STRING(500) }); + await queryInterface.addColumn("monerium_fiat_deposits", "refund_reason", { allowNull: true, type: DataTypes.STRING(32) }); + await queryInterface.addColumn("monerium_fiat_deposits", "refund_started_at", { allowNull: true, type: DataTypes.DATE }); + await queryInterface.addColumn("monerium_fiat_deposits", "lifecycle_event_hash", { + allowNull: true, + type: DataTypes.STRING(64) + }); + await queryInterface.addColumn("monerium_fiat_deposits", "lifecycle_event_at", { allowNull: true, type: DataTypes.DATE }); + await queryInterface.addColumn("monerium_accounts", "lifecycle_event_hash", { allowNull: true, type: DataTypes.STRING(64) }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_accounts", "lifecycle_event_hash"); + for (const column of [ + "lifecycle_event_at", + "lifecycle_event_hash", + "refund_started_at", + "refund_reason", + "rejected_reason", + "waiting_since", + "waiting_reason" + ]) { + await queryInterface.removeColumn("monerium_fiat_deposits", column); + } +} diff --git a/apps/api/src/models/moneriumAccount.model.ts b/apps/api/src/models/moneriumAccount.model.ts index bc177176f..228e36535 100644 --- a/apps/api/src/models/moneriumAccount.model.ts +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -25,6 +25,8 @@ export interface MoneriumAccountAttributes { configVersion: number; status: MoneriumAccountStatus; dormantSince: Date | null; + /** Hash of the last ACCOUNT_UPDATED snapshot sent to the partner. */ + lifecycleEventHash: string | null; createdAt: Date; updatedAt: Date; } @@ -39,6 +41,7 @@ type MoneriumAccountCreationAttributes = Optional< | "configVersion" | "status" | "dormantSince" + | "lifecycleEventHash" | "createdAt" | "updatedAt" >; @@ -58,6 +61,7 @@ class MoneriumAccount declare configVersion: number; declare status: MoneriumAccountStatus; declare dormantSince: Date | null; + declare lifecycleEventHash: string | null; declare createdAt: Date; declare updatedAt: Date; } @@ -108,6 +112,11 @@ MoneriumAccount.init( primaryKey: true, type: DataTypes.UUID }, + lifecycleEventHash: { + allowNull: true, + field: "lifecycle_event_hash", + type: DataTypes.STRING(64) + }, profileId: { allowNull: false, field: "profile_id", diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts index 84f54b3c8..65f143b07 100644 --- a/apps/api/src/models/moneriumFiatDeposit.model.ts +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -47,6 +47,17 @@ export interface MoneriumFiatDepositAttributes { receivedEventAt: Date | null; convertedEventAt: Date | null; returnedEventAt: Date | null; + /** Why the keeper is waiting to convert (a DepositWaitingReason), and since when. */ + waitingReason: string | null; + waitingSince: Date | null; + /** Monerium's reason when it rejected the payment before minting. */ + rejectedReason: string | null; + /** Why the deposit entered the refund path (a DepositRefundReason), and when. */ + refundReason: string | null; + refundStartedAt: Date | null; + /** Hash and time of the last DEPOSIT_UPDATED snapshot evaluated (set silently, never bumps updated_at). */ + lifecycleEventHash: string | null; + lifecycleEventAt: Date | null; createdAt: Date; updatedAt: Date; } @@ -66,6 +77,13 @@ type MoneriumFiatDepositCreationAttributes = Optional< | "receivedEventAt" | "convertedEventAt" | "returnedEventAt" + | "waitingReason" + | "waitingSince" + | "rejectedReason" + | "refundReason" + | "refundStartedAt" + | "lifecycleEventHash" + | "lifecycleEventAt" | "createdAt" | "updatedAt" >; @@ -91,6 +109,13 @@ class MoneriumFiatDeposit declare receivedEventAt: Date | null; declare convertedEventAt: Date | null; declare returnedEventAt: Date | null; + declare waitingReason: string | null; + declare waitingSince: Date | null; + declare rejectedReason: string | null; + declare refundReason: string | null; + declare refundStartedAt: Date | null; + declare lifecycleEventHash: string | null; + declare lifecycleEventAt: Date | null; declare createdAt: Date; declare updatedAt: Date; } @@ -145,6 +170,16 @@ MoneriumFiatDeposit.init( primaryKey: true, type: DataTypes.UUID }, + lifecycleEventAt: { + allowNull: true, + field: "lifecycle_event_at", + type: DataTypes.DATE + }, + lifecycleEventHash: { + allowNull: true, + field: "lifecycle_event_hash", + type: DataTypes.STRING(64) + }, logIndex: { allowNull: true, field: "log_index", @@ -176,6 +211,21 @@ MoneriumFiatDeposit.init( field: "received_event_at", type: DataTypes.DATE }, + refundReason: { + allowNull: true, + field: "refund_reason", + type: DataTypes.STRING(32) + }, + refundStartedAt: { + allowNull: true, + field: "refund_started_at", + type: DataTypes.DATE + }, + rejectedReason: { + allowNull: true, + field: "rejected_reason", + type: DataTypes.STRING(500) + }, returnedEventAt: { allowNull: true, field: "returned_event_at", @@ -196,6 +246,16 @@ MoneriumFiatDeposit.init( defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE + }, + waitingReason: { + allowNull: true, + field: "waiting_reason", + type: DataTypes.STRING(32) + }, + waitingSince: { + allowNull: true, + field: "waiting_since", + type: DataTypes.DATE } }, { 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 6932ec8cd..a00c3ee9f 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 @@ -198,7 +198,7 @@ describe("monerium b2b account read surface", () => { const response = await app.request("/v1/webhook", { body: JSON.stringify({ - events: ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED"], + events: ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED", "DEPOSIT_UPDATED", "ACCOUNT_UPDATED"], url: "https://manager.example.com/vortex/deposits" }), headers: { "Content-Type": "application/json", ...managerHeaders }, @@ -206,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", "DEPOSIT_RETURNED"]); + expect(body.events).toEqual(["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED", "DEPOSIT_UPDATED", "ACCOUNT_UPDATED"]); expect(body.quoteId).toBeNull(); // The transaction-family requirement still holds at the same HTTP surface. @@ -217,4 +217,47 @@ describe("monerium b2b account read surface", () => { }); expect(legacyWithoutTarget.status).toBe(400); }); + + it("lists the manager's onramp accounts and finds one by Monerium profile ID", async () => { + const { delegatedHeaders, managerHeaders, mapped } = await setupMappedChild(); + + const all = await jsonRequest("/v1/monerium-b2b/accounts", managerHeaders); + expect(all.status).toBe(200); + expect(all.body).toMatchObject({ + accounts: [ + { + accountId: mapped.accountId, + externalSubjectId: "client-1", + iban: null, + moneriumProfileId: MONERIUM_PROFILE, + profileId: mapped.profileId, + status: "onboarding" + } + ], + pagination: { total: 1 } + }); + + const byProfile = await jsonRequest(`/v1/monerium-b2b/accounts?moneriumProfileId=${MONERIUM_PROFILE}`, managerHeaders); + expect((byProfile.body.accounts as unknown[]).length).toBe(1); + const other = await jsonRequest(`/v1/monerium-b2b/accounts?moneriumProfileId=${crypto.randomUUID()}`, managerHeaders); + expect((other.body.accounts as unknown[]).length).toBe(0); + expect((await jsonRequest("/v1/monerium-b2b/accounts?moneriumProfileId=nope", managerHeaders)).status).toBe(400); + + // Manager key only: no delegation header, and a foreign manager sees nothing. + expect((await jsonRequest("/v1/monerium-b2b/accounts", delegatedHeaders)).status).toBe(400); + const stranger = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: stranger.id + }); + const strangerCredential = await createTestApiKey({ userId: stranger.id }); + const foreign = await jsonRequest("/v1/monerium-b2b/accounts", { "X-API-Key": strangerCredential.plaintextKey }); + expect(foreign.body).toMatchObject({ accounts: [], pagination: { total: 0 } }); + + const notAManager = await createTestUser(); + const plainCredential = await createTestApiKey({ userId: notAManager.id }); + expect((await jsonRequest("/v1/monerium-b2b/accounts", { "X-API-Key": plainCredential.plaintextKey })).status).toBe(403); + }); }); diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 2d9dd36b9..f7edc7faa 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -47,6 +47,7 @@ "/v1/managed-profiles/{profileId}/api-credentials", "/v1/managed-profiles/{profileId}/api-credentials/{credentialId}", "/v1/monerium-b2b/account", + "/v1/monerium-b2b/accounts", "/v1/monerium-b2b/deposits", "/v1/onboarding/active-entity", "/v1/onboarding/requirements", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 7be63b876..3e285e5b0 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -970,6 +970,28 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/monerium-b2b/accounts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the manager's EUR onramp accounts + * @description Available in sandbox; production activation is pending. Lists the business EUR onramp accounts of every active managed profile under the calling manager, newest first, each with its IBAN and status. Filter by `moneriumProfileId` to find one client by the EUR provider's profile ID. Manager credential only: the `X-Managed-Profile-Id` header is rejected with 400 and a child's own credential with 403. + * + * **Auth:** `X-API-Key` or Supabase Bearer. + */ + get: operations["listMoneriumB2bAccounts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/monerium-b2b/deposits": { parameters: { query?: never; @@ -979,7 +1001,7 @@ export interface paths { }; /** * List the acting profile's EUR deposits - * @description Available in sandbox; production activation is pending. Returns the acting profile's EUR deposits newest first, with every allocated conversion portion and aggregate attributed USDC. A per-swap cap can split one deposit across multiple executions. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. + * @description Available in sandbox; production activation is pending. Returns the acting profile's EUR deposits newest first, each as its full lifecycle snapshot: IDs, amounts, timestamps, waiting and refund reasons, and every conversion chunk. A per-swap cap can split one deposit into several chunks, delivered together in one transfer. This is the polling surface; the `DEPOSIT_UPDATED` webhook pushes the same snapshot on every change. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. * * **Auth:** `X-API-Key` or Supabase Bearer. */ @@ -2864,12 +2886,18 @@ export interface components { * @description Set while the account is dormancy-paused. */ dormantSince: string | null; + /** @description Your own client reference for this managed profile. */ + externalSubjectId: string | null; /** @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; + /** @description The EUR provider's profile ID for this client. */ + moneriumProfileId: string; + /** @description The client's managed profile ID: the `X-Managed-Profile-Id` value for delegated calls. */ + profileId: 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. */ @@ -2878,11 +2906,27 @@ export interface components { MoneriumB2bAccountResponse: { account: components["schemas"]["MoneriumB2bAccount"]; }; + MoneriumB2bAccountsResponse: { + accounts: components["schemas"]["MoneriumB2bAccount"][]; + pagination: { + limit: number; + offset: number; + total: number; + }; + }; MoneriumB2bDeposit: { + accountId: string; + /** @description EUR amount to the cent, for example "1234.56". */ + amount: string; /** @description Deposit amount in 18-decimal base units of the deposit currency. */ amountRaw: string; /** @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: { + /** + * Format: date-time + * @description When the chunk's swap confirmed; null while pending. + */ + confirmedAt: string | null; /** @description EURe of this deposit consumed by the chunk in 18-decimal base units. */ eureInRaw: string; /** @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. */ @@ -2896,28 +2940,75 @@ export interface components { }; executionId: string; /** - * @description Execution status. + * Format: date-time + * @description When the chunk's swap was sent. + */ + sentAt: string; + /** + * @description Chunk status. Failed attempts are retried and not listed. * @enum {string} */ - status: "pending" | "confirmed" | "failed"; + status: "pending" | "confirmed"; /** @description The chunk swap transaction hash. */ txHash: string | null; /** @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; + /** + * Format: date-time + * @description When the single transfer to the destination confirmed; null until forwarded. + */ + deliveredAt: string | null; depositId: string; + /** @description Your own client reference for this managed profile. */ + externalSubjectId: string | null; /** @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. */ + /** + * Format: date-time + * @description When the EUR arrived on chain as EURe; the conversion window counts from here. + */ + mintedAt: string | null; + /** @description The EUR provider's order ID for the incoming payment. */ + moneriumOrderId: string; + /** @description The EUR provider's profile ID for this client. */ + moneriumProfileId: string; + /** @description The client's managed profile ID: the `X-Managed-Profile-Id` value for delegated calls. */ + profileId: string; + /** + * Format: date-time + * @description When Vortex first saw the payment. + */ + receivedAt: string; + /** @description Present once the deposit entered the refund path: why, when it started, the EUR amount refunded, the masked IBAN it goes to, the provider's redemption 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. */ + /** @description The EUR amount refunded, to the cent: always the full issue amount. */ amount: string | null; + /** @description The payer's IBAN the refund goes to, masked to its first and last four characters. */ + payerIbanMasked: string | null; + /** + * @description Why the deposit is refunded: `window_missed` (not converted within the promised window), `compliance`, `incident`, or `operator` for another operator decision. + * @enum {string|null} + */ + reason: "window_missed" | "compliance" | "incident" | "operator" | null; + /** @description The transaction that moved the deposit off the forwarding contract. */ recoverTxHash: string | null; + /** @description The provider's order ID for the outgoing SEPA refund. */ redeemOrderId: string | null; + /** + * Format: date-time + * @description When the provider processed the refund; null until then. + */ + refundedAt: string | null; + /** + * Format: date-time + * @description When the deposit entered the refund path. + */ + startedAt: string | null; } | null; + /** @description The provider's reason when it returned the payment before minting. */ + rejectedReason: string | 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} @@ -2927,6 +3018,13 @@ export interface components { txHash: string | null; /** @description Sum of the confirmed chunks' net USDC in 6-decimal base units: what the deposit's single transfer delivers once forwarded. */ usdcNetRaw: string; + /** @description Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. Afterwards, the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise. */ + waiting: { + /** @enum {string} */ + reason: "monerium_pending" | "oracle_unavailable" | "reference_unavailable" | "reference_out_of_band" | "no_route" | "below_floor"; + /** Format: date-time */ + since: string; + } | null; }; MoneriumB2bDepositsResponse: { deposits: components["schemas"]["MoneriumB2bDeposit"][]; @@ -6618,6 +6716,54 @@ export interface operations { }; }; }; + listMoneriumB2bAccounts: { + parameters: { + query?: { + /** @description Only the account of this EUR provider profile. */ + moneriumProfileId?: string; + /** @description Page size (default 20, max 100). */ + limit?: number; + /** @description Rows to skip (default 0). */ + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The manager's onramp accounts. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MoneriumB2bAccountsResponse"]; + }; + }; + /** @description Invalid `moneriumProfileId`, or a `X-Managed-Profile-Id` header was sent. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The caller is not an active manager for business EUR customers, or used a child's own credential. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listMoneriumB2bDeposits: { parameters: { query?: { diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 9d9fc8673..165699bf9 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2517,6 +2517,10 @@ "format": "date-time", "type": ["string", "null"] }, + "externalSubjectId": { + "description": "Your own client reference for this managed profile.", + "type": ["string", "null"] + }, "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" @@ -2529,6 +2533,14 @@ "description": "The account's dedicated IBAN; null until issuance completes.", "type": ["string", "null"] }, + "moneriumProfileId": { + "description": "The EUR provider's profile ID for this client.", + "type": "string" + }, + "profileId": { + "description": "The client's managed profile ID: the `X-Managed-Profile-Id` value for delegated calls.", + "type": ["string", "null"] + }, "status": { "enum": ["onboarding", "active", "suspended", "closed"], "type": "string" @@ -2543,9 +2555,12 @@ "createdAt", "destination", "dormantSince", + "externalSubjectId", "floorPpm", "forwarderAddress", "iban", + "moneriumProfileId", + "profileId", "status", "targetPpm" ], @@ -2560,8 +2575,42 @@ "required": ["account"], "type": "object" }, + "MoneriumB2bAccountsResponse": { + "properties": { + "accounts": { + "items": { + "$ref": "#/components/schemas/MoneriumB2bAccount" + }, + "type": "array" + }, + "pagination": { + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": ["limit", "offset", "total"], + "type": "object" + } + }, + "required": ["accounts", "pagination"], + "type": "object" + }, "MoneriumB2bDeposit": { "properties": { + "accountId": { + "type": "string" + }, + "amount": { + "description": "EUR amount to the cent, for example \"1234.56\".", + "type": "string" + }, "amountRaw": { "description": "Deposit amount in 18-decimal base units of the deposit currency.", "type": "string" @@ -2570,6 +2619,11 @@ "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": { + "confirmedAt": { + "description": "When the chunk's swap confirmed; null while pending.", + "format": "date-time", + "type": ["string", "null"] + }, "eureInRaw": { "description": "EURe of this deposit consumed by the chunk in 18-decimal base units.", "type": "string" @@ -2596,9 +2650,14 @@ "executionId": { "type": "string" }, + "sentAt": { + "description": "When the chunk's swap was sent.", + "format": "date-time", + "type": "string" + }, "status": { - "description": "Execution status.", - "enum": ["pending", "confirmed", "failed"], + "description": "Chunk status. Failed attempts are retried and not listed.", + "enum": ["pending", "confirmed"], "type": "string" }, "txHash": { @@ -2610,42 +2669,94 @@ "type": "string" } }, - "required": ["eureInRaw", "execution", "executionId", "status", "txHash", "usdcNetRaw"], + "required": ["confirmedAt", "eureInRaw", "execution", "executionId", "sentAt", "status", "txHash", "usdcNetRaw"], "type": "object" }, "type": "array" }, - "createdAt": { - "format": "date-time", - "type": "string" - }, "currency": { "type": "string" }, + "deliveredAt": { + "description": "When the single transfer to the destination confirmed; null until forwarded.", + "format": "date-time", + "type": ["string", "null"] + }, "depositId": { "type": "string" }, + "externalSubjectId": { + "description": "Your own client reference for this managed profile.", + "type": ["string", "null"] + }, "forwardTxHash": { "description": "The single transaction that delivered the whole converted deposit to the destination; null until the deposit is forwarded.", "type": ["string", "null"] }, + "mintedAt": { + "description": "When the EUR arrived on chain as EURe; the conversion window counts from here.", + "format": "date-time", + "type": ["string", "null"] + }, + "moneriumOrderId": { + "description": "The EUR provider's order ID for the incoming payment.", + "type": "string" + }, + "moneriumProfileId": { + "description": "The EUR provider's profile ID for this client.", + "type": "string" + }, + "profileId": { + "description": "The client's managed profile ID: the `X-Managed-Profile-Id` value for delegated calls.", + "type": "string" + }, + "receivedAt": { + "description": "When Vortex first saw the payment.", + "format": "date-time", + "type": "string" + }, "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.", + "description": "Present once the deposit entered the refund path: why, when it started, the EUR amount refunded, the masked IBAN it goes to, the provider's redemption 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.", + "description": "The EUR amount refunded, to the cent: always the full issue amount.", + "type": ["string", "null"] + }, + "payerIbanMasked": { + "description": "The payer's IBAN the refund goes to, masked to its first and last four characters.", + "type": ["string", "null"] + }, + "reason": { + "description": "Why the deposit is refunded: `window_missed` (not converted within the promised window), `compliance`, `incident`, or `operator` for another operator decision.", + "enum": ["window_missed", "compliance", "incident", "operator", null], "type": ["string", "null"] }, "recoverTxHash": { + "description": "The transaction that moved the deposit off the forwarding contract.", "type": ["string", "null"] }, "redeemOrderId": { + "description": "The provider's order ID for the outgoing SEPA refund.", + "type": ["string", "null"] + }, + "refundedAt": { + "description": "When the provider processed the refund; null until then.", + "format": "date-time", + "type": ["string", "null"] + }, + "startedAt": { + "description": "When the deposit entered the refund path.", + "format": "date-time", "type": ["string", "null"] } }, - "required": ["amount", "recoverTxHash", "redeemOrderId"], + "required": ["amount", "payerIbanMasked", "reason", "recoverTxHash", "redeemOrderId", "refundedAt", "startedAt"], "type": ["object", "null"] }, + "rejectedReason": { + "description": "The provider's reason when it returned the payment before minting.", + "type": ["string", "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": [ @@ -2668,19 +2779,51 @@ "usdcNetRaw": { "description": "Sum of the confirmed chunks' net USDC in 6-decimal base units: what the deposit's single transfer delivers once forwarded.", "type": "string" + }, + "waiting": { + "description": "Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. Afterwards, the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise.", + "properties": { + "reason": { + "enum": [ + "monerium_pending", + "oracle_unavailable", + "reference_unavailable", + "reference_out_of_band", + "no_route", + "below_floor" + ], + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + } + }, + "required": ["reason", "since"], + "type": ["object", "null"] } }, "required": [ + "accountId", + "amount", "amountRaw", "conversions", - "createdAt", "currency", + "deliveredAt", "depositId", + "externalSubjectId", "forwardTxHash", + "mintedAt", + "moneriumOrderId", + "moneriumProfileId", + "profileId", + "receivedAt", "refund", + "rejectedReason", "status", "txHash", - "usdcNetRaw" + "usdcNetRaw", + "waiting" ], "type": "object" }, @@ -8540,10 +8683,78 @@ "tags": ["Account Management"] } }, + "/v1/monerium-b2b/accounts": { + "get": { + "deprecated": false, + "description": "Available in sandbox; production activation is pending. Lists the business EUR onramp accounts of every active managed profile under the calling manager, newest first, each with its IBAN and status. Filter by `moneriumProfileId` to find one client by the EUR provider's profile ID. Manager credential only: the `X-Managed-Profile-Id` header is rejected with 400 and a child's own credential with 403.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", + "operationId": "listMoneriumB2bAccounts", + "parameters": [ + { + "description": "Only the account of this EUR provider profile.", + "in": "query", + "name": "moneriumProfileId", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Page size (default 20, max 100).", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Rows to skip (default 0).", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoneriumB2bAccountsResponse" + } + } + }, + "description": "The manager's onramp accounts." + }, + "400": { + "description": "Invalid `moneriumProfileId`, or a `X-Managed-Profile-Id` header was sent." + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "The caller is not an active manager for business EUR customers, or used a child's own credential." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "List the manager's EUR onramp accounts", + "tags": ["Account Management"] + } + }, "/v1/monerium-b2b/deposits": { "get": { "deprecated": false, - "description": "Available in sandbox; production activation is pending. Returns the acting profile's EUR deposits newest first, with every allocated conversion portion and aggregate attributed USDC. A per-swap cap can split one deposit across multiple executions. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", + "description": "Available in sandbox; production activation is pending. Returns the acting profile's EUR deposits newest first, each as its full lifecycle snapshot: IDs, amounts, timestamps, waiting and refund reasons, and every conversion chunk. A per-swap cap can split one deposit into several chunks, delivered together in one transfer. This is the polling surface; the `DEPOSIT_UPDATED` webhook pushes the same snapshot on every change. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", "operationId": "listMoneriumB2bDeposits", "parameters": [ { @@ -11081,7 +11292,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\", \"DEPOSIT_RETURNED\"] must be requested explicitly, cannot be mixed with transaction events, require a profile-scoped secret credential, and take no quoteId/sessionId. Deposit events are available in sandbox; production activation is pending.", + "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\", \"DEPOSIT_UPDATED\", \"ACCOUNT_UPDATED\"] must be requested explicitly, cannot be mixed with transaction events, require a profile-scoped secret credential, and take no quoteId/sessionId. Deposit events are available in sandbox; production activation is pending.", "type": "string" }, "type": "array" diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 8c786a789..c3d1ba433 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -111,11 +111,12 @@ Managers whose business clients hold EUR onramp accounts can subscribe to deposi - Register with your **manager profile's own secret key** (no `X-Managed-Profile-Id` header, no `quoteId`/`sessionId`) and an explicit `events` list containing only deposit events. Mixing them with transaction events is rejected, as is a partner-scoped credential. - One subscription covers **all your managed children's accounts**; the payload identifies the child by `profileId` and the account by `accountId`. +- For the full lifecycle, subscribe to `DEPOSIT_UPDATED` and `ACCOUNT_UPDATED`. The milestone events `DEPOSIT_RECEIVED`, `DEPOSIT_CONVERTED` and `DEPOSIT_RETURNED` remain for integrations that only need those three moments. ```json { "url": "https://manager.example.com/vortex/deposits", - "events": ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED"] + "events": ["DEPOSIT_UPDATED", "ACCOUNT_UPDATED"] } ``` @@ -185,7 +186,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, 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. +The nested `execution` object is the chunk's pricing: `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). The chunk's `usdcNetRaw` already includes both. ### `DEPOSIT_RETURNED` @@ -214,7 +215,86 @@ Fired once per deposit that could not be converted within the promised window (o } ``` -`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. +`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 the EUR provider's order for the outgoing SEPA transfer, and `recoverTxHash` the transaction that moved the deposit off the forwarding contract. + +### `DEPOSIT_UPDATED` + +Fired whenever anything about a deposit changes: the payment arrives at the EUR provider, EURe is minted, a conversion chunk is sent or confirmed, the deposit starts or stops waiting, the converted USDC is delivered, or the deposit enters and completes the refund path. The payload is the deposit's full snapshot, the same object `GET /v1/monerium-b2b/deposits` returns, so you can upsert it by `depositId` and never miss a stage. A deposit is reported as `forwarded` once its transfer reached the same safe confirmation depth as `DEPOSIT_CONVERTED`. + +```json +{ + "eventId": "deposit-updated:9f6f6a7e-...:3b1c9e0f2a7d4e61", + "eventType": "DEPOSIT_UPDATED", + "timestamp": "2025-01-15T10:36:20.000Z", + "payload": { + "depositId": "9f6f6a7e-...", + "accountId": "c2a5...", + "profileId": "7d1b...", + "externalSubjectId": "client-1", + "moneriumProfileId": "0b8e...", + "moneriumOrderId": "5a2c...", + "status": "converting", + "currency": "eur", + "amount": "15000.00", + "amountRaw": "15000000000000000000000", + "txHash": "0x...", + "receivedAt": "2025-01-15T10:34:58.000Z", + "mintedAt": "2025-01-15T10:35:00.000Z", + "waiting": { "reason": "below_floor", "since": "2025-01-15T10:36:20.000Z" }, + "rejectedReason": null, + "conversions": [ + { + "executionId": "e77a...", + "status": "confirmed", + "eureInRaw": "10000000000000000000000", + "execution": { "feeRaw": "81000", "referenceRateRaw": "108140000", "subsidyRaw": "0" }, + "usdcNetRaw": "10800000000", + "txHash": "0x...", + "sentAt": "2025-01-15T10:35:20.000Z", + "confirmedAt": "2025-01-15T10:35:44.000Z" + } + ], + "usdcNetRaw": "10800000000", + "forwardTxHash": null, + "deliveredAt": null, + "refund": null + } +} +``` + +- **IDs:** `depositId`, `accountId`, `profileId` (your managed child, the `X-Managed-Profile-Id` value), `externalSubjectId` (your own client reference), and `moneriumProfileId` and `moneriumOrderId` (the EUR provider's IDs for the client and for the incoming payment). Every transaction hash is included: the mint (`txHash`), each chunk, the forward, and on a refund the recovery transaction and the provider's redemption order. +- **Amounts:** `amount` is the EUR amount to the cent and `amountRaw` the same in 18-decimal base units. Each chunk carries the EURe it converted and its net USDC; the top-level `usdcNetRaw` is the sum of the confirmed chunks, which the single transfer delivers. +- **Timestamps:** `receivedAt`, `mintedAt` (the conversion window counts from here), each chunk's `sentAt` and `confirmedAt`, `deliveredAt`, and on a refund `refund.startedAt` and `refund.refundedAt`. +- **Hold status:** `waiting` is set while the deposit waits. `monerium_pending` means the EUR provider has not minted it yet: it is minting or under a compliance review, which the provider does not tell apart. After the mint, the reason Vortex is holding the next chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor`, when the market is below the client's floor by more than the subsidy currently allows. +- **Failure status:** `rejectedReason` holds the provider's reason when it returned the payment before minting (`status` is then `returned`). `refund.reason` says why a deposit is refunded: `window_missed`, `compliance`, `incident`, or `operator`. + +`eventId` is unique per snapshot. If retries deliver an older snapshot after a newer one, keep the one with the later `timestamp`. + +### `ACCOUNT_UPDATED` + +Fired whenever an onramp account's snapshot changes: when it is set up, when its IBAN is issued, and when its status or dormancy changes. Use it to learn when a client's IBAN is ready. The payload is the account object returned by `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/accounts`. + +```json +{ + "eventId": "account-updated:c2a5...:9d04b7e2c1aa5f30", + "eventType": "ACCOUNT_UPDATED", + "timestamp": "2025-01-14T09:12:00.000Z", + "payload": { + "accountId": "c2a5...", + "profileId": "7d1b...", + "externalSubjectId": "client-1", + "moneriumProfileId": "0b8e...", + "status": "active", + "iban": "EE12 3456 7890 1234 5678", + "destination": "0x...", + "forwarderAddress": "0x...", + "targetPpm": 1250, + "floorPpm": 1500, + "dormantSince": null, + "createdAt": "2025-01-14T09:00:00.000Z" + } +} +``` ### Delivery Semantics diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 91f630f4f..ebbcb5dca 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). Automated EUR onboarding and provider binding are not available for managed children. A non-technical child that operations has already provisioned with an approved EUR provider binding, Polygon EOA, and IBAN may use the direct-API EUR BUY flow when the manager policy allows that corridor. The `EU` corridor also 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). EUR, including this business account surface, is available in sandbox; production activation is pending. +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). Automated EUR onboarding and provider binding are not available for managed children. A non-technical child that operations has already provisioned with an approved EUR provider binding, Polygon EOA, and IBAN may use the direct-API EUR BUY flow when the manager policy allows that corridor. The `EU` corridor also 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 IBAN, status and fee policy (`targetPpm` and `floorPpm`, parts per million below the reference rate), and each deposit is returned as its full lifecycle snapshot, the same object the [`DEPOSIT_UPDATED` webhook](https://api-docs.vortexfinance.co/webhooks) pushes. To read every child's account in one call, use `GET /v1/monerium-b2b/accounts` with your manager key and no `X-Managed-Profile-Id` header; filter by `moneriumProfileId` to find one client by the EUR provider's profile ID. EUR, including this business account surface, is available in sandbox; production activation is pending. ## Create A Managed Child diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 5a9b51f52..5d2af65c1 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, WebhookEventType.DEPOSIT_RETURNED] +ACCOUNT_WEBHOOK_EVENT_TYPES: readonly [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED, WebhookEventType.DEPOSIT_RETURNED, WebhookEventType.DEPOSIT_UPDATED, WebhookEventType.ACCOUNT_UPDATED] AcceptedRecipientInvite: { id: string; @@ -31,6 +31,41 @@ AccountMeta: { type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; } +AccountSnapshot: { + accountId: string; + createdAt: string; + destination: string; + dormantSince: null | string; + externalSubjectId: null | string; + floorPpm: number; + forwarderAddress: string; + iban: null | string; + moneriumProfileId: string; + profileId: null | string; + status: string; + targetPpm: number; +} + +AccountUpdatedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.ACCOUNT_UPDATED; + payload: { + accountId: string; + createdAt: string; + destination: string; + dormantSince: null | string; + externalSubjectId: null | string; + floorPpm: number; + forwarderAddress: string; + iban: null | string; + moneriumProfileId: string; + profileId: null | string; + status: string; + targetPpm: number; + }; + timestamp: string; +} + AlchemyPayPriceResponse: { direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; provider: "alchemypay"; @@ -439,6 +474,21 @@ DeleteWebhookResponse: { success: boolean; } +DepositConversionSnapshot: { + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; +} + DepositConvertedWebhookPayload: { eventId: string; eventType: WebhookEventType.DEPOSIT_CONVERTED; @@ -483,6 +533,8 @@ DepositReceivedWebhookPayload: { timestamp: string; } +DepositRefundReason: "compliance" | "incident" | "operator" | "window_missed" + DepositReturnedWebhookPayload: { eventId: string; eventType: WebhookEventType.DEPOSIT_RETURNED; @@ -505,8 +557,109 @@ DepositReturnedWebhookPayload: { timestamp: string; } +DepositSnapshot: { + accountId: string; + amount: string; + amountRaw: string; + conversions: Array<{ + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; + }>; + currency: string; + deliveredAt: null | string; + depositId: string; + externalSubjectId: null | string; + forwardTxHash: null | string; + mintedAt: null | string; + moneriumOrderId: string; + moneriumProfileId: string; + profileId: string; + receivedAt: string; + refund: null | { + amount: null | string; + payerIbanMasked: null | string; + reason: "compliance" | "incident" | "operator" | "window_missed" | null; + recoverTxHash: null | string; + redeemOrderId: null | string; + refundedAt: null | string; + startedAt: null | string; + }; + rejectedReason: null | 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; + usdcNetRaw: string; + waiting: null | { + reason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + since: string; + }; +} + DepositStatus: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" } +DepositUpdatedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_UPDATED; + payload: { + accountId: string; + amount: string; + amountRaw: string; + conversions: Array<{ + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; + }>; + currency: string; + deliveredAt: null | string; + depositId: string; + externalSubjectId: null | string; + forwardTxHash: null | string; + mintedAt: null | string; + moneriumOrderId: string; + moneriumProfileId: string; + profileId: string; + receivedAt: string; + refund: null | { + amount: null | string; + payerIbanMasked: null | string; + reason: "compliance" | "incident" | "operator" | "window_missed" | null; + recoverTxHash: null | string; + redeemOrderId: null | string; + refundedAt: null | string; + startedAt: null | string; + }; + rejectedReason: null | 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; + usdcNetRaw: string; + waiting: null | { + reason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + since: string; + }; + }; + timestamp: string; +} + +DepositWaitingReason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable" + DepositWebhookPayloadBase: { accountId: string; amountRaw: string; @@ -1697,7 +1850,7 @@ RegisterRampResponse: { } RegisterWebhookRequest: { - events?: Array; + events?: Array; quoteId?: string; sessionId?: string; url: string; @@ -1705,7 +1858,7 @@ RegisterWebhookRequest: { RegisterWebhookResponse: { createdAt: string; - events: Array; + events: Array; id: string; isActive: boolean; quoteId: null | string; @@ -2457,6 +2610,24 @@ WebhookDeliveryAttempt: { maxAttempts: number; nextRetryAt?: Date; payload: { + eventId: string; + eventType: WebhookEventType.ACCOUNT_UPDATED; + payload: { + accountId: string; + createdAt: string; + destination: string; + dormantSince: null | string; + externalSubjectId: null | string; + floorPpm: number; + forwarderAddress: string; + iban: null | string; + moneriumProfileId: string; + profileId: null | string; + status: string; + targetPpm: number; + }; + timestamp: string; + } | { eventId: string; eventType: WebhookEventType.DEPOSIT_CONVERTED; payload: { @@ -2516,6 +2687,56 @@ WebhookDeliveryAttempt: { }; }; timestamp: string; + } | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_UPDATED; + payload: { + accountId: string; + amount: string; + amountRaw: string; + conversions: Array<{ + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; + }>; + currency: string; + deliveredAt: null | string; + depositId: string; + externalSubjectId: null | string; + forwardTxHash: null | string; + mintedAt: null | string; + moneriumOrderId: string; + moneriumProfileId: string; + profileId: string; + receivedAt: string; + refund: null | { + amount: null | string; + payerIbanMasked: null | string; + reason: "compliance" | "incident" | "operator" | "window_missed" | null; + recoverTxHash: null | string; + redeemOrderId: null | string; + refundedAt: null | string; + startedAt: null | string; + }; + rejectedReason: null | 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; + usdcNetRaw: string; + waiting: null | { + reason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + since: string; + }; + }; + timestamp: string; } | { eventId: string; eventType: WebhookEventType.STATUS_CHANGE; @@ -2543,9 +2764,27 @@ WebhookDeliveryAttempt: { webhookId: string; } -WebhookEventType: enum WebhookEventType { DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", DEPOSIT_RETURNED = "DEPOSIT_RETURNED", STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } +WebhookEventType: enum WebhookEventType { ACCOUNT_UPDATED = "ACCOUNT_UPDATED", DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", DEPOSIT_RETURNED = "DEPOSIT_RETURNED", DEPOSIT_UPDATED = "DEPOSIT_UPDATED", STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } WebhookPayload: { + eventId: string; + eventType: WebhookEventType.ACCOUNT_UPDATED; + payload: { + accountId: string; + createdAt: string; + destination: string; + dormantSince: null | string; + externalSubjectId: null | string; + floorPpm: number; + forwarderAddress: string; + iban: null | string; + moneriumProfileId: string; + profileId: null | string; + status: string; + targetPpm: number; + }; + timestamp: string; +} | { eventId: string; eventType: WebhookEventType.DEPOSIT_CONVERTED; payload: { @@ -2605,6 +2844,56 @@ WebhookPayload: { }; }; timestamp: string; +} | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_UPDATED; + payload: { + accountId: string; + amount: string; + amountRaw: string; + conversions: Array<{ + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; + }>; + currency: string; + deliveredAt: null | string; + depositId: string; + externalSubjectId: null | string; + forwardTxHash: null | string; + mintedAt: null | string; + moneriumOrderId: string; + moneriumProfileId: string; + profileId: string; + receivedAt: string; + refund: null | { + amount: null | string; + payerIbanMasked: null | string; + reason: "compliance" | "incident" | "operator" | "window_missed" | null; + recoverTxHash: null | string; + redeemOrderId: null | string; + refundedAt: null | string; + startedAt: null | string; + }; + rejectedReason: null | 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; + usdcNetRaw: string; + waiting: null | { + reason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + since: string; + }; + }; + timestamp: string; } | { eventId: string; eventType: WebhookEventType.STATUS_CHANGE; diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 5f3361fff..ff375e144 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -27,8 +27,8 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 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_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`). +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. The one manager-level read, `GET /v1/monerium-b2b/accounts`, takes the manager's own credential only (the `X-Managed-Profile-Id` header is rejected with 400 and a child credential with 403), requires an active manager allowed the EU corridor and business customers, and lists only accounts whose managed profile is an active child of that manager; its optional `moneriumProfileId` filter narrows within that set and never widens it (`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. `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. `DEPOSIT_UPDATED` and `ACCOUNT_UPDATED` send the full deposit or account snapshot (the read-surface shape) whenever its SHA-256 differs from the last one sent, with an `eventId` that embeds the hash so outbox dedup absorbs a crash between enqueue and marker; a `forwarded` deposit is reported only under the same confirmation-depth gate as `DEPOSIT_CONVERTED`, and snapshots carry the payer IBAN only masked. 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 diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index e91ced25c..b8deb54cf 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -5,7 +5,11 @@ export enum WebhookEventType { STATUS_CHANGE = "STATUS_CHANGE", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", - DEPOSIT_RETURNED = "DEPOSIT_RETURNED" + DEPOSIT_RETURNED = "DEPOSIT_RETURNED", + /** Every change of a deposit: the full snapshot, as returned by the deposits endpoint. */ + DEPOSIT_UPDATED = "DEPOSIT_UPDATED", + /** Every change of an onramp account (IBAN issued, status): the full account snapshot. */ + ACCOUNT_UPDATED = "ACCOUNT_UPDATED" } /** @@ -17,7 +21,9 @@ export enum WebhookEventType { export const ACCOUNT_WEBHOOK_EVENT_TYPES = [ WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED, - WebhookEventType.DEPOSIT_RETURNED + WebhookEventType.DEPOSIT_RETURNED, + WebhookEventType.DEPOSIT_UPDATED, + WebhookEventType.ACCOUNT_UPDATED ] as const; export enum DepositStatus { @@ -120,17 +126,17 @@ export interface DepositReceivedWebhookPayload { } /** - * 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`. + * How one conversion chunk was priced (docs/architecture-monerium-b2b-onramp.md, fees + * section): the reference it was settled against, the fee Vortex took above the target, + * and the subsidy the vault paid to reach the floor. Every chunk belongs to exactly one + * deposit. */ export interface ConversionExecutionPricing { /** Fee taken on the execution (6-decimal base units). */ feeRaw: string | null; /** 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). */ + /** Subsidy the vault paid onto the forwarder for this chunk (6-decimal base units). */ subsidyRaw: string | null; } @@ -144,12 +150,12 @@ 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. */ + /** How this chunk was priced. */ execution: ConversionExecutionPricing; executionId: string; - /** The swap-and-forward transaction. */ + /** The chunk's swap transaction. */ txHash: string | null; - /** Net USDC from this execution attributed to this deposit (6-decimal base units). */ + /** Net USDC from this chunk after fee and subsidy (6-decimal base units). */ usdcNetRaw: string; }>; /** The single transaction that pushed the whole converted deposit to the destination. */ @@ -179,12 +185,122 @@ export interface DepositReturnedWebhookPayload { }; } +/** + * Why a deposit is waiting: `monerium_pending` until Monerium mints it (minting or a + * compliance review, which Monerium does not tell apart), otherwise the reason the + * keeper is holding the next conversion chunk. + */ +export type DepositWaitingReason = + | "monerium_pending" + | "oracle_unavailable" + | "reference_unavailable" + | "reference_out_of_band" + | "no_route" + | "below_floor"; + +/** Why a deposit entered the refund path. */ +export type DepositRefundReason = "window_missed" | "compliance" | "incident" | "operator"; + +/** One conversion chunk of a deposit. Timestamps are ISO 8601. */ +export interface DepositConversionSnapshot { + executionId: string; + status: "pending" | "confirmed"; + /** EURe converted by this chunk (18-decimal base units). */ + eureInRaw: string; + execution: ConversionExecutionPricing; + /** Net USDC from this chunk after fee and subsidy (6-decimal base units). */ + usdcNetRaw: string; + txHash: string | null; + sentAt: string; + confirmedAt: string | null; +} + +/** The full state of one deposit, as sent by DEPOSIT_UPDATED and returned by the deposits endpoint. */ +export interface DepositSnapshot { + depositId: string; + accountId: string; + /** The client's Vortex managed profile, the `X-Managed-Profile-Id` value. */ + profileId: string; + moneriumProfileId: string; + moneriumOrderId: string; + /** The partner's own client reference for this managed profile. */ + externalSubjectId: string | null; + status: DepositStatus; + currency: string; + /** EUR amount to the cent, for example "1234.56". */ + amount: string; + /** EUR amount in 18-decimal base units. */ + amountRaw: string; + /** The on-chain mint transaction, when observed. */ + txHash: string | null; + /** When Vortex first saw the payment. */ + receivedAt: string; + mintedAt: string | null; + /** Present while the deposit waits, before the mint or between conversion chunks. */ + waiting: { reason: DepositWaitingReason; since: string } | null; + /** Monerium's reason when it returned the payment before minting. */ + rejectedReason: string | null; + conversions: DepositConversionSnapshot[]; + /** Net USDC of the confirmed chunks (6-decimal base units). */ + usdcNetRaw: string; + /** The single transfer of the whole converted deposit to the destination. */ + forwardTxHash: string | null; + deliveredAt: string | null; + /** Present once the deposit entered the refund path. */ + refund: { + reason: DepositRefundReason | null; + /** The EUR amount refunded, to the cent: always the full issue amount. */ + amount: string | null; + payerIbanMasked: string | null; + recoverTxHash: string | null; + redeemOrderId: string | null; + startedAt: string | null; + refundedAt: string | null; + } | null; +} + +/** The state of one onramp account, as sent by ACCOUNT_UPDATED and returned by the account endpoints. */ +export interface AccountSnapshot { + accountId: string; + /** The client's Vortex managed profile, the `X-Managed-Profile-Id` value. */ + profileId: string | null; + moneriumProfileId: string; + externalSubjectId: string | null; + status: string; + /** Null until Monerium issued it. */ + iban: string | null; + destination: string; + forwarderAddress: string; + targetPpm: number; + floorPpm: number; + dormantSince: string | null; + createdAt: string; +} + +export interface DepositUpdatedWebhookPayload { + /** Unique per snapshot and stable across delivery retries: consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.DEPOSIT_UPDATED; + timestamp: string; + payload: DepositSnapshot; +} + +export interface AccountUpdatedWebhookPayload { + /** Unique per snapshot and stable across delivery retries: consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.ACCOUNT_UPDATED; + timestamp: string; + payload: AccountSnapshot; +} + export type WebhookPayload = | TransactionCreatedWebhookPayload | StatusChangeWebhookPayload | DepositReceivedWebhookPayload | DepositConvertedWebhookPayload - | DepositReturnedWebhookPayload; + | DepositReturnedWebhookPayload + | DepositUpdatedWebhookPayload + | AccountUpdatedWebhookPayload; export interface WebhookDeliveryAttempt { webhookId: string; From dd5bfc8e704b0a6e22f962599ae5b955e5afda64 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 09:25:10 +0200 Subject: [PATCH 53/74] docs(repo): mark the B2B lifecycle events and account list as built The flow overview and architecture map now show how each deposit stage reaches SulPayments and what is still left out. --- docs/architecture-monerium-b2b-onramp.md | 2 +- docs/product-monerium-b2b-flow.md | 100 +++++++++-------------- 2 files changed, 38 insertions(+), 64 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 7aa8507b6..525d98780 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 / CONVERTED / RETURNED" --> PAPI + OUTBOX -- "DEPOSIT_UPDATED / ACCOUNT_UPDATED\nand RECEIVED / CONVERTED / RETURNED" --> PAPI PAPI -- "poll (delegation)" --> READ ``` diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index efffb8201..c717791e3 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -459,86 +459,60 @@ Monerium does not report a separate compliance-review state, so a payment under shows as pending. The `held` status in the API is therefore never set and will be removed **[V11]**. -### 9.2 What SulPayments receives today +### 9.2 What SulPayments receives | Event | When | Key content | |---|---|---| -| `DEPOSIT_RECEIVED` | EURe minted to the client's forwarder | Deposit ID, account, amount, mint transaction | -| `DEPOSIT_CONVERTED` | The USDC transfer is 32 blocks deep | Per chunk: reference rate, fee, subsidy. The forward transaction hash | -| `DEPOSIT_RETURNED` | The refund was processed by Monerium | Refunded amount, masked payer IBAN, Monerium redemption ID, recovery transaction | +| `DEPOSIT_UPDATED` | Any change of a deposit | The full deposit snapshot: IDs, amounts, timestamps, waiting and refund reasons, every conversion chunk | +| `ACCOUNT_UPDATED` | Any change of an account, including the IBAN being issued | The account snapshot: IBAN, status, IDs, destination, fee policy | +| `DEPOSIT_RECEIVED` | EURe minted to the client's forwarder | Milestone: deposit ID, account, amount, mint transaction | +| `DEPOSIT_CONVERTED` | The USDC transfer is 32 blocks deep | Milestone: per chunk reference rate, fee and subsidy, and the forward transaction | +| `DEPOSIT_RETURNED` | The refund was processed by Monerium | Milestone: refunded amount, masked payer IBAN, redemption ID, recovery transaction | | API call | Returns | |---|---| -| Account, per client | IBAN, account status, destination, forwarder address, fee policy | -| Deposits, per client | Every deposit with status, amount and mint transaction, each conversion chunk with its pricing and transaction, the forward transaction, and the refund once started | +| Accounts, all clients of the manager | Each client's account with IBAN and status, filterable by Monerium profile ID | +| Account, per client | IBAN, status, IDs, destination, forwarder address, fee policy | +| Deposits, per client | Every deposit as its full snapshot, the same shape as `DEPOSIT_UPDATED` | -- SulPayments calls both with its manager API key plus a header naming the client's - Vortex profile ID, or with a key issued to the client itself. +- The accounts call uses SulPayments' manager API key alone. The per-client calls add a + header naming the client's Vortex profile ID, or use a key issued to the client. - Webhooks are signed. SulPayments verifies each one against Vortex's published public key and deduplicates on the event ID. -- **Fallback:** the deposits call returns the current status of every deposit, for +- **Fallback:** the deposits call returns the current snapshot of every deposit, for polling if a webhook is missed. -- **Reference IDs** in every event today: the deposit ID, the account ID, the client's - Vortex profile ID, the relevant transaction hash and, on a refund, Monerium's - redemption ID. Vortex stores Monerium's order ID and SulPayments' client reference per - deposit but does not send them yet **[V4]**. -- Amounts are in base units: 18 decimals for EUR and EURe, 6 for USDC. +- **Reference IDs** in every snapshot: deposit, account, Vortex profile, Monerium + profile, Monerium order, SulPayments' client reference, and every transaction hash. +- Amounts come as a EUR decimal and in base units: 18 decimals for EUR and EURe, 6 for + USDC. ### 9.3 What SulPayments asked for -SulPayments' requirements of 2026-09-30: +SulPayments' requirements of 2026-09-30, built on 2026-10-01: - The API and webhooks expose the **full lifecycle**, from deposit through conversion to delivery, including IDs, amounts, timestamps, and hold or failure status. - **API-first:** SulPayments' frontend fetches each sub-account's IBAN from the Vortex backend. -Gaps against what is built: +How each stage is reported: -| Stage | Webhook today | API today | Gap | -|---|---|---|---| -| Payment arrived at Monerium, not minted yet | None | Status pending | No event. Monerium cannot say whether it is minting or under review | -| Rejected by Monerium before minting | None | Status returned | No event, no reason | -| EURe minted to the forwarder | `DEPOSIT_RECEIVED` | Status minted | No mint timestamp, Monerium order ID, payer or payment reference | -| Conversion chunk executed | None | Chunk with pricing and transaction | No event, no timestamp per chunk | -| Conversion waiting on the market | None | None | No waiting status or reason | -| Delivered as one USDC transfer | `DEPOSIT_CONVERTED` | Status forwarded | No delivery timestamp | -| Refund started | None | Status recovering | No event, no reason | -| Refunded | `DEPOSIT_RETURNED` | Status refunded | No reason, no timestamp | -| Refund needs an operator | None | Status recovery failed | No event | -| Account active with its IBAN | None | Account call | No event. Lookup only by Vortex profile ID, no list of all sub-accounts | - -**Proposed** to close the gaps **[V4, V12]**: - -- **One snapshot event.** Every deposit status change and every confirmed chunk sends a - `DEPOSIT_UPDATED` event carrying the full deposit, in the same shape as the deposits - call. SulPayments upserts one object and cannot miss a stage. The three milestone - events can stay or be dropped, since nothing is live yet. -- **IDs:** deposit, account, Vortex profile, Monerium profile, Monerium order, - SulPayments' client reference, each conversion, and every transaction hash: mint, - swap, forward, recovery, and the refund's redemption order. -- **Amounts:** the EUR amount as a decimal and in base units; per chunk the EURe in, USDC - gross, fee, subsidy, net and reference rate; the USDC delivered; the refund amount. -- **Timestamps:** received at Monerium, minted, each chunk executed, delivered, refund - started, refunded. -- **Hold status.** A waiting block with a start time and a reason while a payment waits: - pending at Monerium, market below the floor beyond the subsidy, spread too wide, - reference out of band, or Coinbase unavailable. Monerium does not tell review apart - from minting, so "pending at Monerium" is the only hold Vortex can report before the - mint. -- **Failure status.** A reason code on every refund: window missed, below minimum, - compliance or incident. Monerium's own reason when it rejects a payment. An event when - a refund needs an operator. -- **Account event.** `ACCOUNT_UPDATED` when the IBAN is issued and when the account - becomes active, suspended or closed. -- **IBAN through the API.** The account call stays the source of the IBAN. The - destination endpoint returns the Vortex profile ID, or the account call accepts the - Monerium profile ID, and a list call returns all sub-accounts with IBAN and status. - SulPayments could also read the IBAN from Monerium with its app credentials, but only - Vortex knows when the account is ready. -- **Docs fix.** Some partner-facing field descriptions still describe the older design: - the subsidy now goes to the forwarder, not the destination, and each conversion - belongs to exactly one deposit. +| Stage | Status | Reported through | +|---|---|---| +| Payment arrived at Monerium, not minted yet | pending | `DEPOSIT_UPDATED` with waiting reason `monerium_pending`. Monerium does not tell minting apart from a compliance review | +| Returned by Monerium before minting | returned | `DEPOSIT_UPDATED` with Monerium's rejection reason | +| EURe minted to the forwarder | minted | `DEPOSIT_UPDATED` with the mint time and transaction, plus `DEPOSIT_RECEIVED` | +| Conversion chunk sent and confirmed | converting | `DEPOSIT_UPDATED` with each chunk's pricing, transaction, and sent and confirmed times | +| Conversion waiting on the market | minted or converting | `DEPOSIT_UPDATED` with waiting reason and start time: `below_floor`, `reference_unavailable`, `reference_out_of_band`, `oracle_unavailable` or `no_route` | +| Delivered as one USDC transfer | forwarded | `DEPOSIT_UPDATED` with delivery time and transaction once 32 blocks deep, plus `DEPOSIT_CONVERTED` | +| Refund started | recovering | `DEPOSIT_UPDATED` with refund reason and start time: `window_missed`, `compliance`, `incident` or `operator` | +| Refunded | refunded | `DEPOSIT_UPDATED` with refund time, plus `DEPOSIT_RETURNED` | +| Refund needs an operator | recovery failed | `DEPOSIT_UPDATED` | +| Account set up, IBAN issued, status changed | | `ACCOUNT_UPDATED`, and the accounts call | + +Not included yet: the incoming payment's SEPA reference and the payer's name, and a +separate refund reason for a remainder below the minimum swap, which is reported as +`window_missed`. Add them when SulPayments needs them. ### 9.4 Exceptions and escalation @@ -646,7 +620,7 @@ Gaps against what is built: | S4 | How does SulPayments get each client's IBAN? | API and dashboard scope | Answered 2026-09-30 | API-first: SulPayments' frontend fetches the IBAN from the Vortex API, section 9.3. | | S5 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target. Monerium allows third-party payers | Open | | | S6 | Are destinations self-custody wallets or exchange deposit addresses? | Exchange addresses need an attestation that they do not rotate and accept contract transfers | Open | | -| S7 | Does the proposed event model in section 9.3 cover the lifecycle requirement? Webhook endpoint and support contacts. | Status reporting | Requirement received 2026-09-30, proposal to confirm | SulPayments wants the full lifecycle with IDs, amounts, timestamps, and hold and failure status. | +| S7 | Does the event model in section 9.3 cover the lifecycle requirement? Webhook endpoint and support contacts. | Status reporting | Built 2026-10-01, to confirm with SulPayments | SulPayments wants the full lifecycle with IDs, amounts, timestamps, and hold and failure status. | | S8 | Is a two-hour window before a full refund right? Will clients authorize Vortex to refund from their IBAN? A Monerium review can delay a refund within business hours. | Refund terms in the agreement | Open | | | S9 | The agreement names a "Coinbase EURC oracle". The implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint. Is that what was meant? | Pricing terms | Open | | | S10 | Who bears Monerium's €10 per corporate account? | Commercial | Open | | @@ -658,7 +632,7 @@ Gaps against what is built: | V1 | Start onboarding once the profile is approved and the destination is registered, whichever comes last. | V6, V8 | Open | | V2 | Refunds through per-client refund addresses: one refund key per client derived from one seed, linked at onboarding, one extra transfer per refund. No contract change. | M1 | Agreed for the pilot, not built | | V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Optional, since SulPayments integrates API-first. | S4 | Deprioritized | -| V4 | Full lifecycle deposit events, section 9.3: snapshot event on every change, IDs, amounts, timestamps, hold and failure reasons, an account event, and the docs fix. | S7 | Proposed | +| V4 | Full lifecycle deposit events, section 9.3: snapshot event on every change, IDs, amounts, timestamps, hold and failure reasons, an account event, and the docs fix. | S7 | Built 2026-10-01 | | V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium, including the joint Slack channel with Monerium. | Meeting | Open | | V6 | Endpoint for SulPayments to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Proposed | | V7 | Read the IBAN and the payer's IBAN back from Monerium's API instead of trusting webhook payloads, and check in the sandbox whether listing subscriptions exposes their secrets. | None | Open | @@ -667,7 +641,7 @@ Gaps against what is built: | V10 | Attach the standing agreement to refunds above €15,000 so they can run automatically. | V2 | Open | | V11 | Remove the unused `held` status. | None | Open | | V13 | Later: a fixed refund IBAN per client, given at onboarding, replacing the per-client refund addresses. | V2 | Later | -| V12 | Partner account API: return the Vortex profile ID from the destination endpoint or accept the Monerium profile ID, and list all sub-accounts with IBAN and status. | S4, V6 | Proposed | +| V12 | Partner account API: list all sub-accounts with IBAN and status, filterable by Monerium profile ID. | S4 | Built 2026-10-01 | ## 13. Related documents From 9ec0b913797b5b8cc17795a8190309d355e8cf22 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 12:04:25 +0200 Subject: [PATCH 54/74] feat(repo): fix each forwarder's recovery address at deployment Refunds now run from a wallet per client linked to that client's Monerium profile, so the single implementation-wide RECOVERY_WALLET becomes a per-clone recoveryAddress set by deployForwarder, with no setter and rejected when it equals the destination or a protected address. --- contracts/monerium-forwarder/README.md | 4 +- .../script/manifest-core.ts | 23 ++++--- .../script/verify-manifest.test.ts | 1 + .../src/VortexForwarder.sol | 38 ++++++------ .../src/VortexForwarderFactory.sol | 23 ++++--- .../test/VortexForwarder.fork.t.sol | 3 +- .../test/VortexForwarder.invariants.t.sol | 5 +- .../test/VortexForwarder.t.sol | 62 +++++++++++++------ 8 files changed, 95 insertions(+), 64 deletions(-) diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index 53b1bfc77..66975b7b7 100644 --- a/contracts/monerium-forwarder/README.md +++ b/contracts/monerium-forwarder/README.md @@ -6,8 +6,8 @@ Monerium link message from the Vortex attestor, a conversion policy that swaps o 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), 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. +clone and leave in one `forward`), and a keeper-only, delay-gated `recover` to the client's refund +wallet, fixed per clone at deployment (`recoveryAddress`), 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 diff --git a/contracts/monerium-forwarder/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts index a66d6f99f..cd1b43717 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 = 4; +export const MANIFEST_VERSION = 5; export const MANIFEST_PURPOSE = "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). " + @@ -37,11 +37,12 @@ export const factoryAbi = parseAbi([ ]); export const forwarderDeployedEvent = parseAbiItem( - "event ForwarderDeployed(address indexed forwarder, address indexed destination, uint32 targetPpm, uint32 floorPpm, bytes32 salt)" + "event ForwarderDeployed(address indexed forwarder, address indexed destination, address recoveryAddress, uint32 targetPpm, uint32 floorPpm, bytes32 salt)" ); export const forwarderConfigAbi = parseAbi([ "function destination() view returns (address)", + "function recoveryAddress() view returns (address)", "function targetPpm() view returns (uint32)", "function floorPpm() view returns (uint32)" ]); @@ -56,7 +57,6 @@ 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)", @@ -86,7 +86,6 @@ export interface ImplementationImmutables { ORACLE_DECIMALS: number; RECOVERY_DELAY: string; RECOVERY_HASH: Hex; - RECOVERY_WALLET: string; ROUTER: string; SLIPPAGE_BPS: number; TRIGGER_DELAY: string; @@ -106,13 +105,14 @@ export interface ForwarderManifestEntry { targetPpm: number; }; /** - * 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. + * Fixed for the lifetime of the clone: the destination and the client's refund wallet + * (`recoveryAddress`) have no setter (a change means a new clone, runbook §5) and + * factory registration never changes. Mismatch = incident. */ immutables: { destination: string; isForwarder: boolean; + recoveryAddress: string; }; /** keccak256 of the clone's runtime code; must equal the EIP-1167 code for `implementation.address`. */ runtimeBytecodeHash: Hex; @@ -332,7 +332,6 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres slippageBps, maxFeePpm, maxReferenceDeviationBps, - recoveryWallet, recoveryDelay, triggerDelay, linkHash191, @@ -352,7 +351,6 @@ 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, "RECOVERY_WALLET"), read(client, implementationAbi, implementation, "RECOVERY_DELAY"), read(client, implementationAbi, implementation, "TRIGGER_DELAY"), read(client, implementationAbi, implementation, "LINK_HASH_191"), @@ -396,7 +394,6 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres ORACLE_DECIMALS: Number(oracleDecimals), RECOVERY_DELAY: recoveryDelay.toString(), RECOVERY_HASH: recoveryHash, - RECOVERY_WALLET: getAddress(recoveryWallet), ROUTER: getAddress(router), SLIPPAGE_BPS: Number(slippageBps), TRIGGER_DELAY: triggerDelay.toString(), @@ -416,8 +413,9 @@ export async function readForwarderEntry( ): Promise { const factory = getAddress(factoryAddress); const forwarder = getAddress(forwarderAddress); - const [destination, targetPpm, floorPpm, isForwarder, forwarderCodeHash] = await Promise.all([ + const [destination, recoveryAddress, targetPpm, floorPpm, isForwarder, forwarderCodeHash] = await Promise.all([ read
(client, forwarderConfigAbi, forwarder, "destination"), + read
(client, forwarderConfigAbi, forwarder, "recoveryAddress"), read(client, forwarderConfigAbi, forwarder, "targetPpm"), read(client, forwarderConfigAbi, forwarder, "floorPpm"), read(client, factoryAbi, factory, "isForwarder", [forwarder]), @@ -436,7 +434,8 @@ export async function readForwarderEntry( }, immutables: { destination: getAddress(destination), - isForwarder + isForwarder, + recoveryAddress: getAddress(recoveryAddress) }, runtimeBytecodeHash: forwarderCodeHash }; diff --git a/contracts/monerium-forwarder/script/verify-manifest.test.ts b/contracts/monerium-forwarder/script/verify-manifest.test.ts index cfde5a441..6d354c8b3 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.test.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.test.ts @@ -11,6 +11,7 @@ describe("manifest diff severity", () => { it("treats the per-clone destination and registration as immutable", () => { expect(severityFor("forwarders.0x123.immutables.destination")).toBe("FAIL"); + expect(severityFor("forwarders.0x123.immutables.recoveryAddress")).toBe("FAIL"); expect(severityFor("forwarders.0x123.immutables.isForwarder")).toBe("FAIL"); expect(severityFor("forwarders.0x123.runtimeBytecodeHash")).toBe("FAIL"); }); diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index 929625d70..402febe2a 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -53,13 +53,13 @@ interface IVortexSubsidyVault { /// ways assets can ever leave are: /// 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 and USDC to the immutable Vortex RECOVERY_WALLET, only by the keeper and +/// 3. EURe and USDC to this client's fixed `recoveryAddress`, 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. +/// execute that policy, pause it, recover a stuck payment to the client's refund +/// 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). @@ -85,9 +85,6 @@ 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 @@ -96,7 +93,7 @@ contract VortexForwarder { /// MAX_FEE_PPM plus the vault's caps bound it further. uint16 public immutable MAX_REFERENCE_DEVIATION_BPS; /// @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. + /// it to `recoveryAddress` — the promised conversion window, enforced on chain. uint256 public immutable RECOVERY_DELAY; uint256 public immutable TRIGGER_DELAY; // registry P4 @@ -118,7 +115,6 @@ contract VortexForwarder { address oracle; address attestor; address feeRecipient; - address recoveryWallet; uint256 maxOracleAge; uint16 slippageBps; uint32 maxFeePpm; @@ -133,6 +129,10 @@ contract VortexForwarder { bool public initialized; address public destination; // client's payout address (may be a CEX deposit address) + /// @dev The only address a recovery can move funds to: this client's Vortex-held refund + /// wallet, linked to the client's Monerium profile, from which the bank refund is + /// redeemed out of the client's own IBAN. Fixed at deployment, no setter. + address public recoveryAddress; /// @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. @@ -158,7 +158,7 @@ contract VortexForwarder { // ----------------------------------------------------------------- events - event Initialized(address destination, uint32 targetPpm, uint32 floorPpm); + event Initialized(address destination, address recoveryAddress, 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 @@ -211,8 +211,6 @@ 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); @@ -222,7 +220,6 @@ 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; @@ -264,17 +261,22 @@ contract VortexForwarder { // ---------------------------------------------------------- initialization /// @notice Called by the factory in the same transaction as clone deployment. - function initialize(address destination_, uint32 targetPpm_, uint32 floorPpm_) external { + function initialize(address destination_, address recoveryAddress_, uint32 targetPpm_, uint32 floorPpm_) external { if (msg.sender != address(FACTORY)) revert NotFactory(); if (initialized) revert AlreadyInitialized(); + // A zero recovery address would make `recover` burn client funds; one equal to the + // destination would turn the refund path into an early, unpriced delivery. _validateConfigAddress(destination_); + _validateConfigAddress(recoveryAddress_); + if (recoveryAddress_ == destination_) revert InvalidConfigAddress(); _validateFeePolicy(targetPpm_, floorPpm_); initialized = true; destination = destination_; + recoveryAddress = recoveryAddress_; targetPpm = targetPpm_; floorPpm = floorPpm_; - emit Initialized(destination_, targetPpm_, floorPpm_); + emit Initialized(destination_, recoveryAddress_, targetPpm_, floorPpm_); } // -------------------------------------------------------------- EIP-1271 @@ -517,7 +519,7 @@ contract VortexForwarder { // -------------------------------------------------------------- recovery /// @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 + /// USDC — to `recoveryAddress` 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. @@ -529,8 +531,8 @@ contract VortexForwarder { if (eureAmount > EURE.balanceOf(address(this)) || usdcAmount > USDC.balanceOf(address(this))) { revert InvalidAmount(); } - _transfer(EURE, RECOVERY_WALLET, eureAmount); - _transfer(USDC, RECOVERY_WALLET, usdcAmount); + _transfer(EURE, recoveryAddress, eureAmount); + _transfer(USDC, recoveryAddress, usdcAmount); _syncBatch(true); emit Recovered(msg.sender, eureAmount, usdcAmount); } @@ -619,7 +621,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 == RECOVERY_WALLET + || account == address(ROUTER) || account == address(this) ) revert InvalidConfigAddress(); } diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol index 217c4a51d..65a33007e 100644 --- a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -49,7 +49,12 @@ contract VortexForwarderFactory { address public subsidyVault; event ForwarderDeployed( - address indexed forwarder, address indexed destination, uint32 targetPpm, uint32 floorPpm, bytes32 salt + address indexed forwarder, + address indexed destination, + address recoveryAddress, + uint32 targetPpm, + uint32 floorPpm, + bytes32 salt ); event KeeperSet(address indexed keeper, bool enabled); event GlobalPausedSet(bool paused); @@ -97,15 +102,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, uint32 targetPpm, uint32 floorPpm, bytes32 salt) - external - onlyGuardian - returns (address forwarder) - { + function deployForwarder( + address destination, + address recoveryAddress, + uint32 targetPpm, + uint32 floorPpm, + bytes32 salt + ) external onlyGuardian returns (address forwarder) { forwarder = _cloneDeterministic(implementation, salt); - VortexForwarder(forwarder).initialize(destination, targetPpm, floorPpm); + VortexForwarder(forwarder).initialize(destination, recoveryAddress, targetPpm, floorPpm); isForwarder[forwarder] = true; - emit ForwarderDeployed(forwarder, destination, targetPpm, floorPpm, salt); + emit ForwarderDeployed(forwarder, destination, recoveryAddress, 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 385bfd77f..fe65b9594 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -59,7 +59,6 @@ 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: 60, maxFeePpm: 10_000, @@ -80,7 +79,7 @@ contract VortexForwarderForkTest is Test { ); deal(USDC, address(vault), 1_000e6); factory.setSubsidyVault(address(vault)); - fwd = VortexForwarder(factory.deployForwarder(destination, 1_250, 1_500, bytes32(uint256(1)))); + fwd = VortexForwarder(factory.deployForwarder(destination, recoveryWallet, 1_250, 1_500, bytes32(uint256(1)))); } /// The keeper's reference in these tests is Chainlink itself (trivially inside the band). diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol index 8401f4f00..0049fcbf0 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -65,7 +65,6 @@ 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: 60, maxFeePpm: 10_000, @@ -87,7 +86,9 @@ contract ForwarderHandler is Test { usdc.mint(address(vault), VAULT_FUNDING); factory.setSubsidyVault(address(vault)); fwd = VortexForwarder( - factory.deployForwarder(destination, INITIAL_TARGET_PPM, INITIAL_FLOOR_PPM, bytes32(uint256(1))) + factory.deployForwarder( + destination, recoveryWallet, 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 75e525efc..2c0b238d9 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -157,7 +157,9 @@ contract VortexForwarderTest is Test { ); usdc.mint(address(vault), 1_000e6); factory.setSubsidyVault(address(vault)); - fwd = VortexForwarder(factory.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, bytes32(uint256(1)))); + fwd = VortexForwarder( + factory.deployForwarder(destination, recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(1))) + ); } // ---------------------------------------------------------------- helpers @@ -175,7 +177,6 @@ contract VortexForwarderTest is Test { 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, @@ -249,8 +250,9 @@ contract VortexForwarderTest is Test { VortexForwarderFactory f2 = new VortexForwarderFactory( _config(address(router), recoveryHash), 1e18, 50_000e18, 25e18, 10_000e18, _route(500, 500) ); - VortexForwarder fwd2 = - VortexForwarder(f2.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, bytes32(uint256(8)))); + VortexForwarder fwd2 = VortexForwarder( + f2.deployForwarder(destination, recoveryWallet, 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); @@ -284,8 +286,9 @@ contract VortexForwarderTest is Test { } function test_linkSignature_rejectsCrossCloneReplay() public { - VortexForwarder other = - VortexForwarder(factory.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, bytes32(uint256(2)))); + VortexForwarder other = VortexForwarder( + factory.deployForwarder(destination, recoveryWallet, 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)); @@ -295,30 +298,48 @@ contract VortexForwarderTest is Test { function test_initialize_onlyFactory_andOnce() public { vm.expectRevert(VortexForwarder.NotFactory.selector); - fwd.initialize(rando, 0, 0); + fwd.initialize(rando, recoveryWallet, 0, 0); vm.prank(address(factory)); vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); - fwd.initialize(rando, 0, 0); + fwd.initialize(rando, recoveryWallet, 0, 0); } function test_implementation_isBricked() public { VortexForwarder impl = VortexForwarder(factory.implementation()); vm.prank(address(factory)); vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); - impl.initialize(rando, 0, 0); + impl.initialize(rando, recoveryWallet, 0, 0); } - function test_deploy_rejectsRecoveryWalletAsDestination() public { + function test_deploy_fixesThePerClientRecoveryAddress() public view { + assertEq(fwd.recoveryAddress(), recoveryWallet); + } + + function test_deploy_rejectsRecoveryAddressAsDestination() public { vm.expectRevert(VortexForwarder.InvalidConfigAddress.selector); - factory.deployForwarder(recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(3))); + factory.deployForwarder(recoveryWallet, recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(3))); } - function test_implementation_rejectsZeroRecoveryWallet() public { - VortexForwarder.ImmutableConfig memory cfg = _config(address(router), bytes32(0)); - cfg.recoveryWallet = address(0); + function test_deploy_rejectsZeroOrTokenRecoveryAddress() public { vm.expectRevert(VortexForwarder.ZeroAddress.selector); - new VortexForwarderFactory(cfg, 1e18, 50_000e18, 25e18, 10_000e18, _route(500, 500)); + factory.deployForwarder(destination, address(0), TARGET_PPM, FLOOR_PPM, bytes32(uint256(3))); + vm.expectRevert(VortexForwarder.InvalidConfigAddress.selector); + factory.deployForwarder(destination, address(usdc), TARGET_PPM, FLOOR_PPM, bytes32(uint256(3))); + } + + function test_recover_sendsToEachClonesOwnRecoveryAddress() public { + address otherRecovery = makeAddr("otherRecovery"); + VortexForwarder other = VortexForwarder( + factory.deployForwarder(destination, otherRecovery, TARGET_PPM, FLOOR_PPM, bytes32(uint256(4))) + ); + eure.mint(address(other), 300e18); + other.poke(); + vm.warp(block.timestamp + other.RECOVERY_DELAY()); + vm.prank(keeper); + other.recover(300e18, 0); + assertEq(eure.balanceOf(otherRecovery), 300e18); + assertEq(eure.balanceOf(recoveryWallet), 0); } // ---------------------------------------------------------------- swap + forward @@ -550,8 +571,9 @@ contract VortexForwarderTest is Test { _config(address(evil), bytes32(0)), 1e18, 50_000e18, 25e18, 10_000e18, _route(500, 500) ); f2.setKeeper(keeper, true); - VortexForwarder fwd2 = - VortexForwarder(f2.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, bytes32(uint256(7)))); + VortexForwarder fwd2 = VortexForwarder( + f2.deployForwarder(destination, recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(7))) + ); eure.mint(address(fwd2), 1_000e18); vm.prank(keeper); vm.expectRevert(VortexForwarder.Reentrancy.selector); @@ -664,7 +686,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, TARGET_PPM, FLOOR_PPM, salt); + address deployed = factory.deployForwarder(destination, recoveryWallet, TARGET_PPM, FLOOR_PPM, salt); assertEq(predicted, deployed); } @@ -957,9 +979,9 @@ contract VortexForwarderTest is Test { function test_feePolicy_validatedAtDeploy() public { vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); - factory.deployForwarder(destination, 2_000, 1_500, bytes32(uint256(9))); // target above floor + factory.deployForwarder(destination, recoveryWallet, 2_000, 1_500, bytes32(uint256(9))); // target above floor vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); - factory.deployForwarder(destination, 1_000, 10_001, bytes32(uint256(9))); // floor above cap + factory.deployForwarder(destination, recoveryWallet, 1_000, 10_001, bytes32(uint256(9))); // floor above cap } function test_setFeePolicy_onlyGuardianAndValidated() public { From 19427f7c65dde8dd0553af62c00c197ac304c5f6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 12:04:26 +0200 Subject: [PATCH 55/74] feat(api): run Monerium B2B refunds from per-client derived wallets Each client's refund wallet is derived from MONERIUM_B2B_REFUND_SEED and its Monerium profile ID, so one secret covers every client and the address is known before deployment. Onboarding links it to the client's profile, mapping rejects a clone whose recovery address is not that wallet, an admin endpoint returns it for deployment, and the refund runs on it with the float topping up its gas. --- .../admin/moneriumB2b.controller.test.ts | 24 +++++++ .../admin/moneriumB2b.controller.ts | 29 ++++++++ .../api/routes/v1/admin/monerium-b2b.route.ts | 8 ++- .../monerium-b2b/account-provisioning.ts | 20 +++++- .../src/api/services/monerium-b2b/chain.ts | 37 ++++------ .../monerium-b2b/conversion-executor.test.ts | 2 +- .../services/monerium-b2b/onboarding.test.ts | 45 +++++++++++- .../api/services/monerium-b2b/onboarding.ts | 69 ++++++++++++------- .../src/api/services/monerium-b2b/recovery.ts | 55 ++++++++++----- .../monerium-b2b/refund-wallet.test.ts | 18 +++++ .../services/monerium-b2b/refund-wallet.ts | 19 +++++ apps/api/src/config/vars.test.ts | 1 + apps/api/src/config/vars.ts | 24 +++---- apps/api/src/test-utils/preload.ts | 1 + 14 files changed, 265 insertions(+), 87 deletions(-) create mode 100644 apps/api/src/api/services/monerium-b2b/refund-wallet.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/refund-wallet.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 d4742d959..65f0e3241 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -15,6 +15,7 @@ import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; import { createTestUser } from "../../../test-utils/factories"; import moneriumB2bRoutes from "../../routes/v1/admin/monerium-b2b.route"; import { forwarderConfigMismatch } from "../../services/monerium-b2b/account-provisioning"; +import { refundAccountFor } from "../../services/monerium-b2b/refund-wallet"; const BASE_PATH = "/v1/admin/monerium-b2b"; const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type": "application/json" }; @@ -203,10 +204,12 @@ describe("monerium b2b account mapping admin route", () => { }); it("compares submitted account data against the deployed clone config", () => { + const refundWallet = "0x9999999999999999999999999999999999999999"; const expected = { destination: DESTINATION.toLowerCase(), factory: FACTORY.toLowerCase(), floorPpm: 1500, + recoveryAddress: refundWallet, targetPpm: 1250 }; const matching = { @@ -214,6 +217,7 @@ describe("monerium b2b account mapping admin route", () => { factory: FACTORY, floorPpm: 1500, isForwarder: true, + recoveryAddress: refundWallet, targetPpm: 1250 }; @@ -223,10 +227,30 @@ describe("monerium b2b account mapping admin route", () => { expect( forwarderConfigMismatch(expected, { ...matching, destination: "0x3333333333333333333333333333333333333333" }) ).toContain("destination"); + expect( + forwarderConfigMismatch(expected, { ...matching, recoveryAddress: "0x7777777777777777777777777777777777777777" }) + ).toContain("refund wallet"); expect(forwarderConfigMismatch(expected, { ...matching, targetPpm: 1_000 })).toContain("targetPpm"); expect(forwarderConfigMismatch(expected, { ...matching, floorPpm: 2_000 })).toContain("floorPpm"); }); + it("returns a client's derived refund wallet for deploying its forwarder", async () => { + const savedSeed = config.moneriumB2b.refundSeed; + config.moneriumB2b.refundSeed = `0x${"11".repeat(32)}`; + try { + const profileId = "0B8E7C2A-8F4E-4D43-9F2B-2F9F3C1D5A6E"; + const response = await fetch(`${baseUrl}/refund-address?moneriumProfileId=${profileId}`, { headers: ADMIN_HEADERS }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + moneriumProfileId: profileId.toLowerCase(), + refundAddress: refundAccountFor(profileId.toLowerCase()).address + }); + expect((await fetch(`${baseUrl}/refund-address?moneriumProfileId=nope`, { headers: ADMIN_HEADERS })).status).toBe(400); + } finally { + config.moneriumB2b.refundSeed = savedSeed; + } + }); + it("rejects invalid input and unknown managers", async () => { const managerProfileId = await createManager(); diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts index 4536185e5..5cef2a4be 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -7,6 +7,7 @@ import { ManagedProfileProvisioningError } from "../../services/managed-profile- 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"; +import { refundAccountFor } from "../../services/monerium-b2b/refund-wallet"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -274,3 +275,31 @@ export async function patchMoneriumB2bDepositStatus(req: Request<{ depositId: st }); } } + +/** + * GET /v1/admin/monerium-b2b/refund-address?moneriumProfileId= — the client's derived + * refund wallet, to pass as `recoveryAddress` when deploying its forwarder (runbook §1.2). + * Returns the address only; the key never leaves the backend. + */ +export async function getMoneriumB2bRefundAddress(req: Request, res: Response): Promise { + const moneriumProfileId = req.query.moneriumProfileId; + if (typeof moneriumProfileId !== "string" || !UUID_PATTERN.test(moneriumProfileId)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { code: "MONERIUM_B2B_INVALID_INPUT", message: "moneriumProfileId must be a UUID", status: httpStatus.BAD_REQUEST } + }); + return; + } + try { + const profileId = moneriumProfileId.toLowerCase(); + res.status(httpStatus.OK).json({ moneriumProfileId: profileId, refundAddress: refundAccountFor(profileId).address }); + } catch (error) { + logger.error("Error deriving a Monerium B2B refund address:", error); + res.status(httpStatus.SERVICE_UNAVAILABLE).json({ + error: { + code: "MONERIUM_B2B_NOT_CONFIGURED", + message: "MONERIUM_B2B_REFUND_SEED is not configured", + status: httpStatus.SERVICE_UNAVAILABLE + } + }); + } +} 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 88f963b5c..001554c1b 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,6 @@ import { Router } from "express"; import { + getMoneriumB2bRefundAddress, patchMoneriumB2bAccountStatus, patchMoneriumB2bDepositStatus, postMoneriumB2bAccount, @@ -15,12 +16,15 @@ router.use(adminAuth); // deployed forwarder as a B2B onramp account. Idempotent. router.post("/accounts", postMoneriumB2bAccount); +// The client's derived refund wallet, passed as `recoveryAddress` when deploying its forwarder. +router.get("/refund-address", getMoneriumB2bRefundAddress); + // Operator lifecycle transitions (activate, 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. +// its funds to the client's refund 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); 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 b815f1625..c32876695 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -7,6 +7,7 @@ import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/monerium import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import { type ProvisionManagedProfileResult, provisionManagedProfile } from "../managed-profile-provisioning.service"; import { getPublicClient } from "./chain"; +import { refundAccountFor } from "./refund-wallet"; const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/i; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -69,6 +70,7 @@ export function isValidFeePolicy(targetPpm: number, floorPpm: number): boolean { const forwarderConfigAbi = parseAbi([ "function destination() view returns (address)", + "function recoveryAddress() view returns (address)", "function targetPpm() view returns (uint32)", "function floorPpm() view returns (uint32)", "function FACTORY() view returns (address)" @@ -80,6 +82,8 @@ export interface ForwarderPolicyConfig { destination: string; factory: string; floorPpm: number; + /** The client's derived refund wallet (refund-wallet.ts): the clone's only recovery target. */ + recoveryAddress: string; targetPpm: number; } @@ -96,6 +100,9 @@ export function forwarderConfigMismatch( if (onchain.destination.toLowerCase() !== expected.destination) { return `on-chain destination ${onchain.destination} differs from the submitted value`; } + if (onchain.recoveryAddress.toLowerCase() !== expected.recoveryAddress.toLowerCase()) { + return `on-chain recovery address ${onchain.recoveryAddress} is not this client's refund wallet ${expected.recoveryAddress}`; + } if (onchain.targetPpm !== expected.targetPpm) { return `on-chain targetPpm ${onchain.targetPpm} differs from the submitted ${expected.targetPpm}`; } @@ -116,6 +123,7 @@ export function forwarderConfigMismatch( async function verifyForwarderOnChain( forwarderAddress: string, destination: string, + moneriumProfileId: string, targetPpm: number, floorPpm: number ): Promise { @@ -133,8 +141,9 @@ async function verifyForwarderOnChain( const address = forwarderAddress as Address; let onchain: ForwarderPolicyConfig & { isForwarder: boolean }; try { - const [onchainDestination, onchainTargetPpm, onchainFloorPpm, factory] = await Promise.all([ + const [onchainDestination, onchainRecovery, onchainTargetPpm, onchainFloorPpm, factory] = await Promise.all([ client.readContract({ abi: forwarderConfigAbi, address, functionName: "destination" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "recoveryAddress" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "targetPpm" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "floorPpm" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "FACTORY" }) @@ -150,6 +159,7 @@ async function verifyForwarderOnChain( factory, floorPpm: onchainFloorPpm, isForwarder, + recoveryAddress: onchainRecovery, targetPpm: onchainTargetPpm }; } catch (error) { @@ -160,7 +170,11 @@ async function verifyForwarderOnChain( }` ); } - const mismatch = forwarderConfigMismatch({ destination, factory: trustedFactory, floorPpm, targetPpm }, onchain); + const recoveryAddress = refundAccountFor(moneriumProfileId).address; + const mismatch = forwarderConfigMismatch( + { destination, factory: trustedFactory, floorPpm, recoveryAddress, targetPpm }, + onchain + ); if (mismatch) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", @@ -287,7 +301,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, targetPpm, floorPpm); + await verifyForwarderOnChain(forwarderAddress, destination, moneriumProfileId, targetPpm, floorPpm); let result: { account: { created: boolean; row: MoneriumAccount }; managedProfile: ProvisionManagedProfileResult }; try { diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index d695df0fb..91080f2ac 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -148,7 +148,7 @@ export const forwarderAbi = [ }, { 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: "recoveryAddress", 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" }, @@ -259,7 +259,6 @@ 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; @@ -337,22 +336,9 @@ 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; +/** Client for one client's derived refund wallet (refund-wallet.ts): signs its reverse swap and redeem. */ +export function getRefundWalletClient(account: Account): KeeperWalletClient { + return createWalletClient({ account, transport: http(submissionRpcUrl()) }); } /** Float-wallet client (MONERIUM_B2B_FLOAT_PRIVATE_KEY): the EURe float that tops a refund up to the exact amount. */ @@ -390,13 +376,14 @@ export interface ForwarderImmutables { oracleDecimals: number; /** Seconds a batch must have been open before the clone accepts `recover` (registry P3). */ recoveryDelaySeconds: number; - recoveryWallet: Address; + /** This clone's fixed recovery address: the client's refund wallet. */ + recoveryAddress: Address; slippageBps: number; usdc: Address; } -// 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 plus the clone's own fixed +// recovery address, so one lookup per forwarder address is enough for the process lifetime. const forwarderImmutablesCache = new Map(); export async function getForwarderImmutables(forwarderAddress: Address): Promise { @@ -417,7 +404,7 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise | "MAX_FEE_PPM" | "MAX_REFERENCE_DEVIATION_BPS" | "RECOVERY_DELAY" - | "RECOVERY_WALLET" + | "recoveryAddress" | "ROUTER" >( functionName: T @@ -432,7 +419,7 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise maxFeePpm, maxReferenceDeviationBps, recoveryDelay, - recoveryWallet, + recoveryAddress, router ] = await Promise.all([ read("EURE"), @@ -444,7 +431,7 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise read("MAX_FEE_PPM"), read("MAX_REFERENCE_DEVIATION_BPS"), read("RECOVERY_DELAY"), - read("RECOVERY_WALLET"), + read("recoveryAddress"), read("ROUTER") ]); const immutables: ForwarderImmutables = { @@ -454,8 +441,8 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise maxReferenceDeviationBps: Number(maxReferenceDeviationBps), oracle, oracleDecimals: Number(oracleDecimals), + recoveryAddress, 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 07273249d..65ec6ea06 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 @@ -570,7 +570,7 @@ describe("pricePlannedSwap", () => { oracle: "0x5555555555555555555555555555555555555555", oracleDecimals: 8, recoveryDelaySeconds: 7_200, - recoveryWallet: "0x7777777777777777777777777777777777777777", + recoveryAddress: "0x7777777777777777777777777777777777777777", router: "0x8888888888888888888888888888888888888888", slippageBps: 60, usdc: "0x6666666666666666666666666666666666666666" 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 3743c34b5..274c5cfcc 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -14,19 +14,36 @@ const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; const IBAN = "EE08 7224 5745 6244 9516"; +const REFUND_WALLET = "0x9999999999999999999999999999999999999999"; const ETHEREUM_CHAIN = { getChainId: async () => 1 }; const savedConfig = { ...config.moneriumB2b }; interface FakeDeps extends OnboardingDeps { - calls: { getChainId: number; getIbanForAddress: number; getProfileAddresses: number; linkAddress: unknown[][]; requestIban: unknown[][]; signLinkAttestation: unknown[][] }; + calls: { + getChainId: number; + getIbanForAddress: number; + getProfileAddresses: number; + linkAddress: unknown[][]; + requestIban: unknown[][]; + signLinkAttestation: unknown[][]; + signRefundLink: unknown[][]; + }; ibanByAddress: Map; linkedAddresses: Set; } function fakeDeps(): FakeDeps { const deps: FakeDeps = { - calls: { getChainId: 0, getIbanForAddress: 0, getProfileAddresses: 0, linkAddress: [], requestIban: [], signLinkAttestation: [] }, + calls: { + getChainId: 0, + getIbanForAddress: 0, + getProfileAddresses: 0, + linkAddress: [], + requestIban: [], + signLinkAttestation: [], + signRefundLink: [] + }, async getChainId() { deps.calls.getChainId += 1; return 1; @@ -45,7 +62,8 @@ function fakeDeps(): FakeDeps { deps.calls.linkAddress.push(args); return {}; }, - linkedAddresses: new Set(), + // The refund wallet starts linked so most cases exercise only the forwarder link. + linkedAddresses: new Set([REFUND_WALLET]), async requestIban(...args: unknown[]) { deps.calls.requestIban.push(args); return {}; @@ -53,6 +71,10 @@ function fakeDeps(): FakeDeps { async signLinkAttestation(...args: unknown[]) { deps.calls.signLinkAttestation.push(args); return { signature: "0xattestor-signature" }; + }, + async signRefundLink(...args: unknown[]) { + deps.calls.signRefundLink.push(args); + return { address: REFUND_WALLET, signature: "0xrefund-signature" }; } }; return deps; @@ -91,6 +113,7 @@ describe("monerium b2b onboarding automation", () => { await resetTestDatabase(); config.moneriumB2b.attestorPrivateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; config.moneriumB2b.rpcUrl = "http://rpc.invalid"; + config.moneriumB2b.refundSeed = `0x${"11".repeat(32)}`; // MONERIUM_WHITELABEL_CLIENT_ID/SECRET come from test-utils/preload.ts. }); @@ -116,6 +139,22 @@ describe("monerium b2b onboarding automation", () => { ]); }); + it("links the client's refund wallet to the profile next to the forwarder", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + deps.linkedAddresses.clear(); + + await advanceOnboardingAccounts(deps); + expect(deps.calls.signRefundLink).toEqual([[MONERIUM_PROFILE]]); + expect(deps.calls.linkAddress).toEqual([ + [MONERIUM_PROFILE, FORWARDER, "ethereum", "0xattestor-signature"], + [MONERIUM_PROFILE, REFUND_WALLET, "ethereum", "0xrefund-signature"] + ]); + expect(deps.calls.requestIban).toEqual([[FORWARDER, "ethereum"]]); + const links = await FinancialOperation.findAll({ where: { phase: ["linkAddress", "linkRefundAddress"] } }); + expect(links.map(link => link.status)).toEqual(["confirmed", "confirmed"]); + }); + it("never repeats a claimed provider write on replay", async () => { await createMappedAccount(); const deps = fakeDeps(); diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.ts b/apps/api/src/api/services/monerium-b2b/onboarding.ts index cd3e5813f..f68c37092 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.ts @@ -1,4 +1,4 @@ -import type { MoneriumChain } from "@vortexfi/shared"; +import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, type MoneriumChain } from "@vortexfi/shared"; import { Op } from "sequelize"; import type { Address } from "viem"; import logger from "../../../config/logger"; @@ -8,6 +8,7 @@ import { FinancialOperationRejectedError, runFinancialOperation } from "../phase import { signLinkAttestation } from "./attestor"; import { getChainId, moneriumChainForChainId } from "./chain"; import { getIbanForAddress, getProfileAddresses, isWhitelabelConfigured, linkAddress, requestIban } from "./monerium-api"; +import { refundAccountFor } from "./refund-wallet"; const ONBOARDING_FLOW = { id: "monerium-b2b-onboarding", version: 1 } as const; @@ -18,6 +19,8 @@ export interface OnboardingDeps { linkAddress(profileId: string, address: string, chain: MoneriumChain, signature: string): Promise; requestIban(address: string, chain: MoneriumChain): Promise; signLinkAttestation(chainId: bigint, forwarderAddress: Address): Promise<{ signature: string }>; + /** The client's refund wallet and its signature over Monerium's ownership message. */ + signRefundLink(moneriumProfileId: string): Promise<{ address: string; signature: string }>; } const defaultDeps: OnboardingDeps = { @@ -26,46 +29,57 @@ const defaultDeps: OnboardingDeps = { getProfileAddresses, linkAddress, requestIban, - signLinkAttestation + signLinkAttestation, + async signRefundLink(moneriumProfileId) { + const wallet = refundAccountFor(moneriumProfileId); + return { address: wallet.address, signature: await wallet.signMessage({ message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE }) }; + } }; export function isOnboardingConfigured(): boolean { - const { attestorPrivateKey, rpcUrl } = config.moneriumB2b; - return Boolean(attestorPrivateKey && rpcUrl && isWhitelabelConfigured()); + const { attestorPrivateKey, refundSeed, rpcUrl } = config.moneriumB2b; + return Boolean(attestorPrivateKey && refundSeed && rpcUrl && isWhitelabelConfigured()); } let configWarned = false; -async function isForwarderLinked( +async function isLinked( deps: OnboardingDeps, moneriumProfileId: string, - forwarderAddress: string, + address: string, chainName: MoneriumChain ): Promise { - const forwarderKey = forwarderAddress.toLowerCase(); + const key = address.toLowerCase(); const addresses = await deps.getProfileAddresses(moneriumProfileId, chainName); - return addresses.some(address => address.toLowerCase() === forwarderKey); + return addresses.some(linked => linked.toLowerCase() === key); } +/** + * Links `address` to the client's Monerium profile with `sign`'s ownership signature, + * exactly once. `phase` names the ledger operation: the forwarder and the refund wallet + * are separate provider writes. + */ async function ensureLinked( deps: OnboardingDeps, account: MoneriumAccount, - chainId: number, + phase: "linkAddress" | "linkRefundAddress", + address: string, + sign: () => Promise, chainName: MoneriumChain ): Promise { - if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) return; + if (await isLinked(deps, account.profileId, address, chainName)) return; await runFinancialOperation({ attemptClass: "provider-address-link", flow: ONBOARDING_FLOW, perform: async () => { - const attestation = await deps.signLinkAttestation(BigInt(chainId), account.forwarderAddress as Address); + const signature = await sign(); try { - await deps.linkAddress(account.profileId, account.forwarderAddress, chainName, attestation.signature); + await deps.linkAddress(account.profileId, address, chainName, signature); } catch (error) { // Linking is synchronous upstream: if the address is not linked after a // failure, the call had no side effect — signal that so the ledger allows a // clean retry next cycle instead of parking the row in `unknown` forever. - if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) { + if (await isLinked(deps, account.profileId, address, chainName)) { return { linked: true }; } throw new FinancialOperationRejectedError( @@ -74,13 +88,12 @@ async function ensureLinked( } return { linked: true }; }, - phase: "linkAddress", + phase, provider: "monerium", // A crash between the POST and its confirmation resolves by re-reading the // profile's linked addresses instead of issuing a second link call. - reconcile: async () => - (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) ? { linked: true } : null, - request: { address: account.forwarderAddress.toLowerCase(), chain: chainName, moneriumProfileId: account.profileId }, + reconcile: async () => ((await isLinked(deps, account.profileId, address, chainName)) ? { linked: true } : null), + request: { address: address.toLowerCase(), chain: chainName, moneriumProfileId: account.profileId }, retryFailed: true, // vortexProfileId is non-null for every account this loop selects. scopeId: account.vortexProfileId as string, @@ -126,17 +139,17 @@ async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainN /** * Advances every mapped account still in onboarding: links its forwarder to the - * Monerium profile with the attestor signature, then requests IBAN issuance. Both - * provider writes run through the profile-scoped financial-operation ledger, so a - * crash or retry never repeats a claimed call. Activation stays a manual operator - * step. + * Monerium profile with the attestor signature and the client's refund wallet with its + * own signature, then requests IBAN issuance for the forwarder. Every provider write + * runs through the profile-scoped financial-operation ledger, so a crash or retry never + * repeats a claimed call. Activation stays a manual operator step. */ export async function advanceOnboardingAccounts(deps: OnboardingDeps = defaultDeps): Promise { if (!isOnboardingConfigured()) { if (!configWarned) { configWarned = true; logger.warn( - "monerium-b2b: onboarding automation disabled — requires MONERIUM_WHITELABEL_CLIENT_ID/SECRET, MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, and MONERIUM_B2B_RPC_URL" + "monerium-b2b: onboarding automation disabled — requires MONERIUM_WHITELABEL_CLIENT_ID/SECRET, MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, MONERIUM_B2B_REFUND_SEED, and MONERIUM_B2B_RPC_URL" ); } return 0; @@ -158,7 +171,17 @@ export async function advanceOnboardingAccounts(deps: OnboardingDeps = defaultDe let advanced = 0; for (const account of accounts) { try { - await ensureLinked(deps, account, chainId, chainName); + const forwarder = account.forwarderAddress; + await ensureLinked( + deps, + account, + "linkAddress", + forwarder, + async () => (await deps.signLinkAttestation(BigInt(chainId), forwarder as Address)).signature, + chainName + ); + const refund = await deps.signRefundLink(account.profileId); + await ensureLinked(deps, account, "linkRefundAddress", refund.address, async () => refund.signature, chainName); await ensureIban(deps, account, chainName); advanced += 1; } catch (error) { diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index b4276aa48..e96c132bc 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -18,7 +18,7 @@ import { getFloatWalletClient, getForwarderImmutables, getPublicClient, - getRecoveryWalletClient, + getRefundWalletClient, KeeperWalletClient, moneriumChainForChainId, readEnabledRoutes, @@ -27,6 +27,7 @@ import { import { markDepositForRecovery } from "./conversion-executor"; import { isForwardTransition, withForwarderLock } from "./deposit-processor"; import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; +import { refundAccountFor } from "./refund-wallet"; /** * The refund path (docs/architecture-monerium-b2b-onramp.md, "the refund path"): @@ -36,12 +37,14 @@ import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; * 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. + * to the bank refund, on the client's own refund wallet (the clone's `recoveryAddress`, + * derived in refund-wallet.ts and linked to the client's Monerium profile): reverse-swap + * the USDC, 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, which pays out of the client's own IBAN, and mark the deposit `refunded` when + * Monerium processed it. * - * Crash safety rests on the recovery wallet being dedicated and empty between refunds: + * Crash safety rests on the refund 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; @@ -53,6 +56,8 @@ import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; 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; +/** Gas the refund wallet's own transactions use (approve, reverse swap, surplus transfer), with margin. */ +const REFUND_WALLET_GAS_UNITS = 400_000n; const MAX_ATTEMPTS = 5; const RECEIPT_TIMEOUT_MS = 3 * 60_000; const EURE_DECIMALS = 18; @@ -138,7 +143,7 @@ export interface RecoveryDeps { 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. */ + /** Sends the reverse swap from the client's refund 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">; @@ -157,18 +162,29 @@ function requireClient(client: KeeperWalletClient | null, name: string): KeeperW return client; } -/** Live dependencies: chain clients from ./chain, the shared Monerium client, the two wallet keys. */ -export async function liveRecoveryDeps(forwarder: Address): Promise { +/** Live dependencies: chain clients from ./chain, the shared Monerium client, the client's refund wallet and the float. */ +export async function liveRecoveryDeps(account: MoneriumAccount): 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 immutables = await getForwarderImmutables(account.forwarderAddress as Address); + const refundAccount = refundAccountFor(account.profileId); + if (refundAccount.address.toLowerCase() !== immutables.recoveryAddress.toLowerCase()) { + throw new Error(`MONERIUM_B2B_REFUND_SEED does not derive the recovery address of forwarder ${account.forwarderAddress}`); } + const recovery = getRefundWalletClient(refundAccount); + const float = requireClient(getFloatWalletClient(), "MONERIUM_B2B_FLOAT_PRIVATE_KEY"); const balance = (token: Address, address: Address) => client.readContract({ abi: erc20Abi, address: token, args: [address], functionName: "balanceOf" }); const wallets = { float, recovery }; + // The refund wallet pays for its own approve, swap and surplus transfer: before it sends, + // the float tops its ETH up to twice that cost at the current gas price. Balance-derived, + // so a repeat after a crash sends nothing once the first top-up landed. + const fundRefundGas = async () => { + const need = (await client.getGasPrice()) * REFUND_WALLET_GAS_UNITS; + const held = await client.getBalance({ address: recovery.account.address }); + if (held >= need) return; + const hash = await float.sendTransaction({ chain: null, to: recovery.account.address, value: 2n * need - held }); + await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }); + }; return { async createRedeemOrder(request) { const result = await MoneriumApiService.getInstance().createRedemptionOrder(request); @@ -209,6 +225,7 @@ export async function liveRecoveryDeps(forwarder: Address): Promise Promise = liveRecoveryDeps + depsFor: (account: MoneriumAccount) => Promise = liveRecoveryDeps ): Promise { let recovery = await MoneriumRecovery.findOne({ order: [["created_at", "ASC"]], @@ -612,7 +631,7 @@ export async function runRecoveryOrchestrator( const account = await MoneriumAccount.findByPk(deposit.accountId); if (!account) return; try { - const deps = await depsFor(account.forwarderAddress as Address); + const deps = await depsFor(account); 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/services/monerium-b2b/refund-wallet.test.ts b/apps/api/src/api/services/monerium-b2b/refund-wallet.test.ts new file mode 100644 index 000000000..36a9d32c9 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/refund-wallet.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test"; +import { refundAccountFor } from "./refund-wallet"; + +const SEED = `0x${"11".repeat(32)}`; +const PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; + +describe("refund wallet derivation", () => { + it("derives one stable wallet per Monerium profile from the seed", () => { + const wallet = refundAccountFor(PROFILE, SEED); + expect(refundAccountFor(PROFILE.toUpperCase(), SEED).address).toBe(wallet.address); + expect(refundAccountFor("1c9f8d3b-9a5f-4e54-8a3c-3a0a4d2e6b7f", SEED).address).not.toBe(wallet.address); + expect(refundAccountFor(PROFILE, `0x${"22".repeat(32)}`).address).not.toBe(wallet.address); + }); + + it("refuses to derive without a seed", () => { + expect(() => refundAccountFor(PROFILE, "")).toThrow("MONERIUM_B2B_REFUND_SEED"); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/refund-wallet.ts b/apps/api/src/api/services/monerium-b2b/refund-wallet.ts new file mode 100644 index 000000000..24b3468ba --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/refund-wallet.ts @@ -0,0 +1,19 @@ +import { concat, type Hex, keccak256, type PrivateKeyAccount, toHex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { config } from "../../../config/vars"; + +/** + * Each client's refund wallet: a plain key derived from MONERIUM_B2B_REFUND_SEED and the + * client's Monerium profile ID. It is the forwarder's fixed `recoveryAddress`, is linked to + * the client's Monerium profile, and redeems a refund out of the client's own IBAN. Keyed on + * the profile, not the clone, so a replacement clone for the same client keeps the same + * wallet (Monerium links an address to one profile only). One secret covers every client, + * and the address is known before the forwarder is deployed. + */ +export function refundAccountFor( + moneriumProfileId: string, + seed: string | undefined = config.moneriumB2b.refundSeed +): PrivateKeyAccount { + if (!seed) throw new Error("MONERIUM_B2B_REFUND_SEED is not configured"); + return privateKeyToAccount(keccak256(concat([seed as Hex, toHex(`vortex-b2b-refund:${moneriumProfileId.toLowerCase()}`)]))); +} diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 063ad2149..92c1f3aba 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -27,6 +27,7 @@ const requiredMoneriumB2bEnv = { MONERIUM_B2B_GUARDIAN_PRIVATE_KEY: "0x2222222222222222222222222222222222222222222222222222222222222222", MONERIUM_B2B_KEEPER_PRIVATE_KEY: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", MONERIUM_B2B_PRIVATE_RPC_URL: "https://private-rpc.example.com", + MONERIUM_B2B_REFUND_SEED: "0x3333333333333333333333333333333333333333333333333333333333333333", MONERIUM_B2B_RPC_URL: "https://rpc.example.com", MONERIUM_B2B_WEBHOOK_SECRET: "whsec_MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=", MONERIUM_WHITELABEL_CLIENT_ID: "test-whitelabel-client-id", diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 322484e5b..90ed49d34 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -262,8 +262,12 @@ interface Config { 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; + /** + * 32-byte secret every client's refund wallet key is derived from (refund-wallet.ts): the + * wallet each forwarder recovers to, linked to the client's Monerium profile, signs the + * reverse swap and the redeem from the client's own IBAN. + */ + refundSeed: string | undefined; rpcUrl: string | undefined; /** * How much of a chunk's shortfall below the client's floor Vortex pays, as a ladder of @@ -427,7 +431,7 @@ export const config: Config = { // 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, + refundSeed: process.env.MONERIUM_B2B_REFUND_SEED, rpcUrl: process.env.MONERIUM_B2B_RPC_URL, subsidyLadder: parseSubsidyLadder(process.env.MONERIUM_B2B_SUBSIDY_LADDER), webhookSecret: process.env.MONERIUM_B2B_WEBHOOK_SECRET || "" @@ -539,6 +543,7 @@ if (config.moneriumB2b.enabled) { if (!config.moneriumB2b.attestorPrivateKey) missing.push("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY"); if (!config.moneriumB2b.guardianPrivateKey) missing.push("MONERIUM_B2B_GUARDIAN_PRIVATE_KEY"); if (!config.moneriumB2b.keeperPrivateKey) missing.push("MONERIUM_B2B_KEEPER_PRIVATE_KEY"); + if (!config.moneriumB2b.refundSeed) missing.push("MONERIUM_B2B_REFUND_SEED"); if (!config.moneriumB2b.rpcUrl) missing.push("MONERIUM_B2B_RPC_URL"); if (!config.moneriumB2b.webhookSecret) missing.push("MONERIUM_B2B_WEBHOOK_SECRET"); if (!config.moneriumB2b.forwarderFactoryAddress) missing.push("MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS"); @@ -556,11 +561,8 @@ 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 (!config.moneriumB2b.floatPrivateKey) { + throw new Error("MONERIUM_B2B_AUTO_RECOVERY=auto requires MONERIUM_B2B_FLOAT_PRIVATE_KEY"); } } if (!Number.isInteger(config.moneriumB2b.recoveryDeadlineMinutes) || config.moneriumB2b.recoveryDeadlineMinutes <= 0) { @@ -573,15 +575,13 @@ if (config.moneriumB2b.enabled) { ["MONERIUM_B2B_ATTESTOR_PRIVATE_KEY", config.moneriumB2b.attestorPrivateKey], ["MONERIUM_B2B_GUARDIAN_PRIVATE_KEY", config.moneriumB2b.guardianPrivateKey], ["MONERIUM_B2B_KEEPER_PRIVATE_KEY", config.moneriumB2b.keeperPrivateKey], - ...(config.moneriumB2b.recoveryPrivateKey - ? ([["MONERIUM_B2B_RECOVERY_PRIVATE_KEY", config.moneriumB2b.recoveryPrivateKey]] as const) - : []), + ["MONERIUM_B2B_REFUND_SEED", config.moneriumB2b.refundSeed], ...(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`); + throw new Error(`${name} must be a 32-byte 0x-prefixed hex value`); } } const b2bKeys = [ diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 2785b4e77..690778c57 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -41,6 +41,7 @@ if (!process.env.RUN_LIVE_TESTS) { process.env.MONERIUM_B2B_KEEPER_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; process.env.MONERIUM_B2B_RPC_URL = "http://evm.invalid"; process.env.MONERIUM_B2B_PRIVATE_RPC_URL = "http://evm-private.invalid"; + process.env.MONERIUM_B2B_REFUND_SEED = "0x3333333333333333333333333333333333333333333333333333333333333333"; process.env.MONERIUM_B2B_WEBHOOK_SECRET = "whsec_dGVzdC1tb25lcml1bS13ZWJob29rLXNlY3JldA=="; // COINGECKO_API_URL is deliberately NOT overridden: priceFeed config tests // assert its default, and the fetch guard blocks real calls anyway. From 3ae8931fccde9ba79df27f1b8f6552583f0a2846 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 12:04:27 +0200 Subject: [PATCH 56/74] docs(repo): document per-client refund wallets for the B2B onramp The decision record, architecture, security spec, runbook, rollout and flow overview now describe the per-client refund wallet instead of one Vortex recovery wallet on a company profile. --- docs/adr-0005-monerium-b2b-onramp.md | 52 +++++-- docs/architecture-monerium-b2b-onramp.md | 40 +++--- docs/operations-monerium-b2b-rollout.md | 29 ++-- docs/operations-monerium-b2b-runbook.md | 76 +++++----- docs/product-monerium-b2b-flow.md | 133 ++++++++---------- .../05-integrations/monerium-b2b.md | 14 +- 6 files changed, 192 insertions(+), 152 deletions(-) diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 942ca0c5f..d7a9a5dfe 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -60,21 +60,22 @@ Supporting decisions, all in force: - **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). + the promised window was missed on, the client's refund wallet held by Vortex — see + the amendments of 2026-09-17 and 2026-10-01. 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. + or the client's refund 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 + the payment to the client's refund wallet and Vortex redeems from there (amendments + 2026-09-17 and 2026-10-01), 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 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 + funds; the keeper can move a payment only to the clone's fixed refund wallet and only + once the clone's batch has been open for `RECOVERY_DELAY` (amendments 2026-09-17 and + 2026-10-01); 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 @@ -156,7 +157,8 @@ for that refund is agreed commercially. Decisions (the proposal that led here is 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, +- **Vortex-held recovery wallet, on-chain delay** (the single company-profile wallet was + replaced by per-client refund wallets, amendment 2026-10-01). `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 @@ -262,6 +264,34 @@ for that refund is agreed commercially. Decisions (the proposal that led here is requirement. A wrong or rotated destination is now caught by the penny test only when one is run, otherwise by the dormancy gate; the loss allocation under B5 is unchanged. +## Amendment 2026-10-01: per-client refund wallets + +- **One refund wallet per client, fixed in its forwarder.** The implementation-wide + `RECOVERY_WALLET` is gone. Each clone takes a `recoveryAddress` at deployment + (`deployForwarder(destination, recoveryAddress, targetPpm, floorPpm, salt)`), with no + setter, rejected when zero, a token, the router, the clone itself or equal to the + destination. `recover` pays only that address, still keeper-only and only after + `RECOVERY_DELAY`. +- **A plain wallet, derived.** The refund wallet is an EOA whose key is + `keccak256(MONERIUM_B2B_REFUND_SEED ++ "vortex-b2b-refund:" ++ moneriumProfileId)`, so one + secret covers every client, the address is known before the clone is deployed (admin + `GET /v1/admin/monerium-b2b/refund-address`), and a replacement clone for the same client + keeps the same wallet. Account mapping refuses a clone whose `recoveryAddress` is not + the derived wallet. A refund contract was considered and deferred: while refunds go to + whichever IBAN paid in, a contract cannot know the payer and would sign whatever the + keeper supplies, the same trust as a held key, with more audit surface. It becomes + worthwhile with a fixed refund IBAN per client, when it can refuse every other IBAN; + switching an existing client then means a new clone (runbook §5). +- **Linked to the client's profile, refunded from the client's IBAN.** Onboarding links + the refund wallet to the client's Monerium profile next to the forwarder (an address + belongs to exactly one profile, confirmed by Monerium 2026-09-30). The refund runs on + that wallet as before: reverse swap, float top-up to the exact amount, redeem to the + payer, which Monerium pays out of the client's own IBAN. The float also tops up the + wallet's ETH for its own transactions, sized from the current gas price. No Vortex + company profile at Monerium is needed, and recovered funds of different clients never + share a wallet. `MONERIUM_B2B_RECOVERY_PRIVATE_KEY` is replaced by + `MONERIUM_B2B_REFUND_SEED`, required whenever the module is enabled. + ## Final parameters (decided 2026-08-26 unless noted) | ID | Parameter | Value | @@ -336,13 +366,13 @@ example (oversized-deposit allocation). - **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; past the promised window the - payment is refunded through the recovery wallet, past 24 h anyone may convert and + payment is refunded through the client's refund 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 — only, after the on-chain - delay, move them to the recovery wallet. Accepted. + delay, move them to the client's refund 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; diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 525d98780..18edd766e 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -23,8 +23,8 @@ below the floor is topped up from the subsidy vault, Chainlink bounds the net) 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". +client's refund wallet (`recover`, keeper-only, contract-gated by `RECOVERY_DELAY`) and +refunded to the payer's bank account out of the client's own IBAN — 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 @@ -58,7 +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)"] + RECOV["Client refund wallet\n(refund to the payer's IBAN)"] end subgraph Reference["Reference rate"] @@ -102,7 +102,7 @@ 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, and -the immutable Vortex recovery wallet, reachable only by the keeper and only once a batch +the client's refund wallet fixed in the clone, 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, @@ -120,11 +120,13 @@ sequenceDiagram participant C as Ethereum Note over M: Monerium onboards the corporate under partner reliance - profile "approved" - Op->>C: deployForwarder(destination, targetPpm, floorPpm) via factory + Op->>Adm: GET /v1/admin/monerium-b2b/refund-address (derived refund wallet) + Op->>C: deployForwarder(destination, refundWallet, 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 K->>M: POST /addresses (attestor-signed link) [exactly-once] + K->>M: POST /addresses (refund wallet, EOA-signed link) [exactly-once] K->>M: POST /ibans for the forwarder address [exactly-once] M-->>K: iban.updated webhook -> IBAN recorded Op->>M: optional penny test (simulated/real small SEPA) @@ -137,14 +139,18 @@ 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` (no setter: - a wallet change means a new clone, runbook §5) and the initial fee policy - (`targetPpm`, `floorPpm`); manifest generated and verified. + a wallet change means a new clone, runbook §5), the client's refund wallet as + `recoveryAddress` (derived from `MONERIUM_B2B_REFUND_SEED` and the Monerium profile ID, + read from the admin refund-address endpoint; no setter either) 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 + configured trusted factory on chain (including that its `recoveryAddress` is the + client's derived refund wallet), and creates the account row bound via `vortex_profile_id`. All local records commit in one database transaction. -4. **Keeper automation** links the forwarder (attestor signature) and requests the IBAN, - each exactly-once through the profile-scoped `financial_operations` ledger; the +4. **Keeper automation** links the forwarder (attestor signature) and the client's refund + wallet (its own signature), then requests the IBAN for the forwarder, each + exactly-once through the profile-scoped `financial_operations` ledger; the `iban.updated` webhook records the IBAN. 5. **Optional penny test**, then activation via the admin status endpoint. @@ -169,7 +175,7 @@ sequenceDiagram 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->>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) @@ -313,7 +319,7 @@ and sends at most one transaction per account per cycle: - **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 + clone's fixed `recoveryAddress` (the client's refund wallet), and only once the clone's `batchOpenedAt` marker is older than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive, is never 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 @@ -323,14 +329,16 @@ and sends at most one transaction per account per cycle: 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 + whitelisted route with a Chainlink-derived minimum, the EURe float tops the refund 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 + Monerium redeem order from the refund wallet returns the exact amount to the payer's + IBAN out of the client's own IBAN (`payer_iban` / `payer_name`, captured from the issue + order's counterpart). The float also tops up the refund wallet's ETH for its own + transactions, at twice their cost at the current gas price. The 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 + time: every step re-derives what is left to do from the client's refund 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) diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 68c82591f..efdc2deff 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -11,11 +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). + **re-approval needed (2026-09-17):** the fallback is now a Vortex-held refund wallet + per client, linked to the client's own profile, which refunds out of the client's + IBAN (2026-10-01; one address per profile and the reusable supporting document were + confirmed 2026-09-30) — 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 own external bank account, no fees, re-verification possible — **including the @@ -67,9 +66,8 @@ fee policy 12.5 bps target / 15 bps floor (B1). 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, 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), - €1 floor/€50k ceiling, initial 5 bps/5 bps route); set operational `minSwapAmount` + trigger delays, €1 floor/€50k ceiling, initial 5 bps/5 bps route; the recovery address + is per client, passed at each clone's deployment); set operational `minSwapAmount` €1 and `perSwapCap` €10k; 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 @@ -78,7 +76,8 @@ fee policy 12.5 bps target / 15 bps floor (B1). 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 + keeper funded), `MONERIUM_B2B_REFUND_SEED` (derives every client's refund wallet), + read RPC + private orderflow RPC, webhook secret, and `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. @@ -181,10 +180,10 @@ fee policy 12.5 bps target / 15 bps floor (B1). 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 + the promised window was missed on, and only then — move that payment to the client's + refund wallet, which Vortex holds, in order to refund it from the client's IBAN. What + Vortex cannot do: redirect funds. The contract can pay only the client's payout + address, Vortex's fee treasury, and the client's fixed refund 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 @@ -204,8 +203,8 @@ 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 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 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 | +| Refund seed + float wallet | Ops | Generate `MONERIUM_B2B_REFUND_SEED` (32 random bytes; every client's refund wallet derives from it, and onboarding links each to its client's profile) and the EURe float key; fund the float with EURe and ETH (it pays the refund wallets' gas); both into the keeper's KMS. No Vortex company profile is needed | +| Refund automation | Ops | Implemented (`recovery.ts`): ship with `MONERIUM_B2B_AUTO_RECOVERY=alert`, observe one sandbox refund end to end, then `auto` with the float key 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 1e20835c7..b1cb4733e 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -12,9 +12,9 @@ Ground rules that shape every procedure here: - **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. + client's refund wallet fixed in the clone 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 client's refund 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 @@ -24,7 +24,7 @@ Ground rules that shape every procedure here: 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. + or the client's refund 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. @@ -49,9 +49,10 @@ 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). -- (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; a penny test is recommended for exchange destinations.) +- (No client-held recovery address: the clone's `recoveryAddress` is the client's refund + wallet, derived by Vortex, step 1.2. The destination has no setter — a client wallet + change is a new clone, §5 — so get it right; a penny test is recommended for exchange + destinations.) - `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). @@ -60,13 +61,18 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag ### 1.2 Deploy the forwarder clone ```bash +# the client's refund wallet, derived from MONERIUM_B2B_REFUND_SEED and the Monerium profile ID +curl -s -H "Authorization: Bearer $ADMIN_SECRET" \ + "$API/v1/admin/monerium-b2b/refund-address?moneriumProfileId=$MONERIUM_PROFILE_ID" # 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,uint32,uint32,bytes32)" \ - $DESTINATION $TARGET_PPM $FLOOR_PPM $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $FACTORY "deployForwarder(address,address,uint32,uint32,bytes32)" \ + $DESTINATION $REFUND_ADDRESS $TARGET_PPM $FLOOR_PPM $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY ``` -The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). +The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). The +refund wallet is fixed for the clone's lifetime; the account mapping (§1.4) refuses a +clone whose `recoveryAddress` is not this client's derived wallet. Record the forwarder address + deploy tx hash. ### 1.3 Manifest: generate, verify, publish @@ -112,7 +118,8 @@ overwrite. The keeper's onboarding step picks up every mapped `onboarding` account and, exactly-once via the profile-scoped `financial_operations` ledger: links the forwarder with the attestor signature (`POST /addresses` — HTTP 201, `state: linked`, zero client -interaction), then requests IBAN issuance (`POST /ibans`, async 202). The IBAN lands on +interaction), links the client's refund wallet with its own signature, then requests IBAN +issuance for the forwarder (`POST /ibans`, async 202). The IBAN lands on the account row via the `iban.updated` webhook; from then on the association monitor treats the DB record as the reference state. Nothing to do manually — verify the row has its IBAN before activation, and check the logs if it stays empty for more than @@ -194,8 +201,8 @@ Suspected vulnerability in `VortexForwarder`/factory: output, or `cast call "balanceOf(address)" `); run the manifest verifier against the live deployment. 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 + (§2.7); once each clone's batch is 2 h old the keeper moves the funds to that + client's refund 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 @@ -250,23 +257,25 @@ deferrals become routine; both are instant. The ladder itself 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. +client's refund wallet (the clone's `recoveryAddress()`) is linked to the client's +Monerium profile (onboarding does this, §1.5), `MONERIUM_B2B_REFUND_SEED` and the EURe +float wallet's key are in the operator's custody, and the float holds EURe and some ETH +(it also pays the refund wallet's gas). **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). +`auto` marks them, and — with `MONERIUM_B2B_FLOAT_PRIVATE_KEY` set and the seed deriving +the clone's `recoveryAddress` — 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). +from that phase. While one refund is `recovery_failed` the queue waits (one refund at a +time). 1. **Mark the deposit.** `POST /v1/admin/monerium-b2b/deposits//recover` (`Authorization: Bearer $ADMIN_SECRET`). Refused (409) while a keeper transaction for @@ -282,20 +291,22 @@ from that phase. While one refund is `recovery_failed` the queue waits (one wall 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 +3. **Swap the USDC back** from the client's refund wallet (its key derived from the seed + and the Monerium profile ID; fund it with a little ETH first) 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 + Chainlink-derived minimum, 60 bps tolerance), or leave the USDC in the refund 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 +4. **Top up from the float:** transfer `issueAmount − EURe on the refund wallet` EURe + from the float wallet to the refund 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`, +5. **Redeem the exact amount.** `POST /orders` from the refund 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`. + signed by the refund wallet's key; attach `supportingDocumentId` above EUR 15,000 (the + same client agreement can be reused, Monerium 2026-09-30). Monerium pays the refund + out of the client's own IBAN. 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, @@ -389,8 +400,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`/`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 | +| 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 client's refund 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 | +| Refund seed (`MONERIUM_B2B_REFUND_SEED`) | Derives every client's refund wallet; each holds funds only between that client's `recover` and its bank refund, and can redeem them out of the client's IBAN to any IBAN | Set `MONERIUM_B2B_AUTO_RECOVERY=off`, finish or reconcile open refunds by hand, rotate the seed, then give every client a new clone with its new refund wallet and move the IBANs (§5); the old wallets hold nothing between refunds | | 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 | @@ -533,7 +544,6 @@ fixtures: | `MAX_FEE_PPM` | 10000 | | `MAX_REFERENCE_DEVIATION_BPS` | 100 | | `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)` | @@ -558,8 +568,10 @@ 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()`, +Deploy a client clone with the launch policy (1250 / 1500) as in §1.2, passing the +refund address the backend derives for the fixture's Monerium profile ID (the account +mapping verifies it). Use a fresh salt and record the predicted address and receipt. +Read back `destination()`, `recoveryAddress()`, `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 diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index c717791e3..ab2da8179 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -36,7 +36,7 @@ market reference, and sends the whole payment to the client's wallet as **one US transfer**. If a payment cannot be converted within **two hours**, Vortex refunds the **full EUR amount** to the bank account it came from. The forwarder can only ever pay three places: the client's fixed wallet, Vortex's fee treasury, and, for a refund after -the two-hour window, Vortex's recovery wallet. +the two-hour window, the client's own refund wallet, which Vortex holds. ## 2. Who is involved @@ -49,8 +49,8 @@ the two-hour window, Vortex's recovery wallet. | **Forwarder contract** | One per client on Ethereum. Receives the minted EURe, swaps it, holds the USDC until the payment is complete, then forwards it. | | **Subsidy vault** | A Vortex-funded USDC pool that tops up a swap when the market delivers less than the client's guaranteed floor. | | **Fee treasury** | Vortex multisig that receives the conversion fee. | -| **Recovery wallet and float wallet** | Two Vortex wallets used only for refunds. The recovery wallet receives a payment whose window was missed, and the float covers round-trip losses. | -| **Refund address** | **Proposed.** One Vortex address per client, linked to the client's Monerium profile, from which a refund is paid out through the client's own IBAN. Confirmed as technically possible by Monerium. | +| **Refund wallet** | One Vortex-held wallet per client, fixed in the client's forwarder at deployment and linked to the client's Monerium profile. A payment whose window was missed moves there, and the refund is paid out from it through the client's own IBAN. | +| **Float wallet** | A Vortex wallet that covers round-trip losses so a refund is always the exact amount, and pays the refund wallets' gas. | | **Price sources** | Coinbase Exchange EURC-USDC market for the reference rate, Chainlink EUR/USD as the on-chain safety bound, Uniswap v3 where the swaps execute. | ## 3. The flow at a glance @@ -70,7 +70,7 @@ flowchart LR VAULT["Subsidy vault"] TREAS["Fee treasury"] DEST["Client wallet
fixed destination"] - REC["Vortex recovery wallet
and refund address"] + REC["Client refund wallet"] end subgraph Vx["Vortex"] KEEP["Keeper service"] @@ -106,8 +106,8 @@ subsidy on a swap, or the refund path. points at the forwarder**. Monerium mints to whatever address the IBAN points at, so this is what routes every payment through the conversion. An IBAN pointing at the client's own wallet would deliver EURe, not USDC. -- **Proposed:** a Vortex **refund address** is linked to the client's profile as well, so - a refund can leave from the client's own IBAN (section 8.3). +- The client's **refund wallet** is linked to the client's profile as well, so a refund + leaves from the client's own IBAN (section 8.3). - Vortex has mapped the client under **SulPayments' partner account**, so webhooks and API reads reach SulPayments. @@ -134,7 +134,7 @@ sequenceDiagram V->>M: Check the profile exists in SulPayments' app M-->>V: profile.updated, state approved V->>C: Deploy forwarder with destination and fee policy - V->>M: Link forwarder and refund address to the profile + V->>M: Link forwarder and refund wallet to the profile V->>M: Request the IBAN for the forwarder M-->>V: iban.updated, IBAN issued V->>M: Read the IBAN back from the API [V7] @@ -193,12 +193,13 @@ Notes on the flow: - SulPayments can read the account and its IBAN through the Vortex API with its manager key. There is no dashboard view **[V3]**. - The backend uses one Monerium credential pair, shared with Vortex's retail EUR onramp. -- Adopting the proposal changes five things. The B2B module gets its own credentials for +- Adopting the proposal changes four things. The B2B module gets its own credentials for SulPayments' app **[V8]**. SulPayments supplies the destination by profile ID through the new endpoint **[V6]**. Onboarding starts once the profile is approved and the - destination is registered **[V1]**. The keeper links a refund address next to the - forwarder **[V2]**. The IBAN and the payer's IBAN are read back from Monerium's API - **[V7]**. + destination is registered **[V1]**. The IBAN and the payer's IBAN are read back from + Monerium's API **[V7]**. +- The keeper already links the client's refund wallet next to the forwarder, built on + 2026-10-01. ### 4.4 Decisions behind onboarding @@ -254,9 +255,9 @@ sequenceDiagram address is linked to the client's profile, Monerium mints there instead of to the forwarder. Monerium's guide describes only that case, which implies an unlinked address is ignored and the payment mints to the forwarder as usual. The only other - address linked to a client profile is the proposed refund address, which Vortex + address linked to a client profile is the client's refund wallet, which Vortex controls. Clients are unlikely to use this, so it stays enabled, and a mint to a - refund address is handled by operations **[V9]**. + refund wallet is handled by operations **[V9]**. ## 6. Conversion: EURe to USDC @@ -340,7 +341,6 @@ flowchart TD - It was **not converted within two hours** of the mint. Typical causes are a market move beyond the bounds, thin liquidity, an exhausted subsidy budget, or an operational fault. -- It is **below the €1 minimum swap**. - An operator triggers it after a **compliance decision or an incident**. A payment is never partly delivered. If any part cannot be converted in time, the whole @@ -352,10 +352,9 @@ payment is refunded, including chunks that were already converted. sequenceDiagram participant V as Vortex keeper participant F as Forwarder - participant R as Recovery wallet + participant R as Client's refund wallet participant U as Uniswap participant FL as Float wallet - participant RA as Client's refund address participant M as Monerium participant B as Payer's bank participant SP as SulPayments @@ -365,23 +364,18 @@ sequenceDiagram F->>R: EURe and USDC, contract refuses before 2 hours R->>U: Swap USDC back to EURe FL->>R: Top up to the exact original amount - R->>RA: Exact EURe amount, proposed step - RA->>M: Redeem order to the payer's IBAN + R->>M: Redeem order to the payer's IBAN M->>B: SEPA payout from the client's IBAN M-->>V: order.updated, redemption processed - V-->>SP: DEPOSIT_RETURNED webhook + V-->>SP: DEPOSIT_RETURNED and DEPOSIT_UPDATED ``` -Everything up to the top-up is built. The hop through the client's refund address is the -proposal in section 8.3. As built today, the recovery wallet places the redeem itself, -from the Vortex/SatoshiPay company profile. - - The payer gets back the **exact EUR amount**. Losses from the round trip and fees already taken on converted chunks are Vortex's cost, paid from the float wallet. - The contract **enforces the two-hour window**. The forwarder cannot move funds to the - recovery wallet any earlier. -- With the proposal, the refund leaves from the **client's own IBAN**, in the client's - name, with a reference to the original payment. + refund wallet any earlier, and it can only move them to that one wallet. +- The refund leaves from the **client's own IBAN**, in the client's name, with a + reference to the original payment. - SEPA Instant is used when the payer's bank supports it, otherwise next business day. - Monerium requires a **supporting document** on redemptions above €15,000 and accepts the same agreement every time, so one standing document per client can be uploaded @@ -395,42 +389,41 @@ from the Vortex/SatoshiPay company profile. each refund. It switches to **automatic** after the first refund has been observed end to end. -### 8.3 Proposed: refund from the client's own IBAN - -A Monerium profile can have several linked addresses, and per Monerium's spec any linked -address can use the profile's IBAN for outgoing payments. An address belongs to exactly -one profile. The proposal builds on that: - -- At onboarding, Vortex links a second address to each client profile next to the - forwarder: a Vortex-controlled **refund address**, one per client. -- For a refund, the recovery wallet sends the exact EURe amount to that client's refund - address, which places the redeem order. The payer receives the refund from the - client's own IBAN, in the client's name. +### 8.3 Refund from the client's own IBAN + +A Monerium profile can have several linked addresses, any linked address can use the +profile's IBAN for outgoing payments, and an address belongs to exactly one profile. The +refund path builds on that, built on 2026-10-01: + +- Each client gets its own **refund wallet**: a plain Vortex-held wallet whose key is + derived from one Vortex secret and the client's Monerium profile ID. One secret covers + every client, and the wallet is known before the forwarder is deployed. +- The wallet is fixed in the client's forwarder at deployment as the only address a + recovery can pay, with no way to change it. Vortex refuses to map a forwarder whose + refund wallet is not the client's derived one. +- Onboarding links the wallet to the client's Monerium profile next to the forwarder. +- On a refund the forwarder sends the stuck EURe and USDC straight to that wallet, which + swaps back, receives the float's top-up, and places the redeem order. Monerium pays it + out of the client's own IBAN. +- The wallet pays gas for its own transactions; the float tops its ETH up before it + sends. +- No Vortex company profile at Monerium is needed, and different clients' refunds never + share a wallet. - The client authorizes Vortex to send these refunds in the SulPayments terms. -What it changes: - -- **No contract change.** The backend derives one refund key per client from a single - seed, links it at onboarding, and adds one transfer to the refund steps **[V2]**. -- **No company profile needed for refunds.** The recovery and float wallets no longer - need to be linked at Monerium, so the Vortex/SatoshiPay company profile stops being a - prerequisite for deploying the contracts. -- **Short holding time.** A refund address only ever holds the payment being refunded, - between the missed window and the payout. - -Status: agreed with Monerium on 2026-09-30 for the pilot. An address belongs to exactly -one profile, so one refund address per client is needed, and a redeem from it leaves -from the profile's IBAN. Not built yet **[V2]**. Later, each client may instead name a -fixed refund IBAN at onboarding, so every refund follows the same path **[V13]**. +Status: agreed with Monerium on 2026-09-30 for the pilot. Later, each client may instead +name a fixed refund IBAN at onboarding, so every refund follows the same path **[V13]**. +A refund contract that only pays that fixed IBAN would then replace the plain wallet, +which means a new forwarder for existing clients. Alternatives considered: -- **Built today:** the redeem is placed from the Vortex/SatoshiPay company profile. It - works without further changes, but the payer sees SatoshiPay as the sender, and one - company profile pays many unrelated payers. +- **One Vortex recovery wallet on a Vortex/SatoshiPay company profile.** The previous + design. The payer would see SatoshiPay as the sender, and one company profile would + pay many unrelated payers. - **Redeem signed by the forwarder contract.** The refund would also leave from the - client's IBAN, but it needs contract changes, an allowlist of payer IBANs per client, - and a new audit. + client's IBAN, but it needs an allowlist of payer IBANs per client and a larger + contract change. ## 9. Status, reporting and support @@ -510,9 +503,8 @@ How each stage is reported: | Refund needs an operator | recovery failed | `DEPOSIT_UPDATED` | | Account set up, IBAN issued, status changed | | `ACCOUNT_UPDATED`, and the accounts call | -Not included yet: the incoming payment's SEPA reference and the payer's name, and a -separate refund reason for a remainder below the minimum swap, which is reported as -`window_missed`. Add them when SulPayments needs them. +Not included yet: the incoming payment's SEPA reference and the payer's name. Add them +when SulPayments needs them. ### 9.4 Exceptions and escalation @@ -533,14 +525,13 @@ separate refund reason for a remainder below the minimum swap, which is reported - Change a client's fee policy within the 1% cap. Raising it takes effect only after a 24-hour on-chain notice. Lowering it is immediate. - Fund or limit its own subsidy budget. -- Move a payment to its recovery wallet, only after the two-hour window, to refund it. - As proposed, the refund is then paid out from the refund address Vortex holds on the - client's profile. +- Move a payment to the client's refund wallet, only after the two-hour window, to + refund it from the client's own IBAN. **Vortex cannot:** - Redirect funds. A forwarder pays only the client's fixed destination, the fee - treasury, and, after two hours, the recovery wallet. + treasury, and, after two hours, the client's own refund wallet. - Deliver a conversion below the Chainlink rate minus 0.6%. - Stop a client's conversion permanently. After 24 hours anyone can complete it. - Prevent incoming SEPA payments. Payments made during a pause wait safely as EURe and @@ -569,8 +560,8 @@ separate refund reason for a remainder below the minimum swap, which is reported ### 12.1 Assumptions (2026-09-30) -- Refunds start with per-client refund addresses. A fixed refund IBAN per client may - replace them later **[V13]**. +- Refunds run through per-client refund wallets. A fixed refund IBAN per client may + replace the dynamic payer IBAN later **[V13]**. - SulPayments delivers KYB directly to its own white-label app. Vortex never handles KYB data. - No SulPayments client has an existing Monerium profile. @@ -583,21 +574,21 @@ separate refund reason for a remainder below the minimum swap, which is reported | Topic | Answer | |---|---| | Whose white-label app | A dedicated app in SulPayments' Monerium account, under SulPayments' reliance agreement. Vortex uses that app's client ID and secret. | -| Onboarding steps | Confirmed: create profile, submit details, form and verifications, wait for `profile.updated` approved, link the forwarder and the refund address, request the IBAN. | +| Onboarding steps | Confirmed: create profile, submit details, form and verifications, wait for `profile.updated` approved, link the forwarder and the refund wallet, request the IBAN. | | KYB route and speed | Corporates use the reliance endpoints. Approval takes seconds when the data follows Monerium's guidelines. | | Profile visibility | Only the credentials of the app that onboarded a profile can read it. | | Readable profile data | Only the bare minimum, such as the name. Full KYB details are not returned. | | Payment notifications | `order.created` when the payment hits the IBAN, `order.updated` on state changes. Pending covers both minting and compliance review. | | IBAN | Only for an address already linked to the profile. It can be moved to another linked address. | | Batching | None. Each client is its own request. | -| Refund address | One address per profile, so one refund address per client. A redeem from it leaves from the profile's IBAN. | +| Refund wallet | One address per profile, so one refund wallet per client. A redeem from it leaves from the profile's IBAN. | | Supporting document above €15,000 | The same agreement can be reused every time. | | Refund limits and fees | None. Some refunds are reviewed during business hours. | | Account fee | €10 per corporate account, per the agreement. | | Partner apps | Each partner gets its own white-label app. SulPayments delivers KYB data and files directly; Vortex could proxy those calls later as tech provider. | | Held and rejected payments | Monerium's monitoring holds payments for review during office hours, contacts the payer directly if it needs documents, and returns the funds if it cannot mint them. | | Third-party payers | Allowed. Monerium watches transaction patterns so accounts are not misused. | -| Refund address approach | Agreed for the pilot. A fixed refund IBAN per client may replace it later. | +| Refund wallet approach | Agreed for the pilot. A fixed refund IBAN per client may replace it later. | | Communication | A joint Slack channel with Monerium is to be set up. | | SulPayments' onboarding at Monerium | Documents in review on 2026-09-30, onboarding starting 2026-10-01. | @@ -630,17 +621,17 @@ separate refund reason for a remainder below the minimum swap, which is reported | ID | Question or task | Depends on | Status | |---|---|---|---| | V1 | Start onboarding once the profile is approved and the destination is registered, whichever comes last. | V6, V8 | Open | -| V2 | Refunds through per-client refund addresses: one refund key per client derived from one seed, linked at onboarding, one extra transfer per refund. No contract change. | M1 | Agreed for the pilot, not built | +| V2 | Refunds through per-client refund wallets: derived from one seed, fixed in each forwarder as its recovery address, linked at onboarding. | None | Built 2026-10-01 | | V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Optional, since SulPayments integrates API-first. | S4 | Deprioritized | | V4 | Full lifecycle deposit events, section 9.3: snapshot event on every change, IDs, amounts, timestamps, hold and failure reasons, an account event, and the docs fix. | S7 | Built 2026-10-01 | | V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium, including the joint Slack channel with Monerium. | Meeting | Open | | V6 | Endpoint for SulPayments to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Proposed | | V7 | Read the IBAN and the payer's IBAN back from Monerium's API instead of trusting webhook payloads, and check in the sandbox whether listing subscriptions exposes their secrets. | None | Open | | V8 | Separate Monerium credentials for the B2B module, apart from the retail onramp, with one app and credential pair per partner. | S2 | Open | -| V9 | Detect a mint to a refund address routed by payment memo, and handle it as a refund. | V2 | Open | +| V9 | Detect a mint to a refund wallet routed by payment memo, and handle it as a refund. | V2 | Open | | V10 | Attach the standing agreement to refunds above €15,000 so they can run automatically. | V2 | Open | | V11 | Remove the unused `held` status. | None | Open | -| V13 | Later: a fixed refund IBAN per client, given at onboarding, replacing the per-client refund addresses. | V2 | Later | +| V13 | Later: a fixed refund IBAN per client, given at onboarding, with a refund contract that only pays that IBAN. | V2 | Later | | V12 | Partner account API: list all sub-accounts with IBAN and status, filterable by Monerium profile ID. | S4 | Built 2026-10-01 | ## 13. Related documents diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index ff375e144..87ca4d0f8 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -7,7 +7,7 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each **Provider type:** on-ramp (EUR → USDC) **Fiat currencies:** EUR **Chains involved:** Ethereum (forwarder contracts, EURe/USDC) -**Modules:** `monerium-b2b/monerium-api.ts` (narrow adapter over the shared white-label client, [monerium.md](./monerium.md)), `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `monerium-b2b/account-provisioning.ts`, `monerium-b2b/onboarding.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`), `controllers/admin/moneriumB2b.controller.ts` (POST `/v1/admin/monerium-b2b/accounts`, ADMIN_SECRET); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts`; monitoring: `monerium-b2b/monitoring.ts` +**Modules:** `monerium-b2b/monerium-api.ts` (narrow adapter over the shared white-label client, [monerium.md](./monerium.md)), `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `monerium-b2b/account-provisioning.ts`, `monerium-b2b/refund-wallet.ts`, `monerium-b2b/onboarding.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`), `controllers/admin/moneriumB2b.controller.ts` (POST `/v1/admin/monerium-b2b/accounts`, ADMIN_SECRET); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts`; monitoring: `monerium-b2b/monitoring.ts` **API auth method:** OAuth client credentials through the shared `MoneriumApiService` (`MONERIUM_WHITELABEL_CLIENT_ID`/`MONERIUM_WHITELABEL_CLIENT_SECRET`) against `MONERIUM_API_URL` (defaults to sandbox `api.monerium.dev` while `SANDBOX_ENABLED`, `api.monerium.app` otherwise); inbound webhooks authenticated by HMAC-SHA256 (`MONERIUM_B2B_WEBHOOK_SECRET`) ## Security Invariants @@ -35,13 +35,13 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 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, 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. +1. **Three-way key separation, bounded fund movement** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`, `swap()`, `forward()` and `recover()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can redirect funds: `swap` only executes a factory-whitelisted, oracle-floored swap whose USDC stays on the clone, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `forward` only ever pays the clone's own `destination`; `recover` only ever pays the clone's `recoveryAddress`, the client's refund wallet fixed at deployment with no setter, and only once the clone's batch marker has been open for `RECOVERY_DELAY` (the contract, not the keeper, enforces the promised window); `setGuardianPaused` is protective-only by contract invariant (it blocks swaps and forwards, never a recovery); the attestor signs the fixed link statement. Vortex therefore holds a client's funds only on the refund path, only in that client's own refund wallet, and only for a payment the promised window was missed on (adr-0005, amendments 2026-09-17 and 2026-10-01). All 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 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. -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. +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`). The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). 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 refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. ## Monitoring @@ -66,8 +66,8 @@ 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 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; one refund at a time; the key is env-only and must match the immutable `RECOVERY_WALLET` | +| **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 clone's fixed `recoveryAddress` (the client's derived refund wallet, verified at mapping), 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 | +| **Refund wallet or seed compromise** | A client's refund wallet is drained before the refund, or the seed leaks and exposes every client's wallet | Funds sit on a refund wallet only during that client's recovery; each wallet holds one client's in-flight refund and nothing else; one refund at a time; the seed is env-only and never logged; rotating it means new clones for every client (runbook §5 and §6) | | **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) | @@ -110,10 +110,10 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; 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 +- [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch - [ ] `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` is the factory queried during provisioning/monitoring, and a self-reported mismatch is rejected before local persistence - [ ] `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 +- [ ] `MONERIUM_B2B_REFUND_SEED` and `MONERIUM_B2B_FLOAT_PRIVATE_KEY` are env-only and validated as 32-byte values; the seed is required whenever the module is enabled, the float key in `auto` mode; mapping rejects a clone whose `recoveryAddress` is not the derived refund wallet, and the derived wallet equals the clone's `recoveryAddress` before any refund send From b0f72f144b90dccc7786a818d49733d988a8235d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 19:37:41 +0200 Subject: [PATCH 57/74] docs(repo): record the KYB and sandbox decisions in the B2B flow overview SulPayments calls Monerium's KYB endpoints itself without a SatoshiPay proxy, Monerium releases production credentials after test data in SulPayments' sandbox app, and the destination endpoint is planned for the week of 2026-10-05, as sent to SulPayments and Monerium on 2026-10-01. --- docs/product-monerium-b2b-flow.md | 54 +++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index ab2da8179..023bd0d98 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -1,7 +1,8 @@ # Monerium B2B Onramp: End-to-End Flow > **Status:** living overview, draft for alignment. Last updated 2026-10-01, including -> Monerium's written answers and the call of 2026-09-30. +> Monerium's written answers and the call of 2026-09-30, and the KYB and sandbox +> decisions sent to SulPayments and Monerium on 2026-10-01. > **Audience:** Vortex/SatoshiPay internally, SulPayments, and Monerium. > **Scope:** the EUR to USDC onramp for SulPayments' business clients, as built for the > pilot on the branch of PR #1375. It is not merged or deployed yet. Open questions carry @@ -156,20 +157,23 @@ Notes on the flow: orders, or change Vortex's webhook subscription. Closing a profile also closes its IBAN, so SulPayments coordinates closures with Vortex **[S2]**. - **KYB.** Companies must use Monerium's reliance route: SulPayments delivers company - details, form, verifications and files to Monerium directly. Vortex could later proxy - those calls as SulPayments' tech provider. Approval takes seconds when the data follows + details, form, verifications and files to Monerium directly. SulPayments calls + Monerium's KYB and profile endpoints itself; SatoshiPay does not proxy them (decided + 2026-10-01). Approval takes seconds when the data follows Monerium's corporate KYB guide. Vortex never handles KYB data, which matches the security spec. Reading a profile returns only the company name, so KYB details stay with SulPayments. -- **Destination handover by profile ID (proposed).** Creating a profile returns its ID. +- **Destination handover by profile ID (planned for the week of 2026-10-05).** Creating a + profile returns its ID. SulPayments then calls a new Vortex endpoint with that ID, the destination, its own client reference and a contact email **[V6]**. Vortex checks the profile exists in SulPayments' app, stores the destination, and deploys the forwarder once the profile is approved. The destination is create-only, because it is fixed in the contract; a change means a new account on SulPayments' written instruction. Vortex rejects zero, token and contract addresses, and exchange addresses need SulPayments' confirmation - that they do not rotate. Until the endpoint exists, the destination can come on the - signed onboarding form. + that they do not rotate. Until the endpoint is live, SulPayments sends the Monerium + profile ID and the destination to Vortex directly, and Vortex sets the account up + manually. - **Why the destination does not go through Monerium.** Monerium's profile API has no field for it, linking an address needs a signature from its owner, which exchange deposit addresses cannot give, and only the forwarder contract uses the destination. @@ -195,7 +199,7 @@ Notes on the flow: - The backend uses one Monerium credential pair, shared with Vortex's retail EUR onramp. - Adopting the proposal changes four things. The B2B module gets its own credentials for SulPayments' app **[V8]**. SulPayments supplies the destination by profile ID through - the new endpoint **[V6]**. Onboarding starts once the profile is approved and the + the new endpoint, planned for the week of 2026-10-05 **[V6]**. Onboarding starts once the profile is approved and the destination is registered **[V1]**. The IBAN and the payer's IBAN are read back from Monerium's API **[V7]**. - The keeper already links the client's refund wallet next to the forwarder, built on @@ -213,6 +217,27 @@ Notes on the flow: key, but only over Monerium's exact link message. That key has no power over the contract's funds. +### 4.5 Sandbox phase before production credentials + +Monerium releases the production credentials for SulPayments' white-label app once it +sees test data in SulPayments' sandbox app: approved test client profiles onboarded +through SulPayments' KYB integration, IBANs issued, and test payments processed (agreed +on the call of 2026-09-30). Monerium's sandbox runs on Ethereum Sepolia. + +SulPayments' steps **[S11]**: + +1. Create a white-label app in its Monerium sandbox account at sandbox.monerium.dev, + share the app's client ID and secret with Vortex, and onboard one or two test client + profiles through its KYB integration. +2. Get a test API key from dashboard-sandbox.vortexfinance.co, for the API at + api-sandbox.vortexfinance.co, and register its webhook endpoint there. +3. For each test profile, send Vortex the Monerium profile ID and a Sepolia destination + wallet that SulPayments controls. + +Vortex then sets up the forwarder, links it and the client's refund wallet, and requests +the IBAN. In a joint session the parties run three test payments: a normal one, a large +one that converts in several chunks, and one that is refunded. + ## 5. Payment in: SEPA to EURe ```mermaid @@ -562,12 +587,13 @@ when SulPayments needs them. - Refunds run through per-client refund wallets. A fixed refund IBAN per client may replace the dynamic payer IBAN later **[V13]**. -- SulPayments delivers KYB directly to its own white-label app. Vortex never handles - KYB data. +- SulPayments delivers KYB directly to its own white-label app and calls Monerium's KYB + endpoints itself; SatoshiPay does not proxy them. Vortex never handles KYB data. - No SulPayments client has an existing Monerium profile. - Monerium does not need to know or screen the client's final wallet. - Memo routing stays enabled, because clients are unlikely to use it. -- Testing runs in Vortex's own Monerium sandbox. +- Testing runs in SulPayments' Monerium sandbox app on Sepolia before Monerium releases + the production credentials (section 4.5). ### 12.2 Answered by Monerium (2026-09-30) @@ -585,7 +611,8 @@ when SulPayments needs them. | Supporting document above €15,000 | The same agreement can be reused every time. | | Refund limits and fees | None. Some refunds are reviewed during business hours. | | Account fee | €10 per corporate account, per the agreement. | -| Partner apps | Each partner gets its own white-label app. SulPayments delivers KYB data and files directly; Vortex could proxy those calls later as tech provider. | +| Partner apps | Each partner gets its own white-label app. SulPayments delivers KYB data and files directly; Vortex could proxy those calls later as tech provider. Decided 2026-10-01: no proxy, SulPayments calls the KYB endpoints itself. | +| Production credentials | Released after Monerium sees test data in SulPayments' sandbox app: approved test profiles onboarded through SulPayments' KYB integration, IBANs issued, test payments processed (section 4.5). | | Held and rejected payments | Monerium's monitoring holds payments for review during office hours, contacts the payer directly if it needs documents, and returns the funds if it cannot mint them. | | Third-party payers | Allowed. Monerium watches transaction patterns so accounts are not misused. | | Refund wallet approach | Agreed for the pilot. A fixed refund IBAN per client may replace it later. | @@ -606,7 +633,7 @@ when SulPayments needs them. | ID | Question or item to agree | Why it matters | Status | Answer | |---|---|---|---|---| | S1 | How many clients, and when? | Planning, and when the destination endpoint is needed | Open | | -| S2 | Share the white-label app's production credentials with Vortex, and agree the usage rules in section 4.2: no address links, IBAN requests or moves, orders, or changes to Vortex's webhook subscription, and profile closures coordinated with Vortex. | Protects where client payments are minted | Waiting on SulPayments' Monerium onboarding, starting 2026-10-01 | | +| S2 | Share the white-label app's production credentials with Vortex, and agree the usage rules in section 4.2: no address links, IBAN requests or moves, orders, or changes to Vortex's webhook subscription, and profile closures coordinated with Vortex. | Protects where client payments are minted | Production credentials follow the sandbox sign-off, section 4.5 | | | S3 | Hand over each destination through the new Vortex endpoint by Monerium profile ID. Who at SulPayments approves a destination? | The destination is fixed in the contract | Proposal to confirm | | | S4 | How does SulPayments get each client's IBAN? | API and dashboard scope | Answered 2026-09-30 | API-first: SulPayments' frontend fetches the IBAN from the Vortex API, section 9.3. | | S5 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target. Monerium allows third-party payers | Open | | @@ -615,6 +642,7 @@ when SulPayments needs them. | S8 | Is a two-hour window before a full refund right? Will clients authorize Vortex to refund from their IBAN? A Monerium review can delay a refund within business hours. | Refund terms in the agreement | Open | | | S9 | The agreement names a "Coinbase EURC oracle". The implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint. Is that what was meant? | Pricing terms | Open | | | S10 | Who bears Monerium's €10 per corporate account? | Commercial | Open | | +| S11 | Sandbox phase, section 4.5: create a white-label app in the Monerium sandbox and share its client ID and secret, onboard one or two test profiles through the KYB integration, get a test API key from dashboard-sandbox.vortexfinance.co and register the webhook endpoint, and send the Monerium profile ID and a Sepolia destination wallet per test profile. | Monerium releases production credentials after seeing this test data | Requested 2026-10-01 | | ### 12.5 Vortex internal @@ -625,7 +653,7 @@ when SulPayments needs them. | V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Optional, since SulPayments integrates API-first. | S4 | Deprioritized | | V4 | Full lifecycle deposit events, section 9.3: snapshot event on every change, IDs, amounts, timestamps, hold and failure reasons, an account event, and the docs fix. | S7 | Built 2026-10-01 | | V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium, including the joint Slack channel with Monerium. | Meeting | Open | -| V6 | Endpoint for SulPayments to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Proposed | +| V6 | Endpoint for SulPayments to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Planned to go live the week of 2026-10-05 | | V7 | Read the IBAN and the payer's IBAN back from Monerium's API instead of trusting webhook payloads, and check in the sandbox whether listing subscriptions exposes their secrets. | None | Open | | V8 | Separate Monerium credentials for the B2B module, apart from the retail onramp, with one app and credential pair per partner. | S2 | Open | | V9 | Detect a mint to a refund wallet routed by payment memo, and handle it as a refund. | V2 | Open | From 998d5fcdbc2c64d83890611e0dcb67e7699643e2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:37:06 +0200 Subject: [PATCH 58/74] docs(api): snapshot the Monerium B2B routes this branch mounts Staging's wire-contract gate now snapshots every mounted route; the four routes are additive. --- docs/api/wire-contract.snapshot.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 0e28b3003..51d4dd5a1 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -9018,6 +9018,9 @@ POST /v1/admin/managed-profile-managers/:profileId/managed-profiles POST /v1/admin/managed-profiles POST /v1/admin/monerium-b2b/accounts PATCH /v1/admin/monerium-b2b/accounts/:accountId/status +POST /v1/admin/monerium-b2b/deposits/:depositId/recover +PATCH /v1/admin/monerium-b2b/deposits/:depositId/status +GET /v1/admin/monerium-b2b/refund-address POST /v1/admin/partner-pricing-configs DELETE /v1/admin/partner-pricing-configs/:configId GET /v1/admin/partners/:partnerName/api-credentials @@ -9164,6 +9167,7 @@ POST /v1/managed-profiles/:profileId/api-credentials DELETE /v1/managed-profiles/:profileId/api-credentials/:credentialId GET /v1/metrics/volumes GET /v1/monerium-b2b/account +GET /v1/monerium-b2b/accounts GET /v1/monerium-b2b/deposits POST /v1/monerium-b2b/webhook POST /v1/monerium/iban/move From 69df0e981050a0f8906284f1865a7cb2a80db7c2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:38:14 +0200 Subject: [PATCH 59/74] docs(api): remove the accepted Monerium settlement proposal docs/README.md removes a proposal once accepted; ADR-0005's 2026-09-17 amendment holds the decisions and the architecture doc the behaviour. The proposal also still named a company-profile refund wallet, which per-client wallets replaced. --- docs/README.md | 1 - docs/adr-0005-monerium-b2b-onramp.md | 8 +- ...al-monerium-b2b-settlement-and-recovery.md | 404 ------------------ 3 files changed, 4 insertions(+), 409 deletions(-) delete mode 100644 docs/proposal-monerium-b2b-settlement-and-recovery.md diff --git a/docs/README.md b/docs/README.md index 1e20b15b3..7ce8da3ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,7 +35,6 @@ The smaller set of general project documents stays directly in `docs/`: | [`product-monerium-b2b-flow.md`](product-monerium-b2b-flow.md) | High-level end-to-end flow of the Monerium B2B onramp for Vortex, SulPayments and Monerium, with the open questions per party | | [`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/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index d7a9a5dfe..69b7bd80e 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -147,15 +147,15 @@ not yet deployed, so this replaced the flat fee before launch with no migration) 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)): +for that refund is agreed commercially. Decisions (the proposal that led here, +`proposal-monerium-b2b-settlement-and-recovery.md`, is in git history): - **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 + share a swap and the N:M attribution of 2026-08 is gone. Chosen over a shared + settlement 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** (the single company-profile wallet was replaced by per-client refund wallets, amendment 2026-10-01). `recover(eure, usdc)` is keeper-only, diff --git a/docs/proposal-monerium-b2b-settlement-and-recovery.md b/docs/proposal-monerium-b2b-settlement-and-recovery.md deleted file mode 100644 index 8777a772b..000000000 --- a/docs/proposal-monerium-b2b-settlement-and-recovery.md +++ /dev/null @@ -1,404 +0,0 @@ -# Proposal: whole-deposit settlement and automatic refund recovery (Monerium B2B onramp) - -**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 - -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 2906510de7a45e419b0dbfc6bacb3f55b1985bfd Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:38:41 +0200 Subject: [PATCH 60/74] docs(api): align the fork exercise with the ADR's swap limits The exercise table still deployed the pre-2026-09-29 floor, minimum and cap (25/250/25k EURe) although it tells the reader to use ADR-0005's values (P6/P7: 1/1/10k). With the minimum at the floor, the step that lowered it has no job left. --- docs/operations-monerium-b2b-runbook.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index b1cb4733e..e1c3d9848 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -547,20 +547,16 @@ fixtures: | `TRIGGER_DELAY` | 24 hours | | Initial route | EURe → EURC → USDC, 500 / 500 (packed path constructor argument) | | `RECOVERY_HASH` | `bytes32(0)` | -| `MIN_SWAP_FLOOR` | `25e18` | +| `MIN_SWAP_FLOOR` | `1e18` | | `CAP_CEILING` | `50000e18` | -| Initial `minSwapAmount` | `250e18` | -| Initial `perSwapCap` | `25000e18` | +| Initial `minSwapAmount` | `1e18` | +| Initial `perSwapCap` | `10000e18` | -After deployment, register Anvil account 1 as a keeper and lower the mutable minimum to -the immutable 25 EURe floor for this exercise: +After deployment, register Anvil account 1 as a keeper: ```bash cast send "$FACTORY" "setKeeper(address,bool)" "$KEEPER" true \ --private-key "$GUARDIAN_KEY" --rpc-url http://127.0.0.1:8545 - -cast send "$FACTORY" "setMinSwapAmount(uint256)" 25000000000000000000 \ - --private-key "$GUARDIAN_KEY" --rpc-url http://127.0.0.1:8545 ``` Deploy `VortexSubsidyVault` (USDC, account 3 as treasury, the factory, 5000 ppm, 200e6) @@ -574,7 +570,7 @@ mapping verifies it). Use a fresh salt and record the predicted address and rece Read back `destination()`, `recoveryAddress()`, `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 +reference from the live Coinbase ticker before each swap, so the backend needs outbound HTTPS during the run. ### 7.4 Create the local account fixture From a7f332b08d7505548c5a09aa45c4d17ee26e2513 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:39:45 +0200 Subject: [PATCH 61/74] refactor(api): fold the branch's one-column Monerium migrations 082-084 were never deployed: 084 dropped a column 079 added, and 082/083 each added one column to tables 079/081 already change. A fresh database ends with the same Monerium schema (columns and enums compared against the unfolded chain). 085 keeps its name, so development databases that ran the old files stay consistent; umzug ignores their orphaned 082-084 entries. --- ...79-add-conversion-reference-and-subsidy.ts | 20 +++++++++---------- .../081-monerium-recovery-automation.ts | 5 ++++- ...82-add-monerium-deposit-returned-marker.ts | 11 ---------- .../083-add-conversion-max-subsidy.ts | 15 -------------- .../084-drop-conversion-reference-window.ts | 14 ------------- 5 files changed, 14 insertions(+), 51 deletions(-) delete mode 100644 apps/api/src/database/migrations/082-add-monerium-deposit-returned-marker.ts delete mode 100644 apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts delete mode 100644 apps/api/src/database/migrations/084-drop-conversion-reference-window.ts 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 2207d52b8..18bcd794d 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,10 +1,10 @@ import { DataTypes, QueryInterface } from "sequelize"; -// 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. +// Every swap is priced against a partner reference rate (the Coinbase bid/ask midpoint) +// and may draw a subsidy from the vault up to the keeper's tier for the chunk +// (docs/architecture-monerium-b2b-onramp.md, fees section). The reference, the chosen +// route and the tier 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, @@ -14,10 +14,6 @@ export async function up(queryInterface: QueryInterface): Promise { allowNull: true, type: DataTypes.STRING(64) }); - await queryInterface.addColumn("monerium_conversion_executions", "reference_window_seconds", { - allowNull: true, - type: DataTypes.INTEGER - }); await queryInterface.addColumn("monerium_conversion_executions", "reference_at", { allowNull: true, type: DataTypes.DATE @@ -30,13 +26,17 @@ export async function up(queryInterface: QueryInterface): Promise { allowNull: true, type: DataTypes.DECIMAL(38, 0) }); + 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"); 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_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/database/migrations/081-monerium-recovery-automation.ts b/apps/api/src/database/migrations/081-monerium-recovery-automation.ts index b8d376b2c..f93e2b686 100644 --- a/apps/api/src/database/migrations/081-monerium-recovery-automation.ts +++ b/apps/api/src/database/migrations/081-monerium-recovery-automation.ts @@ -4,11 +4,13 @@ import { DataTypes, QueryInterface } from "sequelize"; // 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. +// float top-up and the Monerium redeem order, one recovery at a time. The DEPOSIT_RETURNED +// marker, like the received/converted markers, makes that event fire once per refund. 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.addColumn("monerium_fiat_deposits", "returned_event_at", { allowNull: true, type: DataTypes.DATE }); await queryInterface.createTable("monerium_recoveries", { attempts: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, @@ -43,6 +45,7 @@ export async function up(queryInterface: QueryInterface): Promise { 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", "returned_event_at"); 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/database/migrations/082-add-monerium-deposit-returned-marker.ts b/apps/api/src/database/migrations/082-add-monerium-deposit-returned-marker.ts deleted file mode 100644 index 8f0a054d9..000000000 --- a/apps/api/src/database/migrations/082-add-monerium-deposit-returned-marker.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/database/migrations/083-add-conversion-max-subsidy.ts b/apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts deleted file mode 100644 index b880ed7dd..000000000 --- a/apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/database/migrations/084-drop-conversion-reference-window.ts b/apps/api/src/database/migrations/084-drop-conversion-reference-window.ts deleted file mode 100644 index 552001797..000000000 --- a/apps/api/src/database/migrations/084-drop-conversion-reference-window.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 - }); -} From d20ef33116771da7bd8f857e94aef6dec3e89d24 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:40:43 +0200 Subject: [PATCH 62/74] refactor(api): drop the forwarder ABI's undecoded event entries Receipts and getLogs recovery decode through the standalone event items; nothing passed the forwarderAbi copies to a decoder, and keeping both in sync was a manual rule. --- .../src/api/services/monerium-b2b/chain.ts | 51 ++----------------- 1 file changed, 3 insertions(+), 48 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index 91080f2ac..66a6509cc 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -54,8 +54,8 @@ export const NOTIFY_CONFIRMATION_DEPTH = 32; export const eureTransferEvent = parseAbiItem("event Transfer(address indexed from, address indexed to, uint256 value)"); -// Standalone event items for getLogs-based crash recovery, one per keeper transaction -// kind (must stay in sync with the entries in forwarderAbi below). +// The forwarder events the keeper decodes (receipts and getLogs-based crash recovery), one +// per keeper transaction kind. export const swapExecutedEvent = parseAbiItem( "event SwapExecuted(address indexed caller, uint256 routeIndex, uint256 eureIn, uint256 usdcOut, uint256 referenceRate, uint256 fee, uint256 subsidy)" ); @@ -166,52 +166,7 @@ export const forwarderAbi = [ type: "function" }, { inputs: [], name: "targetPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, - { inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, - { - anonymous: false, - inputs: [{ indexed: false, name: "batchOpenedAt", type: "uint64" }], - name: "Poked", - type: "event" - }, - { - 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" } - ], - 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" }], - name: "GuardianPausedSet", - type: "event" - } + { inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" } ] as const; export const factoryAbi = [ From 2fccbb32932824c0454a4cf2495c3b74c0255c31 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:42:40 +0200 Subject: [PATCH 63/74] refactor(api): parse the Coinbase top of book once parseTicker, spreadBps and computeMid parsed bid and ask three times for one caller, and the zero-midpoint check could not fire after the empty-book check. The fetch seam existed only for tests, which now stub global fetch; the same tests pass against the old and new code, with every error message unchanged. --- .../monerium-b2b/reference-rate.test.ts | 106 +++++++++--------- .../services/monerium-b2b/reference-rate.ts | 70 ++++-------- 2 files changed, 71 insertions(+), 105 deletions(-) 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 1d4320290..e698d8d4c 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,17 +1,18 @@ -import { describe, expect, it } from "bun:test"; -import { - classifyReferenceVenue, - computeMid, - fetchCoinbaseProductStatus, - fetchCoinbaseReference, - isWithinReferenceBand, - parseTicker, - spreadBps -} from "./reference-rate"; +import { afterEach, describe, expect, it, mock, setSystemTime, spyOn } from "bun:test"; +import { classifyReferenceVenue, fetchCoinbaseProductStatus, fetchCoinbaseReference, isWithinReferenceBand } from "./reference-rate"; // 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. +function serve(body: unknown, status = 200) { + return spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(body), { status })); +} + +afterEach(() => { + mock.restore(); + setSystemTime(); +}); + describe("isWithinReferenceBand", () => { it("mirrors the contract's symmetric band around Chainlink", () => { const oracle = 114_000_000n; @@ -23,50 +24,47 @@ describe("isWithinReferenceBand", () => { }); }); -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"); - }); - - 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("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({ + it("reads the EURC-USDC ticker midpoint at the oracle's decimals as the reference", async () => { + setSystemTime(new Date(1_800_000_000_000)); + const fetchSpy = serve({ ask: "1.1475", bid: "1.1471", price: "1.1472", volume: "12.5" }); + expect(await fetchCoinbaseReference(8)).toEqual({ price: "1.1473", rateRaw: 114_730_000n, source: "coinbase-exchange:EURC-USDC:mid", time: new Date(1_800_000_000_000) }); + expect(fetchSpy.mock.calls[0][0]).toBe("https://api.exchange.coinbase.com/products/EURC-USDC/ticker"); }); - 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"); + it("floors the midpoint to the unit", async () => { + serve({ ask: "1.00000002", bid: "1.00000001" }); + expect((await fetchCoinbaseReference(8)).rateRaw).toBe(100_000_001n); }); - 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"); + it("defers on a book wider than 50 bps", async () => { + serve({ ask: "1.1530", bid: "1.1470" }); + await expect(fetchCoinbaseReference(8)).rejects.toThrow("Coinbase EURC-USDC spread of 52 bps exceeds 50 bps"); + }); + + it("defers on an inverted or empty book", async () => { + serve({ ask: "1.1470", bid: "1.1475" }); + await expect(fetchCoinbaseReference(8)).rejects.toThrow( + "Coinbase top of book is inverted or empty (bid 1.1475, ask 1.1470)" + ); + mock.restore(); + serve({ ask: "1.0", bid: "0" }); + await expect(fetchCoinbaseReference(8)).rejects.toThrow("Coinbase top of book is inverted or empty (bid 0, ask 1.0)"); + }); + + it("defers on a bad status or anything but two positive decimals", async () => { + serve(null, 503); + await expect(fetchCoinbaseReference(8)).rejects.toThrow("Coinbase ticker responded 503"); + for (const body of [{ price: "1.1472" }, { ask: "1.1475" }, { ask: "abc", bid: "1.1471" }, { ask: 1.1475, bid: 1.1471 }, null]) { + mock.restore(); + serve(body); + await expect(fetchCoinbaseReference(8)).rejects.toThrow("Coinbase ticker response is malformed"); + } }); }); @@ -78,16 +76,14 @@ describe("reference venue status", () => { }); 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" - ); + const fetchSpy = serve({ id: "EURC-USDC", status: "online", trading_disabled: false }); + expect(await fetchCoinbaseProductStatus()).toEqual({ status: "online", tradingDisabled: false }); + expect(fetchSpy.mock.calls[0][0]).toBe("https://api.exchange.coinbase.com/products/EURC-USDC"); + mock.restore(); + serve({ status: "online" }); + await expect(fetchCoinbaseProductStatus()).rejects.toThrow("Coinbase product response is malformed"); + mock.restore(); + serve(null, 503); + await expect(fetchCoinbaseProductStatus()).rejects.toThrow("Coinbase product responded 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 9659a3664..69431078d 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -41,63 +41,33 @@ export function isWithinReferenceBand(rateRaw: bigint, oracleRaw: bigint, bandBp return rateRaw + tolerance >= oracleRaw && rateRaw <= oracleRaw + tolerance; } -export interface TopOfBook { - ask: string; - bid: string; -} +const DECIMAL = /^\d+(\.\d+)?$/; -/** 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; +/** Reads the ticker and returns the midpoint. Any failure throws; the caller defers. */ +export async function fetchCoinbaseReference(decimals: number): Promise { + const time = new Date(); + const response = await fetch(COINBASE_EURC_TICKER_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Coinbase ticker responded ${response.status}`); + } + const ticker = (await response.json()) 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)) { + if (typeof bid !== "string" || typeof ask !== "string" || !DECIMAL.test(bid) || !DECIMAL.test(ask)) { throw new Error("Coinbase ticker response is malformed"); } - return { ask, bid }; -} - -/** 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})`); + const bidRaw = parseUnits(bid, decimals); + const askRaw = parseUnits(ask, decimals); + if (bidRaw <= 0n || askRaw < bidRaw) { + throw new Error(`Coinbase top of book is inverted or empty (bid ${bid}, ask ${ask})`); } - const mid = (bid + ask) / 2n; - return Number(((ask - bid) * 10_000n) / mid); -} - -/** 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 = ( - url: string, - init?: { signal?: AbortSignal } -) => Promise<{ ok: boolean; status: number; json(): Promise }>; - -/** 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 { - 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 book = parseTicker(await response.json()); - const spread = spreadBps(book, decimals); + // The midpoint, floored to the unit; the spread in bps of it (floored). + const rateRaw = (bidRaw + askRaw) / 2n; + const spread = Number(((askRaw - bidRaw) * 10_000n) / rateRaw); if (spread > MAX_SPREAD_BPS) { throw new Error(`Coinbase ${COINBASE_REFERENCE_PRODUCT} spread of ${spread} bps exceeds ${MAX_SPREAD_BPS} bps`); } - const rateRaw = computeMid(book, decimals); - if (rateRaw <= 0n) { - throw new Error(`Coinbase ${COINBASE_REFERENCE_PRODUCT} midpoint is zero`); - } - return { price: formatUnits(rateRaw, decimals), rateRaw, source: COINBASE_REFERENCE_SOURCE, time: new Date(nowMs) }; + return { price: formatUnits(rateRaw, decimals), rateRaw, source: COINBASE_REFERENCE_SOURCE, time }; } // ------------------------------------------------------------------ venue status @@ -119,8 +89,8 @@ export function classifyReferenceVenue(product: CoinbaseProductStatus): string | } /** 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) }); +export async function fetchCoinbaseProductStatus(): Promise { + const response = await fetch(COINBASE_EURC_PRODUCT_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!response.ok) { throw new Error(`Coinbase product responded ${response.status}`); } From c0af16eda201851d8905c8327ef10935c8b6dcc8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:44:43 +0200 Subject: [PATCH 64/74] refactor(api): reuse setDepositStatus in the admin deposit-status endpoint The handler re-implemented the locked forward-only transition the refund path already had. setDepositStatus now returns the refusal instead of dropping it; status codes, error codes and messages are unchanged and now pinned by the endpoint test. The handler's account 404 could not fire: deposits.account_id is NOT NULL with a restricting foreign key. --- .../admin/moneriumB2b.controller.test.ts | 12 +++++++++- .../admin/moneriumB2b.controller.ts | 21 ++++-------------- .../src/api/services/monerium-b2b/recovery.ts | 22 +++++++++++++------ 3 files changed, 30 insertions(+), 25 deletions(-) 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 65f0e3241..6419f2065 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -389,8 +389,18 @@ describe("monerium b2b account mapping admin route", () => { 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 refunded.json()).toEqual({ deposit: { depositId: deposit.id, status: "refunded" } }); + expect((await patchStatus(deposit.id, "refunded")).status).toBe(200); + const reopened = await patchStatus(deposit.id, "recovering"); + expect(reopened.status).toBe(409); + expect(await reopened.json()).toMatchObject({ + error: { + code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", + message: "Monerium deposit cannot transition from refunded to recovering" + } + }); expect((await MoneriumFiatDeposit.findByPk(deposit.id))?.status).toBe(MoneriumFiatDepositStatus.Refunded); + expect((await patchStatus(crypto.randomUUID(), "refunded")).status).toBe(404); expect((await recover(crypto.randomUUID())).status).toBe(404); expect((await recover("not-a-uuid")).status).toBe(400); diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts index 17bafe1a1..3e8f89a14 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -8,7 +8,7 @@ import { UUID_PATTERN } from "../../helpers/uuid"; 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"; +import { setDepositStatus } from "../../services/monerium-b2b/recovery"; import { refundAccountFor } from "../../services/monerium-b2b/refund-wallet"; export async function postMoneriumB2bAccount(req: Request, res: Response): Promise { @@ -200,23 +200,10 @@ export async function patchMoneriumB2bDepositStatus(req: Request<{ depositId: st sendError(res, httpStatus.NOT_FOUND, "MONERIUM_B2B_DEPOSIT_NOT_FOUND", "Monerium deposit not found"); return; } - const account = await MoneriumAccount.findByPk(deposit.accountId); - if (!account) { - sendError(res, httpStatus.NOT_FOUND, "MONERIUM_B2B_ACCOUNT_NOT_FOUND", "Monerium account 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") { - sendError(res, httpStatus.CONFLICT, "MONERIUM_B2B_INVALID_STATUS_TRANSITION", outcome); + const refusal = await setDepositStatus(deposit, targetStatus); + if (refusal) { + sendError(res, httpStatus.CONFLICT, "MONERIUM_B2B_INVALID_STATUS_TRANSITION", refusal); return; } res.status(httpStatus.OK).json({ deposit: { depositId: deposit.id, status: targetStatus } }); diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index e96c132bc..688f471f8 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -152,8 +152,8 @@ export interface RecoveryDeps { 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; + /** Forward-only deposit transition under the forwarder lock (a no-op for an illegal edge; the refund path ignores the refusal). */ + setDepositStatus(deposit: MoneriumFiatDeposit, status: MoneriumFiatDepositStatus): Promise; now(): Date; } @@ -333,14 +333,22 @@ export async function activeRecoveryExists(): Promise { return rows.length > 0; } -export async function setDepositStatus(deposit: MoneriumFiatDeposit, status: MoneriumFiatDepositStatus): Promise { +/** Forward-only status change under the forwarder lock: why it was refused, or null once the deposit has `status`. */ +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 => { + if (!account) return "Monerium account not found"; + return withForwarderLock(account.forwarderAddress, async transaction => { const current = await MoneriumFiatDeposit.findByPk(deposit.id, { transaction }); - if (current && isForwardTransition(current.status, status)) { - await current.update({ status }, { transaction }); + if (!current) return "missing"; + if (current.status === status) return null; + if (!isForwardTransition(current.status, status)) { + return `Monerium deposit cannot transition from ${current.status} to ${status}`; } + await current.update({ status }, { transaction }); + return null; }); } From 141ceca674c7e9b791b6e9d89fd6d442fba932f5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:46:25 +0200 Subject: [PATCH 65/74] refactor(api): drop settlement and pricing fields only tests read The executor reads the remaining EURe, the net USDC and the last swap time from a deposit's settlement state, and the tier cap, reference and route from a priced swap; swaps, convertedEureRaw and projection were returned for test assertions only. projectSwap keeps its own unit tests. --- .../services/monerium-b2b/conversion-executor.test.ts | 7 +++---- .../api/services/monerium-b2b/conversion-executor.ts | 10 +++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 65ec6ea06..265fa3321 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 @@ -113,7 +113,7 @@ describe("settlementState", () => { swapRow(60n * EUR, 65n * USDC), swapRow(30n * EUR, 32n * USDC) ]); - expect(state).toMatchObject({ convertedEureRaw: 90n * EUR, remainingEureRaw: 10n * EUR, usdcNetRaw: 97n * USDC }); + expect(state).toMatchObject({ remainingEureRaw: 10n * EUR, usdcNetRaw: 97n * USDC }); }); it("never reports a negative remainder", () => { @@ -662,7 +662,7 @@ describe("pricePlannedSwap", () => { 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", maxSubsidyRaw: 5_700_000n, projection: null, reference, routeIndex: 0 }); + expect(await price()).toEqual({ kind: "ready", maxSubsidyRaw: 5_700_000n, reference, routeIndex: 0 }); }); it("defers when no route can be quoted", async () => { @@ -670,12 +670,11 @@ describe("pricePlannedSwap", () => { expect(await price()).toEqual({ code: "no_route", kind: "defer", reason: "no enabled swap route could be quoted" }); }); - it("picks the route with the highest quote and projects its settlement", async () => { + it("picks the route with the highest quote", async () => { 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 }); 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 e4843a46e..3ab2802ea 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -269,11 +269,8 @@ export function planChunk(remaining: bigint, minSwapAmount: bigint, perSwapCap: // ------------------------------------------------------------------ deposit bookkeeping 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. */ usdcNetRaw: bigint; @@ -291,7 +288,7 @@ export function settlementState( (latest, swap) => (swap.updatedAt && (!latest || swap.updatedAt > latest) ? swap.updatedAt : latest), null ); - return { convertedEureRaw, lastSwapAt, remainingEureRaw: remainingEureRaw < 0n ? 0n : remainingEureRaw, swaps, usdcNetRaw }; + return { lastSwapAt, remainingEureRaw: remainingEureRaw < 0n ? 0n : remainingEureRaw, usdcNetRaw }; } async function loadSettlementState(deposit: MoneriumFiatDeposit, transaction?: Transaction): Promise { @@ -731,7 +728,6 @@ export type PlannedSwap = kind: "ready"; /** The tier cap in USDC (6 decimals): the `maxSubsidy` argument of the swap. */ maxSubsidyRaw: bigint; - projection: SwapProjection | null; reference: ReferenceQuote; routeIndex: number; }; @@ -803,7 +799,7 @@ export async function pricePlannedSwap( return deferSwap("no_route", "the factory has no enabled swap route"); } if ((await getChainId()) !== 1) { - return { kind: "ready", maxSubsidyRaw, projection: null, reference, routeIndex: routes[0].index }; + return { kind: "ready", maxSubsidyRaw, reference, routeIndex: routes[0].index }; } const quotes = await quoteRoutes(routes, amountIn); if (quotes.length === 0) { @@ -836,7 +832,7 @@ export async function pricePlannedSwap( `monerium-b2b: priced swap of ${amountIn} on route ${best.index}: quoted ${best.quotedOut}, ` + `reference ${reference.price}, fee ${projection.fee}, subsidy ${projection.subsidy}, tier ${maxSubsidyBps} bps` ); - return { kind: "ready", maxSubsidyRaw, projection, reference, routeIndex: best.index }; + return { kind: "ready", maxSubsidyRaw, reference, routeIndex: best.index }; } // ------------------------------------------------------------------ action planning From b17a7483a0135fb9168646c833086695c538e768 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:47:02 +0200 Subject: [PATCH 66/74] refactor(api): build the float wallet client per call Like the per-client refund wallet clients, a viem wallet client over a plain private-key account holds no nonce or other state, so the module-level cache bought nothing. --- apps/api/src/api/services/monerium-b2b/chain.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index 66a6509cc..be869641d 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -214,7 +214,6 @@ export type KeeperWalletClient = WalletClient; let publicClientCache: PublicClient | null = null; let keeperClientCache: KeeperWalletClient | null = null; let guardianClientCache: KeeperWalletClient | null = null; -let floatClientCache: KeeperWalletClient | null = null; let privateRpcWarned = false; export function isKeeperChainConfigured(): boolean { @@ -298,16 +297,8 @@ export function getRefundWalletClient(account: Account): KeeperWalletClient { /** 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; + const key = config.moneriumB2b.floatPrivateKey; + return key ? createWalletClient({ account: privateKeyToAccount(key as Hex), transport: http(submissionRpcUrl()) }) : null; } // ------------------------------------------------------------------ cached chain lookups From a5e7c5059cf86f06a82761e42c971f7118200002 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:47:50 +0200 Subject: [PATCH 67/74] refactor(api): share the executor's settling statuses and receipt helpers The worker re-listed the settling statuses the executor plans from, and the refund path copied errorText and the receipt timeout; a status added to one list but not the other would strand deposits. --- .../api/services/monerium-b2b/conversion-executor.ts | 6 +++--- apps/api/src/api/services/monerium-b2b/recovery.ts | 7 +------ apps/api/src/api/workers/monerium-b2b.worker.ts | 11 ++--------- 3 files changed, 6 insertions(+), 18 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 3ab2802ea..7cb702c8c 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -58,7 +58,7 @@ const RETRY_BASE_MS = 60_000; const RETRY_MAX_MS = 60 * 60_000; /** How long one cycle waits for the receipt before deferring to the next cycle. */ -const RECEIPT_TIMEOUT_MS = 3 * 60_000; +export const RECEIPT_TIMEOUT_MS = 3 * 60_000; /** * A nonce-less pending row is a live pre-send reservation until this deadline. The @@ -74,7 +74,7 @@ const RECOVERY_LOG_BLOCK_RANGE = 2000n; const RECOVERY_ELIGIBILITY_MARGIN_MS = 30_000; /** Deposit states the keeper still has work for. */ -const SETTLING_STATUSES = [ +export const SETTLING_STATUSES = [ MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Converting, MoneriumFiatDepositStatus.Recovering @@ -326,7 +326,7 @@ async function settlingDeposits(accountId: string, transaction?: Transaction): P // ------------------------------------------------------------------ finalization -function errorText(error: unknown): string { +export function errorText(error: unknown): string { return (error instanceof Error ? error.message : String(error)).slice(0, 500); } diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index 688f471f8..b1c445752 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -24,7 +24,7 @@ import { readEnabledRoutes, swapRouter02Abi } from "./chain"; -import { markDepositForRecovery } from "./conversion-executor"; +import { errorText, markDepositForRecovery, RECEIPT_TIMEOUT_MS } from "./conversion-executor"; import { isForwardTransition, withForwarderLock } from "./deposit-processor"; import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; import { refundAccountFor } from "./refund-wallet"; @@ -59,7 +59,6 @@ export const SUPPORTING_DOCUMENT_THRESHOLD_EUR = 15_000; /** Gas the refund wallet's own transactions use (approve, reverse swap, surplus transfer), with margin. */ const REFUND_WALLET_GAS_UNITS = 400_000n; const MAX_ATTEMPTS = 5; -const RECEIPT_TIMEOUT_MS = 3 * 60_000; const EURE_DECIMALS = 18; const USDC_DECIMALS = 6; const BPS = 10_000n; @@ -584,10 +583,6 @@ export async function driveRecovery( } } -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. diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index d539d38ff..5e333ae77 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -3,9 +3,8 @@ 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"; +import { runConversionExecutor, SETTLING_STATUSES } 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"; @@ -112,13 +111,7 @@ class MoneriumB2bWorker { WHERE status IN (:settling) AND block_number IS NOT NULL`, { - replacements: { - settling: [ - MoneriumFiatDepositStatus.Minted, - MoneriumFiatDepositStatus.Converting, - MoneriumFiatDepositStatus.Recovering - ] - }, + replacements: { settling: SETTLING_STATUSES }, type: QueryTypes.SELECT } ); From 3f755d83b0be974e1355db7e5c53b47142cbcb35 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:48:47 +0200 Subject: [PATCH 68/74] refactor(api): slice the two reversible route layouts directly The factory admits only one- and two-hop paths, so reversing them needs no byte walker; the new form matched the old on 2,000 random paths of both lengths and keeps its unit tests and error message. --- .../src/api/services/monerium-b2b/recovery.ts | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index b1c445752..35240516f 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -65,31 +65,17 @@ 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. */ +/** + * Reverses a packed Uniswap V3 path so the same pools run the other way. The factory only + * admits token(20) fee(3) token(20) and token fee token fee token, so the two hex layouts + * are sliced directly. + */ 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; + const hex = path.slice(2); + if (hex.length === 86) return `0x${hex.slice(46)}${hex.slice(40, 46)}${hex.slice(0, 40)}`; + if (hex.length === 132) + return `0x${hex.slice(92)}${hex.slice(86, 92)}${hex.slice(46, 86)}${hex.slice(40, 46)}${hex.slice(0, 40)}`; + throw new Error(`unexpected packed path length ${hex.length / 2}`); } /** From cc72882197ff4bf5c8fc65ff39381aa18cb83394 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:49:35 +0200 Subject: [PATCH 69/74] refactor(repo): check the forwarder's batch age without inline assembly _requireBatchAge reverted through mstore/revert to pass the error selector in; a bool helper with plain reverts at its three callers raises the same selectors under the same conditions, as the existing NotAuthorizedYet and DelayNotElapsed tests confirm. --- .../monerium-forwarder/src/VortexForwarder.sol | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index 402febe2a..34b02163a 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -369,7 +369,7 @@ contract VortexForwarder { whenNotPaused { bool privileged = _privileged(); - if (!privileged) _requireBatchAge(TRIGGER_DELAY, NotAuthorizedYet.selector); + if (!privileged && !_batchOlderThan(TRIGGER_DELAY)) revert NotAuthorizedYet(); if (amountIn < FACTORY.minSwapAmount()) revert BelowMinimum(); if (amountIn > FACTORY.perSwapCap() || amountIn > EURE.balanceOf(address(this))) revert InvalidAmount(); @@ -508,7 +508,7 @@ contract VortexForwarder { /// 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); + if (!_privileged() && !_batchOlderThan(TRIGGER_DELAY)) revert NotAuthorizedYet(); uint256 amount = USDC.balanceOf(address(this)); if (amount == 0) revert InvalidAmount(); _transfer(USDC, destination, amount); @@ -526,7 +526,7 @@ contract VortexForwarder { /// 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 (!_batchOlderThan(RECOVERY_DELAY)) revert DelayNotElapsed(); if (eureAmount == 0 && usdcAmount == 0) revert InvalidAmount(); if (eureAmount > EURE.balanceOf(address(this)) || usdcAmount > USDC.balanceOf(address(this))) { revert InvalidAmount(); @@ -600,15 +600,9 @@ contract VortexForwarder { 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 Whether the batch marker is armed and older than `delay`. + function _batchOlderThan(uint256 delay) internal view returns (bool) { + return batchOpenedAt != 0 && block.timestamp - batchOpenedAt >= delay; } /// @dev The floor is the worse-for-the-client bound, so it may never sit above the From 57b58d7395dd5913c1ee49c6246f85a74b5a268f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 18:50:32 +0200 Subject: [PATCH 70/74] refactor(repo): pay a vault subsidy only to the calling clone The vault let its caller name any recipient, but its only caller, the forwarder, always passed address(this). Paying msg.sender makes that structural with one argument less; the contracts are not deployed, so the interface can still narrow. ADR-0005 and the security spec still described the recipient as the clone's destination, stale since subsidies started landing on the clone. --- .../src/VortexForwarder.sol | 4 +-- .../src/VortexSubsidyVault.sol | 10 +++---- .../test/VortexForwarder.t.sol | 2 +- .../test/VortexSubsidyVault.t.sol | 27 +++++++++---------- docs/adr-0005-monerium-b2b-onramp.md | 4 +-- .../05-integrations/monerium-b2b.md | 2 +- 6 files changed, 24 insertions(+), 25 deletions(-) diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index 34b02163a..f7e8e6eec 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -42,7 +42,7 @@ interface IVortexForwarderFactory { } interface IVortexSubsidyVault { - function pay(address to, uint256 amount, uint256 referenceOut) external; + function pay(uint256 amount, uint256 referenceOut) external; } /// @title VortexForwarder @@ -459,7 +459,7 @@ contract VortexForwarder { // The vault is guardian-settable without a timelock, so its word is not enough: // count the subsidy only once exactly that amount has landed here. uint256 before = USDC.balanceOf(address(this)); - IVortexSubsidyVault(vault).pay(address(this), subsidy, referenceOut); + IVortexSubsidyVault(vault).pay(subsidy, referenceOut); if (USDC.balanceOf(address(this)) - before != subsidy) revert SubsidyUnavailable(); return (0, subsidy); } diff --git a/contracts/monerium-forwarder/src/VortexSubsidyVault.sol b/contracts/monerium-forwarder/src/VortexSubsidyVault.sol index 513a1cc1d..3446ef4a1 100644 --- a/contracts/monerium-forwarder/src/VortexSubsidyVault.sol +++ b/contracts/monerium-forwarder/src/VortexSubsidyVault.sol @@ -27,7 +27,7 @@ contract VortexSubsidyVault { uint256 public spentToday; bool public paused; - event SubsidyPaid(address indexed forwarder, address indexed to, uint256 amount); + event SubsidyPaid(address indexed forwarder, uint256 amount); event MaxSubsidyPpmSet(uint32 value); event DailyBudgetSet(uint256 value); event PausedSet(bool paused); @@ -63,11 +63,11 @@ contract VortexSubsidyVault { dailyBudget = initialDailyBudget; } - /// @notice Pays `amount` USDC to `to` on behalf of the calling clone. Reverts — and + /// @notice Pays `amount` USDC to 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 { + function pay(uint256 amount, uint256 referenceOut) external { if (!FACTORY.isForwarder(msg.sender)) revert NotForwarder(); if (paused) revert VaultPaused(); if (amount > (referenceOut * maxSubsidyPpm) / PPM) revert SubsidyCapExceeded(); @@ -80,8 +80,8 @@ contract VortexSubsidyVault { if (spentToday + amount > dailyBudget) revert BudgetExhausted(); spentToday += amount; - _transfer(to, amount); - emit SubsidyPaid(msg.sender, to, amount); + _transfer(msg.sender, amount); + emit SubsidyPaid(msg.sender, amount); } // ----------------------------------------------------------- guardian authority diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 2c0b238d9..e11146086 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -98,7 +98,7 @@ 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 {} + function pay(uint256, uint256) external {} } contract VortexForwarderTest is Test { diff --git a/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol b/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol index bf863588d..bc44efe79 100644 --- a/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol +++ b/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol @@ -27,7 +27,6 @@ contract VortexSubsidyVaultTest is Test { 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 @@ -47,39 +46,39 @@ contract VortexSubsidyVaultTest is Test { function test_pay_onlyRegisteredForwarders() public { vm.prank(rando); vm.expectRevert(VortexSubsidyVault.NotForwarder.selector); - vault.pay(destination, 1e6, REFERENCE_OUT); + vault.pay(1e6, REFERENCE_OUT); factory.register(forwarder, false); vm.prank(forwarder); vm.expectRevert(VortexSubsidyVault.NotForwarder.selector); - vault.pay(destination, 1e6, REFERENCE_OUT); + vault.pay(1e6, REFERENCE_OUT); } function test_pay_transfersAndCountsAgainstTheDay() public { vm.prank(forwarder); - vault.pay(destination, 30e6, REFERENCE_OUT); - assertEq(usdc.balanceOf(destination), 30e6); + vault.pay(30e6, REFERENCE_OUT); + assertEq(usdc.balanceOf(forwarder), 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 + vault.pay(50e6, REFERENCE_OUT); // exactly the cap is fine vm.prank(forwarder); vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); - vault.pay(destination, 50e6 + 1, REFERENCE_OUT); + vault.pay(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 + vault.pay(50e6, REFERENCE_OUT); + vault.pay(50e6, REFERENCE_OUT); // budget fully used vm.expectRevert(VortexSubsidyVault.BudgetExhausted.selector); - vault.pay(destination, 1, REFERENCE_OUT); + vault.pay(1, REFERENCE_OUT); vm.warp((block.timestamp / 1 days + 1) * 1 days); // next UTC day - vault.pay(destination, 50e6, REFERENCE_OUT); + vault.pay(50e6, REFERENCE_OUT); assertEq(vault.spentToday(), 50e6); vm.stopPrank(); } @@ -88,14 +87,14 @@ contract VortexSubsidyVaultTest is Test { vault.setPaused(true); vm.prank(forwarder); vm.expectRevert(VortexSubsidyVault.VaultPaused.selector); - vault.pay(destination, 1e6, REFERENCE_OUT); + vault.pay(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); + vault.pay(1e6, REFERENCE_OUT); } function test_guardianAuthority_gated() public { @@ -116,7 +115,7 @@ contract VortexSubsidyVaultTest is Test { assertEq(vault.dailyBudget(), 1e6); vm.prank(forwarder); vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); - vault.pay(destination, 10e6 + 1, REFERENCE_OUT); // new cap: 10 USDC + vault.pay(10e6 + 1, REFERENCE_OUT); // new cap: 10 USDC } function test_withdraw_onlyEverReachesTreasury() public { diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 69b7bd80e..d991a84cd 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -114,8 +114,8 @@ 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, to the destination that clone - passes (its own immutable one), within a guardian-settable per-swap cap and UTC-daily + pays only when called by a factory-registered clone, to that clone itself, 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 diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 87ca4d0f8..41be13729 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -73,7 +73,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | **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 | +| **Subsidy vault drain** | A contract or key tries to pull vault USDC | `pay` accepts only factory-registered clones and pays only the calling clone, within cap and budget; the clone 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 ff53ebda088c3ef3169e245b5b2a7792242e297e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 5 Oct 2026 19:41:43 +0200 Subject: [PATCH 71/74] fix(api): wait for the refund transfer that was just sent ToppingUp waited on floatTopupTxHash ?? surplusTxHash. After a confirmed top-up, a later surplus sweep left the old top-up hash first in line, so the step returned at once, still saw the unswept surplus and sent the sweep again. Each send now clears the other transfer's hash; the earlier transfer's amount stays on the row. --- .../services/monerium-b2b/recovery.test.ts | 29 +++++++++++++++++++ .../src/api/services/monerium-b2b/recovery.ts | 11 +++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/recovery.test.ts b/apps/api/src/api/services/monerium-b2b/recovery.test.ts index afa7f60d6..2995c0b1e 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.test.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.test.ts @@ -228,6 +228,35 @@ describe("driveRecovery", () => { expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppedUp); }); + it("waits for a surplus sweep sent after a confirmed top-up, not for the top-up again", async () => { + // More EURe reached the wallet after an earlier top-up confirmed; the sweep of the + // excess lands only once its own receipt arrives, as on chain. + const ledger: Ledger = { eure: new Map([[RECOVERY, 103n * EUR]]), usdc: new Map() }; + const sweeps: bigint[] = []; + const deps = fakeDeps(ledger, { + sendEure: async (_from, _to, amount) => { + sweeps.push(amount); + return "0xsweeptx" as Hex; + }, + waitReceipt: async hash => { + if (hash === "0xsweeptx") ledger.eure.set(RECOVERY, 100n * EUR); + return "success"; + } + }); + const recovery = recoveryRow({ + floatTopupRaw: (1n * EUR).toString(), + floatTopupTxHash: "0xfloattx", + phase: MoneriumRecoveryPhase.Swapped + }); + const deposit = depositRow(); + + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppingUp); + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppedUp); + expect(sweeps).toEqual([3n * EUR]); + }); + 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]]) }; diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index 35240516f..6e3b074d1 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -437,10 +437,12 @@ export async function driveRecovery( ); return; } + // Only the transfer just sent may carry a hash: ToppingUp waits on whichever is set. await recovery.update({ floatTopupRaw: topUp.toString(), floatTopupTxHash: hash, - phase: MoneriumRecoveryPhase.ToppingUp + phase: MoneriumRecoveryPhase.ToppingUp, + surplusTxHash: null }); return; } @@ -458,7 +460,12 @@ export async function driveRecovery( ); return; } - await recovery.update({ phase: MoneriumRecoveryPhase.ToppingUp, surplusRaw: surplus.toString(), surplusTxHash: hash }); + await recovery.update({ + floatTopupTxHash: null, + phase: MoneriumRecoveryPhase.ToppingUp, + surplusRaw: surplus.toString(), + surplusTxHash: hash + }); return; } await recovery.update({ phase: MoneriumRecoveryPhase.ToppedUp }); From 101adfaceb522902599c506a2733722a61a56621 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 6 Oct 2026 17:42:53 +0200 Subject: [PATCH 72/74] docs(api): list every deposit event and register webhooks by API The managed-profiles page named only the three milestone events, and the flow doc sent SulPayments to a dashboard webhook screen that does not exist. --- docs/api/pages/14-managed-profiles.md | 2 +- docs/product-monerium-b2b-flow.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index f5ab6009f..f649a9e08 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`/`DEPOSIT_RETURNED` 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 the lifecycle events `DEPOSIT_UPDATED`/`ACCOUNT_UPDATED` and the milestones `DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED`/`DEPOSIT_RETURNED` for all their children's accounts — see the Webhooks page. ## Common Errors diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md index 023bd0d98..46f87229b 100644 --- a/docs/product-monerium-b2b-flow.md +++ b/docs/product-monerium-b2b-flow.md @@ -230,7 +230,10 @@ SulPayments' steps **[S11]**: share the app's client ID and secret with Vortex, and onboard one or two test client profiles through its KYB integration. 2. Get a test API key from dashboard-sandbox.vortexfinance.co, for the API at - api-sandbox.vortexfinance.co, and register its webhook endpoint there. + api-sandbox.vortexfinance.co, once Vortex has set SulPayments' profile up as the + manager of its clients. Register the webhook endpoint with that key through + `POST /v1/webhook`, subscribing to `DEPOSIT_UPDATED` and `ACCOUNT_UPDATED`; the + dashboard has no webhook screen. 3. For each test profile, send Vortex the Monerium profile ID and a Sepolia destination wallet that SulPayments controls. From dcfc36f4de34ab953a242fbfb0917f8f656ca52f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 6 Oct 2026 17:52:38 +0200 Subject: [PATCH 73/74] docs(api): add the Sepolia sandbox bring-up to the B2B runbook The rollout checklist covered mainnet only, and Sepolia has no usable EURe/USDC market: its one pool prices EURe at 0.71 USDC, so every sandbox payment would have reverted into a refund. The procedure seeds a Vortex-run 1 bps pool at the Chainlink price and was dry-run on a Sepolia fork, including the refund leg. --- docs/operations-monerium-b2b-rollout.md | 2 + docs/operations-monerium-b2b-runbook.md | 210 +++++++++++++++++++++++- 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index efdc2deff..1706c1e27 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -47,6 +47,8 @@ fee policy 12.5 bps target / 15 bps floor (B1). ## Deploy checklist (mainnet bring-up) +The Sepolia sandbox follows its own procedure in the runbook (§8). + 1. Apply database migrations from exactly one deployment instance. Migration execution is not serialized across replicas; do not let multiple instances run the migrator concurrently. Migrations 076/077 install allocation accounting and its exact diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index e1c3d9848..be2112c79 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -1,7 +1,7 @@ # Monerium B2B Onramp — Operations Runbook All operator procedures for the B2B onramp in one place: onboarding, incident response, -alert triage, dormancy, and client migration. Architecture: +alert triage, dormancy, client migration, and the Sepolia sandbox bring-up (§8). Architecture: [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); decisions and parameters: [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); security invariants: @@ -753,3 +753,211 @@ Required results: monitor received the expected provider `403` for the fake profile, and the large-size executable-depth quote timed out once; neither monitor was part of the conversion success criterion. + +## 8. Sepolia sandbox bring-up + +Monerium's sandbox mints EURe on Ethereum Sepolia, and `api-sandbox.vortexfinance.co` +(the `vortex-sandbox` Render service, deployed from `main`) runs the keeper for it. +This section is the Sepolia counterpart of the mainnet deploy checklist in the rollout +doc. Every command below was dry-run on a local fork of Sepolia on 2026-10-06: pool, +factory, vault and clone, a payment converted in two chunks and forwarded, and the +refund leg (recover after the window, reverse swap). Set the B2B variables on the +`vortex-sandbox` service only, never in the shared "Vortex API" env group, which +production and staging both read; production stays dark. + +### 8.1 Sepolia addresses (verified on chain 2026-10-06) + +| Contract | Address | +|---|---| +| EURe (Monerium sandbox) | `0x67b34b93ac295c985e856E5B8A20D83026b580Eb` | +| EURC (Circle) | `0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4` | +| USDC (Circle) | `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238` | +| Chainlink EUR/USD (8 decimals) | `0x1a81afB8146aeFfCFc5E50e8479e826E7D55b910` | +| Uniswap v3 factory | `0x0227628f3F023bb0B980b67D528571c95c6DaC1c` | +| Uniswap SwapRouter02 | `0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E` | +| Uniswap NonfungiblePositionManager | `0x1238536071E1c677A632429e3655c799b22cDA52` | +| Mispriced EURe/USDC 5 bps pool (never whitelist) | `0xaC4D4fe930cb78b6eAC08e8Ec3cCA5Ea12059aD2` | + +The July 2026 link-test deployment (factory `0xcBE354…`) used other tokens and a +placeholder router; it is not reusable. + +### 8.2 What differs from mainnet + +- **Vortex runs the pool.** The only EURe/USDC pool with liquidity prices EURe at 0.71 + USDC (Chainlink: 1.127 on 2026-10-06), so a swap there misses the floor (Chainlink + − `SLIPPAGE_BPS`) and reverts, and every payment would end in a refund. Vortex seeds + its own 1 bps pool at the Chainlink price (§8.4). +- **No quoting.** Off mainnet the keeper does not quote routes; it swaps on the first + enabled factory route. Make the 1 bps pool the initial route and add no other. +- **No arbitrage.** Nothing pulls the pool back to the market, and each conversion moves + it a little. Re-centre it before every test session (§8.9). The mispriced 5 bps pool + is an open arbitrage against it: if its USDC disappears between sessions, re-seed. +- **Fresh keys only.** The well-known development keys (anvil's) carry delegated code on + Sepolia that sweeps any ETH sent to them. +- **No private orderflow.** `MONERIUM_B2B_PRIVATE_RPC_URL` is required only when + `DEPLOYMENT_ENV=production`. + +### 8.3 Keys and funding + +- Fresh EOAs: guardian, keeper and attestor (three distinct keys), the float wallet + (`MONERIUM_B2B_FLOAT_PRIVATE_KEY`), and a fee recipient address Vortex controls. + `MONERIUM_B2B_REFUND_SEED` is any fresh 32-byte secret (`openssl rand -hex 32`). +- Sepolia ETH: about 0.2 each for the guardian (deployments), the keeper (swaps, + forwards, recoveries) and the float (it tops up the refund wallets' gas). +- Sandbox EURe for the guardian (pool seeding) and the float (refund top-ups): link the + address to a Vortex profile in Monerium's sandbox and use "Simulate bank transfer" on + that profile's IBAN. Sandbox EURe costs nothing. +- USDC: buy it with sandbox EURe from the mispriced 5 bps pool; its price is irrelevant + when the EURe is free. 2,000 EURe bought about 1,385 USDC in the dry run. + +```bash +RPC= +EURE=0x67b34b93ac295c985e856E5B8A20D83026b580Eb +EURC=0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4 +USDC=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 +ORACLE=0x1a81afB8146aeFfCFc5E50e8479e826E7D55b910 +ROUTER=0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E +NPM=0x1238536071E1c677A632429e3655c799b22cDA52 +UNI_FACTORY=0x0227628f3F023bb0B980b67D528571c95c6DaC1c +GUARDIAN=$(cast wallet address $GUARDIAN_KEY) + +cast send $EURE "approve(address,uint256)" $ROUTER 2000ether --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $ROUTER "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "($EURE,$USDC,500,$GUARDIAN,2000000000000000000000,0,0)" --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +### 8.4 Seed the 1 bps pool at the Chainlink price + +USDC sorts before EURe, so the pool's price is EURe base units per USDC base unit, +`1e20 / answer` for a Chainlink answer with 8 decimals. The position spans ±1% (100 +ticks at the 1 bps tier's spacing of 1). 1,000 USDC and about 890 EURe keep a €100 +payment's price impact around 0.1%. + +```bash +ANSWER=$(cast call $ORACLE "latestRoundData()(uint80,int256,uint256,uint256,uint80)" --rpc-url $RPC | sed -n 2p | awk '{print $1}') +read SQRT_PRICE TICK_LOWER TICK_UPPER < <(python3 -c " +import math; a=$ANSWER +tick = math.floor(math.log(10**20 / a, 1.0001)) +print(math.isqrt(10**20 * 2**192 // a), tick - 100, tick + 100)") + +cast send $NPM "createAndInitializePoolIfNecessary(address,address,uint24,uint160)" $USDC $EURE 100 $SQRT_PRICE \ + --rpc-url $RPC --private-key $GUARDIAN_KEY +POOL=$(cast call $UNI_FACTORY "getPool(address,address,uint24)(address)" $USDC $EURE 100 --rpc-url $RPC) +cast send $USDC "approve(address,uint256)" $NPM 1000000000 --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $EURE "approve(address,uint256)" $NPM 1000ether --rpc-url $RPC --private-key $GUARDIAN_KEY +# the gas estimate is too low for a mint that moves EURe; pass a limit +cast send $NPM "mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))" \ + "($USDC,$EURE,100,$TICK_LOWER,$TICK_UPPER,1000000000,1000000000000000000000,0,0,$GUARDIAN,$(( $(date +%s) + 3600 )))" \ + --gas-limit 1500000 --rpc-url $RPC --private-key $GUARDIAN_KEY +cast call $USDC "balanceOf(address)(uint256)" $POOL --rpc-url $RPC # 1000000000 +``` + +### 8.5 Deploy the factory, register the keeper, deploy the vault + +The parameters are the ADR's (§7.3 table) except `perSwapCap`: €25 lets a €60 test +payment convert in three chunks. It is operational; `setPerSwapCap` changes it later. +The initial route is the 1 bps pool, EURe → USDC. + +From `contracts/monerium-forwarder/`, with `ATTESTOR`, `KEEPER` and `FEE_RECIPIENT` set +to the §8.3 addresses, and `FACTORY` and `VAULT` taken from forge's "Deployed to" line: + +```bash +ROUTE=$(cast concat-hex $EURE 0x000064 $USDC) # fee 100 as three bytes +forge create src/VortexForwarderFactory.sol:VortexForwarderFactory --rpc-url $RPC --private-key $GUARDIAN_KEY --broadcast \ + --constructor-args "($EURE,$EURC,$USDC,$ROUTER,$ORACLE,$ATTESTOR,$FEE_RECIPIENT,187200,60,10000,100,7200,86400,0x0000000000000000000000000000000000000000000000000000000000000000)" \ + 1000000000000000000 50000000000000000000000 1000000000000000000 25000000000000000000 $ROUTE +cast call $FACTORY "route(uint256)(bytes,bool)" 0 --rpc-url $RPC # the path above, true +cast send $FACTORY "setKeeper(address,bool)" $KEEPER true --rpc-url $RPC --private-key $GUARDIAN_KEY + +# vault: 1% per swap (the ladder's top), 50 USDC per day +forge create src/VortexSubsidyVault.sol:VortexSubsidyVault --rpc-url $RPC --private-key $GUARDIAN_KEY --broadcast \ + --constructor-args $USDC $FEE_RECIPIENT $FACTORY 10000 50000000 +cast send $FACTORY "setSubsidyVault(address)" $VAULT --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $USDC "transfer(address,uint256)" $VAULT 20000000 --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Then generate and verify the manifest as in §1.3. Sepolia's public RPCs prune old logs, +so pass `--logs-rpc` with an endpoint that serves the full history, and commit the +result to `manifests/`. + +### 8.6 Sandbox backend configuration + +On the `vortex-sandbox` service only: + +| Variable | Value | +|---|---| +| `FLOW_VARIANT` | `mykobo` (startup refuses B2B otherwise; the sandbox's retail EUR onramp then runs on Mykobo) | +| `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET` | SulPayments' sandbox white-label app (S11) | +| `MONERIUM_B2B_RPC_URL` | Sepolia RPC | +| `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` | `$FACTORY` | +| `MONERIUM_B2B_KEEPER_PRIVATE_KEY`, `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` | the three keys of §8.3 | +| `MONERIUM_B2B_REFUND_SEED`, `MONERIUM_B2B_FLOAT_PRIVATE_KEY` | §8.3 | +| `MONERIUM_B2B_AUTO_RECOVERY` | `auto`, so the refund test runs end to end | +| `MONERIUM_B2B_WEBHOOK_SECRET` | `whsec_` plus base64 of 32 random bytes: `echo "whsec_$(openssl rand -base64 32)"` | +| `MONERIUM_B2B_ENABLED` | `true`, set last | + +Register Vortex's webhook subscription on SulPayments' sandbox app with the same +secret, from `apps/api` with that app's credentials and `MONERIUM_API_URL` pointing at +Monerium's sandbox API: + +```bash +SECRET= MONERIUM_WHITELABEL_CLIENT_ID=... MONERIUM_WHITELABEL_CLIENT_SECRET=... \ +MONERIUM_API_URL=https://api.monerium.dev bun -e ' +import { MoneriumApiService } from "@vortexfi/shared"; +console.log(await MoneriumApiService.getInstance().createWebhook({ + secret: process.env.SECRET, types: ["iban.updated", "order.created", "order.updated", "profile.updated"], + url: "https://api-sandbox.vortexfinance.co/v1/monerium-b2b/webhook" }));' +``` + +Restart the service. Startup fails if a required setting is missing; once it is up, +`GET /v1/monerium-b2b/accounts` answers 401 without a key instead of 404. + +### 8.7 Partner and test clients + +1. Make SulPayments' sandbox profile a manager: + `PUT /v1/admin/managed-profile-managers/` with corridor `EU` and customer + type `business`. SulPayments then takes a key from dashboard-sandbox and registers + its webhook through `POST /v1/webhook` (`DEPOSIT_UPDATED`, `ACCOUNT_UPDATED`). +2. Per test client, once SulPayments confirms the Monerium profile is approved: §1.2 + (refund address, deploy the clone), §1.4 (map), §1.5 (automatic link and IBAN), + §1.7 (activate). Until the destination endpoint (V6) exists, SulPayments sends the + profile ID and the Sepolia destination to Vortex directly. + +### 8.8 Test payments + +SulPayments simulates each SEPA payment on the client's profile in its Monerium sandbox +app ("Simulate bank transfer"); EURe lands on the clone and the keeper takes over. + +| Test | Payment | Expected | +|---|---|---| +| Normal | €20 | One chunk, one forward. The destination receives the reference less the client's target (12.5 bps); the fee treasury the surplus over it | +| Chunked | €60 | Three chunks at the €25 cap, then one forward of their sum | +| Refund | €15 | Suspend the account before the payment (`PATCH /v1/admin/monerium-b2b/accounts//status` with `suspended`): the keeper converts nothing for a suspended account but still arms the clone's clock and runs recoveries. After two hours the deadline job marks the deposit, the keeper recovers it, and the refund leaves from the client's IBAN. Reactivate afterwards. If the simulated transfer carries no payer IBAN and name, the refund parks as `recovery_failed`; that is a finding about the sandbox simulation, closed with `PATCH /v1/admin/monerium-b2b/deposits//status` | + +`DEPOSIT_UPDATED` reports every step to SulPayments, and `GET /v1/monerium-b2b/deposits` +shows the same snapshots. + +### 8.9 Re-centre the pool before a session + +Each conversion sells EURe into the pool and pushes its price away from Chainlink. A +swap with a price limit moves it back exactly: + +```bash +ANSWER=$(cast call $ORACLE "latestRoundData()(uint80,int256,uint256,uint256,uint80)" --rpc-url $RPC | sed -n 2p | awk '{print $1}') +TARGET=$(python3 -c "import math; print(math.isqrt(10**20 * 2**192 // $ANSWER))") +CURRENT=$(cast call $POOL "slot0()(uint160,int24,uint16,uint16,uint16,uint8,bool)" --rpc-url $RPC | head -1 | awk '{print $1}') +if python3 -c "import sys; sys.exit(0 if $CURRENT > $TARGET else 1)"; then + # EURe cheaper than Chainlink: buy EURe with USDC up to the target price + cast send $USDC "approve(address,uint256)" $ROUTER 1000000000 --rpc-url $RPC --private-key $GUARDIAN_KEY + cast send $ROUTER "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "($USDC,$EURE,100,$GUARDIAN,1000000000,0,$TARGET)" --gas-limit 600000 --rpc-url $RPC --private-key $GUARDIAN_KEY +else + # EURe dearer than Chainlink: sell EURe for USDC down to the target price + cast send $EURE "approve(address,uint256)" $ROUTER 1000ether --rpc-url $RPC --private-key $GUARDIAN_KEY + cast send $ROUTER "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "($EURE,$USDC,100,$GUARDIAN,1000000000000000000000,0,$TARGET)" --gas-limit 600000 --rpc-url $RPC --private-key $GUARDIAN_KEY +fi +``` + +The router pulls only what the move needs. Chainlink's Sepolia feed must stay under the +52-hour `MAX_ORACLE_AGE`; it was four hours old when checked. From a5b174a0a60cdbd94205b9f997b18ce933de3feb Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 6 Oct 2026 17:52:38 +0200 Subject: [PATCH 74/74] chore(repo): drop the July link-test Sepolia manifest It describes a manifest-v2 deployment with the wrong tokens and a placeholder router that the current verifier rejects; the sandbox bring-up deploys a new factory and commits its own manifest. --- ...354e847bF597513148918E7EbDff72aC75842.json | 67 ------------------- 1 file changed, 67 deletions(-) delete mode 100644 contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json diff --git a/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json deleted file mode 100644 index 713fbbeef..000000000 --- a/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "chainId": 11155111, - "factory": { - "address": "0xcBE354e847bF597513148918E7EbDff72aC75842", - "immutables": { - "CAP_CEILING": "50000000000000000000000", - "implementation": "0x7e1c653CaAFCa44258d8680B09F42a33475504a9", - "MIN_SWAP_FLOOR": "1000000000000000000" - }, - "operational": { - "globalPaused": false, - "guardian": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", - "minSwapAmount": "25000000000000000000", - "perSwapCap": "10000000000000000000000" - }, - "runtimeBytecodeHash": "0x3a651b091d179f24618becebeba26698ac13e366899e08f5f03ba8cbac879e98" - }, - "forwarders": [ - { - "address": "0xD7444AB7270A142227Fe659D63873ABdc8AF9b72", - "clientMutable": { - "destination": "0x1111111111111111111111111111111111111111", - "fallbackAddress": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1" - }, - "deploy": { - "blockNumber": "11293035", - "salt": "0x0000000000000000000000000000000000000000000000000000000000000002", - "txHash": "0x90e67a4f9698c9d009687eaaedd2d8b89eebfa104a2edb4c7923fa7a033f2787" - }, - "guardianMutable": { - "feeBps": 0 - }, - "immutables": { - "isForwarder": true - }, - "runtimeBytecodeHash": "0x215c88056e67f5925d5660d5eb02a45dca8e69b50db65bacb5ba9ed44d63f364" - } - ], - "generatedAt": "2026-07-17T17:11:57.612Z", - "implementation": { - "address": "0x7e1c653CaAFCa44258d8680B09F42a33475504a9", - "immutables": { - "ATTESTOR": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", - "EURC": "0x539fA90D0a29eB2a513f6b88fEd30b529dF071ca", - "EURE": "0x67b34b93ac295c985e856E5B8A20D83026b580Eb", - "FACTORY": "0xcBE354e847bF597513148918E7EbDff72aC75842", - "FEE_RECIPIENT": "0x3333333333333333333333333333333333333333", - "LINK_HASH_191": "0xb77c35c892a1b24b10a2ce49b424e578472333ee8d2456234fff90626332c50f", - "LINK_MESSAGE": "I hereby declare that I am the address owner.", - "MAX_FEE_BPS": 100, - "MAX_ORACLE_AGE": "187200", - "ORACLE": "0x337dd479435aE2593c9B023B48617278c6AB34E3", - "ORACLE_DECIMALS": 8, - "POOL_FEE_EURC_USDC": 500, - "POOL_FEE_EURE_EURC": 500, - "RECOVERY_HASH": "0x0000000000000000000000000000000000000000000000000000000000000000", - "ROUTER": "0x2222222222222222222222222222222222222222", - "SLIPPAGE_BPS": 100, - "SWEEP_DELAY": "5184000", - "TRIGGER_DELAY": "86400", - "USDC": "0xEc262C76Ff70330BBa90e2c477B185d6665c4aA2" - }, - "runtimeBytecodeHash": "0x0c362c605fc7a795c4cab02fca0751e27817caf84017296d8fa2325dde6f1d11" - }, - "manifestVersion": 2, - "purpose": "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). NOT a trust root (re-review R01): this file is produced by Vortex from the same chain state it attests to, so it proves only that the deployment has not silently changed since publication — not that it was honest. Verify contract behavior independently against the verified source on a block explorer." -}