From aa71fca761e8872610ba983cd422cbc36b5ba832 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:16:56 +0200 Subject: [PATCH 1/5] docs(repo): describe B2B onramp parameters as Vortex defaults The ADRs, architecture, runbook and security spec now state the fee policy, conversion window and destination handling as configured defaults, and carry the operational facts (memo routing, redeem timing, the link-message dependency, SEPA recalls) that only the flow overview held. --- contracts/monerium-forwarder/README.md | 2 +- docs/adr-0005-monerium-b2b-onramp.md | 113 +++++++++--------- ...-0007-monerium-b2b-partner-registration.md | 5 +- docs/api/pages/07-webhooks.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 28 +++-- docs/operations-monerium-b2b-runbook.md | 48 ++++---- .../05-integrations/monerium-b2b.md | 8 +- 7 files changed, 109 insertions(+), 97 deletions(-) diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index 66975b7b7..d9ffa2a2e 100644 --- a/contracts/monerium-forwarder/README.md +++ b/contracts/monerium-forwarder/README.md @@ -3,7 +3,7 @@ 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, a conversion policy that swaps over a -factory-whitelisted Uniswap v3 route and settles the fill against a partner reference +factory-whitelisted Uniswap v3 route and settles the fill against a 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 client's refund diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 81a40250b..9de820d5c 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -11,7 +11,7 @@ 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 [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md); -launch gates and terms inputs in +launch gates and the deploy checklist in [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md); procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). The consumer-flow design this grew out of remains a phase-2 proposal: @@ -19,13 +19,11 @@ consumer-flow design this grew out of remains a phase-2 proposal: ## Context -Partner-sourced business clients (the partner's OTC corporates, KYB'd under the partner's -FINMA/VQF licence with per-customer reliance attestations to Monerium) need EUR → USDC -on Ethereum with **zero Vortex-side interaction**: no app, no wallet ceremony, no digital -signature. Onboarding is paperwork only. The single blocker in the consumer design was -Monerium's link signature — connecting an IBAN to an on-chain address requires that -address to approve the fixed ownership message `"I hereby declare that I am the address -owner."` via EIP-1271. +Partner-sourced business clients need EUR → USDC on Ethereum with **zero Vortex-side +interaction**: no app, no wallet ceremony, no digital signature. Onboarding needs no +client action at Vortex. The single blocker in the consumer design was Monerium's link signature — +connecting an IBAN to an on-chain address requires that address to approve the fixed +ownership message `"I hereby declare that I am the address owner."` via EIP-1271. ## Decision: the attestor-constrained forwarder @@ -47,7 +45,7 @@ Supporting decisions, all in force: - **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 + supplies the 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 @@ -92,11 +90,11 @@ 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-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): +Vortex's default fee policy fixes the client's rate against a reference: the client +receives 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 (contracts were not yet deployed, +so this replaced the flat fee before launch with no migration): - **Reference rate** (superseded 2026-09-18 by the bid/ask midpoint, see the third amendment). Before each swap the keeper computes a five-minute @@ -135,7 +133,7 @@ not yet deployed, so this replaced the flat fee before launch with no migration) - **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. + funds; the rate guarantee applies to keeper-executed swaps only. 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 @@ -144,11 +142,9 @@ not yet deployed, so this replaced the flat fee before launch with no migration) ## Amendment 2026-09-17: whole-deposit settlement and the refund path -Product requirements from the partner: 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, -`proposal-monerium-b2b-settlement-and-recovery.md`, is in git history): +Product requirements: 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. Decisions: - **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 @@ -170,14 +166,12 @@ for that refund is agreed commercially. Decisions (the proposal that led here, 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 + the pilot; the partner supplies 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). + and the contract can never send anywhere else. - **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 @@ -246,8 +240,8 @@ for that refund is agreed commercially. Decisions (the proposal that led here, €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 + land below that exchange's minimum deposit (the destination is the partner's input, + B5); 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. @@ -256,13 +250,12 @@ for that refund is agreed commercially. Decisions (the proposal that led here, 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 + 16 minutes, so a payment above roughly €70k could reach the two-hour window. 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. +- **Penny test optional.** The penny test (B2) 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. ## Amendment 2026-10-01: per-client refund wallets @@ -284,9 +277,9 @@ for that refund is agreed commercially. Decisions (the proposal that led here, 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 + belongs to exactly one profile). 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 @@ -296,12 +289,12 @@ for that refund is agreed commercially. Decisions (the proposal that led here, | 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) | +| B1 | Default 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, **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 | -| B6 | Redemption-limitation disclosure | Mandatory in client terms (committed to Monerium); draft in the rollout doc | +| B3 | Conversion window | **2 h from the mint** (P3; `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` default 120), refunded past it; weekend mints execute within the 52 h oracle window at possibly wider spreads | +| B4 | Per-client volume limit | **Not enforced by the backend** (GA revisit); `perSwapCap` (P7) bounds each swap, not a client's daily volume | +| B5 | Destination responsibility | The partner supplies and confirms each destination; dormancy re-activation on written partner confirmation | +| B6 | Redemption limitation | A clone validates no redeem order, so its EURe is never redeemed to an IBAN directly: it leaves only by swap and forward to the destination, by recovery to the client's refund wallet, or through Monerium's issuer recovery (T1) | | 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 | `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 | @@ -316,10 +309,10 @@ for that refund is agreed commercially. Decisions (the proposal that led here, | 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) | +| T2 | Per-IBAN suspension | Best-effort: a manual request to Monerium (runbook §2.2) | +| T3 | KYB submission mechanism | Open, deliberately unbuilt — clients are approved by Monerium and imported via the admin mapping; no identity-data submission path may exist until a submission mechanism is designed and this row and security-spec invariant 11 are updated | | 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 | -| T1 | Issuer recovery message | **Resolved (verbal, 2026-08-26): identical to the link message** — already whitelisted, recovery works as built; `RECOVERY_HASH` stays 0; written confirmation folds into the G1 package | +| T1 | Issuer recovery message | **Identical to the link message** — already whitelisted, recovery works as built; `RECOVERY_HASH` stays 0 | | O1 | Client-migration tooling | Build when first needed; manual procedure in the runbook meanwhile | | O2 | `FEE_RECIPIENT` treasury | **New dedicated Safe multisig** (immutable at implementation deploy); guardian key to hardware/multisig custody at GA | @@ -345,29 +338,37 @@ example (oversized-deposit allocation). client verification moment; the published manifest + verifier make deployments *checkable*, not trustless. Accepted; heightened relative to the consumer flow. - **S1 — Monerium credential control-plane.** Whitelabel credentials can re-link - addresses and move IBANs (redirecting *future* mints only). Cannot be prevented - 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) + addresses and move IBANs (redirecting *future* mints only). The white-label app is the + partner's, so the partner holds credentials with the same power; it only creates + profiles and submits KYB, and never links addresses, requests or moves IBANs, places + orders, or changes Vortex's webhook subscription, so any such change is treated as an + incident (runbook §2.5). Closing a profile also closes its IBAN, so profile closures are + coordinated with Vortex operations. Cannot be prevented client-side: a preventive + control (authorization requirements on `PATCH /ibans` and `POST /addresses`) can exist + only at Monerium; the association monitor is the detective control; response = rotate + + suspend (runbook). +- **CEX destination rotation.** Not verifiable on-chain; left to the partner (B5), 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 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. + nothing else, and the association monitor. - **Broken destination** — with no client key on the clone, a wrong destination is - 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 - question. Never present "no custody" as "no licence needed". + caught by an optional penny test or by the dormancy gate; a destination change is a new + clone. - **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 client's refund wallet, past 24 h anyone may convert and forward permissionlessly; the issuer backstop remains. Accepted. +- **Link-message change.** The forwarder validates only Monerium's exact ownership + message (a constant of the implementation), so a change to that message at Monerium + fails every new link closed, and with it new onboarding, until a new implementation and + factory are deployed; issuer recovery validates the same message (T1). +- **SEPA recall after forwarding.** A payment recalled by the payer's bank after its USDC + was forwarded cannot be reversed on chain; the system has no clawback path. - **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 @@ -387,7 +388,7 @@ accepted, IBAN issued, no client interaction). A Vortex outage can never trap co 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 +bounded by on-chain route validation rather than admin switches, the client'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. +that only Monerium-side controls and monitoring can bound. diff --git a/docs/adr-0007-monerium-b2b-partner-registration.md b/docs/adr-0007-monerium-b2b-partner-registration.md index 88dff2453..7802d9056 100644 --- a/docs/adr-0007-monerium-b2b-partner-registration.md +++ b/docs/adr-0007-monerium-b2b-partner-registration.md @@ -79,7 +79,7 @@ could deploy. the registry (below). 5. **Partner binding by configuration.** `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID` (read lowercased) names the one manager allowed to register, the partner owning the - white-label app. Per-partner credentials and bindings come with the second partner. + white-label app. Per-partner credentials and bindings are a later extension. 6. **Activation.** An operator call everywhere except the sandbox (`SANDBOX_ENABLED=true`, which boot pairs with `DEPLOYMENT_ENV=sandbox`), where a registered account activates once its IBAN is recorded; staging and development need @@ -99,7 +99,8 @@ could deploy. from and to status, destination and forwarder. Operators find the accounts waiting with `GET /v1/admin/monerium-b2b/accounts?status=onboarding`. 7. **No exchange-address distinction.** Every valid address is accepted the same way; - the partner agreement carries the risk of rotating exchange deposit addresses. + an exchange rotating a deposit address is caught by the dormancy gate or an optional + penny test (ADR-0005 B5). 8. **Profile kind.** The keeper reads a profile's state, not its kind, so a personal (individual) Monerium profile in the partner's app is accepted like a corporate one and mapped with a mirrored business KYB record. Accepted scope decision of the diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index bec865e65..20b8dec3c 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -186,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 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. +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 client's target rate, and `subsidyRaw` the top-up paid to reach the client's floor rate (both 6-decimal USDC base units). The chunk's `usdcNetRaw` already includes both. ### `DEPOSIT_RETURNED` diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 8e55b00f6..bcc73778c 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -11,14 +11,14 @@ operator procedures in [`operations-monerium-b2b-runbook.md`](operations-moneriu ## The shape in one paragraph Each corporate client is onboarded and KYB-approved by Monerium in Vortex's whitelabel -app (under the partner's KYC reliance) and owns a dedicated Monerium profile. Vortex +app and owns a dedicated Monerium profile. Vortex deploys one `VortexForwarder` contract clone per client, links it to that profile with an 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 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 +fill against the 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 @@ -119,7 +119,7 @@ sequenceDiagram participant M as Monerium participant C as Ethereum - Note over M: Monerium onboards the corporate under partner reliance - profile "approved" + Note over M: Monerium onboards and KYB-approves the corporate - profile "approved" 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 @@ -135,9 +135,8 @@ sequenceDiagram 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.) +1. **Monerium onboards the corporate** and approves its KYB; 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` (no setter: 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, @@ -209,6 +208,14 @@ mint identity. Only a settled, chain-indexed mint makes an account a conversion candidate. A live balance by itself is deliberately insufficient: this prevents a swap from outrunning the watcher's reorg window and becoming impossible to attribute safely. +A payer can name a chain and address in the SEPA memo; when that address is linked to the +client's profile, Monerium mints there instead of to the IBAN's default address. The only +other address linked to a client profile is the client's refund wallet, so such a payment +lands there: its order is skipped as referencing an unknown forwarder address, and it is +neither converted nor refunded automatically. Operators refund it by hand (runbook §2.7) +before the client's next refund, which would otherwise sweep that EURe to the float as +surplus. + ## How the mint watcher walks the chain The watcher is a poll-based log scanner with a **persisted cursor** so no block range is @@ -270,6 +277,9 @@ stateDiagram-v2 } ``` +Monerium reports no separate review state (a payment under compliance review stays +`pending`), so `held` is mapped defensively and not expected in practice. + ```mermaid stateDiagram-v2 direction LR @@ -397,9 +407,9 @@ and sends at most one transaction per account per cycle: ## 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: +Vortex's default fee policy prices 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 reads the Coinbase Exchange EURC-USDC diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index b5a587e0d..43c4b79f9 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -89,17 +89,16 @@ 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); 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 +funded (§2.6); the client company onboarded and KYB-approved on Monerium's side 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 `business`). -### 1.1 Paperwork inputs (from the partner agreement) +### 1.1 Client inputs - `destination` — client's payout address. CEX deposit addresses allowed; validate by hand: EIP-55 checksum, not zero/dead/precompile/token/router, warn-and-attest for - contract addresses and CEX addresses (rotation risk — terms). Nothing else screens + contract addresses and CEX addresses (rotation risk). Nothing else screens them: the registration API rejects only a malformed or zero address (400), and the factory refuses at init (the registration is rejected) only EURe, EURC, USDC, the router, the clone itself and the client's own refund wallet. A dead or precompile @@ -109,9 +108,8 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag 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). -- Signed terms including the redemption-limitation disclosure (rollout doc, Terms §1). + Vortex's default policy 1250 / 1500 (12.5 / 15 bps, ADR B1). Adjustable later via + the guardian's timelocked `setFeePolicy` (raising either value waits 24 h). ### 1.2 Deploy the forwarder clone @@ -277,12 +275,11 @@ need no subsidy keep executing. ### 2.2 Monerium IBAN suspension ask -Per-IBAN suspension is **G1 item 7 — not yet contractual**; until the MSA settles it, -best-effort: contact Monerium support/emergency, identify the whitelabel partner -account and affected IBAN(s) + forwarder(s), ask for suspension of inbound SEPA -(deposits bounce to senders — NOT profile closure), record the ticket for the G1 -negotiation record. While unsuspended, inbound SEPA keeps minting EURe to the forwarder -— safe behind the contract invariants, but growing exposure. +Contact Monerium support/emergency, identify the whitelabel partner account and +affected IBAN(s) + forwarder(s), ask for suspension of inbound SEPA (deposits bounce to +senders — NOT profile closure), and record the ticket. While unsuspended, inbound SEPA +keeps minting EURe to the forwarder — safe behind the contract invariants, but growing +exposure. ### 2.3 Client notification @@ -442,9 +439,12 @@ be pending. If its float top-up was dropped, park the refund, clear its hash (`U (individual payers: `firstName`/`lastName`), `country` from the IBAN prefix, `memo` `vortex-refund:` (the key the automation checks before placing), the message `Send EUR to at ` - 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`. + signed by the refund wallet's key; attach `supportingDocumentId` from EUR 15,000 (one + standing document per client can be uploaded once and reused for its refunds). Monerium + pays the refund out of the client's own IBAN. Watch `order.updated` for `processed`. + Monerium may review a redeem order during business hours before paying it out; the + payout goes by SEPA Instant where the payer's bank supports it, otherwise the next + business day. 6. **Close the deposit.** `PATCH /v1/admin/monerium-b2b/deposits//status` with `{"status": "refunded"}`, from `recovering` or `recovery_failed`; use `recovery_failed` when a step cannot complete (and `recovering` again to retry later). @@ -466,7 +466,7 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- | `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, an address other than the forwarder and the client's own refund wallet linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | | `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 | +| `stranded funds ... past TRIGGER_DELAY` (error) | Permissionless path now live; the promised window long missed (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: after five failed attempts, fix the cause and set the deposit back to `recovering`; otherwise refund by hand under the memo and close the deposit as `refunded`. A hand send from the float key can collide with the keeper's float sends for other clients: first confirm no other deposit is `recovering` and no float transaction is in flight, then send right away (the check is a snapshot), or set `MONERIUM_B2B_AUTO_RECOVERY=alert` (needs a restart, pauses every client's refunds and silences the refund alerts for every client; set `auto` again right after the hand step) | @@ -740,7 +740,7 @@ 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, passing the +Deploy a client clone with the default 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()`, @@ -1111,7 +1111,7 @@ 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` | The partner's sandbox white-label app (S11) | +| `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET` | The partner's sandbox white-label app | | `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 | @@ -1168,10 +1168,10 @@ It registers the destination (or resumes the registration), waits until the acco active, checks the clone on chain, asks for the payment, and then checks the outcome: one forward transaction paying the deposit's net USDC to the destination, or the refund. `--refund` suspends the account for the payment and reactivates it afterwards, with -`ADMIN_SECRET`. The script does not make the payment. How a payment reaches a white-label -profile's IBAN in the sandbox is open with Monerium: the dashboard's "Simulate bank -transfer" mints only for your own profile, and EURe sent to the clone on chain is recorded -as unattributed and never converted. +`ADMIN_SECRET`. The script does not make the payment. How a payment reaches a +white-label profile's IBAN in the sandbox is still open (rollout ledger): the dashboard's +"Simulate bank transfer" mints only for your own profile, and EURe sent to the clone on +chain is recorded as unattributed and never converted. | Test | Payment | Expected | |---|---|---| diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 3474ce043..a8f69231d 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -2,7 +2,7 @@ ## What This Does -The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each corporate client a Monerium IBAN linked to a per-client `VortexForwarder` contract. SEPA deposits mint EURe to the forwarder; a keeper later swaps and forwards USDC to the client's destination. This spec covers the backend integration built in `apps/api/src/api/services/monerium-b2b/`: the whitelabel API client, the attestor signature for address linking, the webhook receiver with durable inbox, the deposit processor, the managed-profile account mapping, and the onboarding automation. It is deliberately NOT part of the one-shot ramp state machine — accounts are persistent and repeatedly funded. Each account is owned by a Vortex **managed profile** (`monerium_accounts.vortex_profile_id`): the corporate is onboarded and KYB-approved on Monerium's side under the partner's KYC reliance, then mapped into Vortex as a managed child with an approved `provider_customers`/`kyc_cases` mirror. +The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each corporate client a Monerium IBAN linked to a per-client `VortexForwarder` contract. SEPA deposits mint EURe to the forwarder; a keeper later swaps and forwards USDC to the client's destination. This spec covers the backend integration built in `apps/api/src/api/services/monerium-b2b/`: the whitelabel API client, the attestor signature for address linking, the webhook receiver with durable inbox, the deposit processor, the managed-profile account mapping, and the onboarding automation. It is deliberately NOT part of the one-shot ramp state machine — accounts are persistent and repeatedly funded. Each account is owned by a Vortex **managed profile** (`monerium_accounts.vortex_profile_id`): the corporate is onboarded and KYB-approved on Monerium's side, then mapped into Vortex as a managed child with an approved `provider_customers`/`kyc_cases` mirror. **Provider type:** on-ramp (EUR → USDC) **Fiat currencies:** EUR @@ -14,7 +14,7 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 0. **Dark by default and fail-fast on activation** — unless `MONERIUM_B2B_ENABLED` is exactly `true`, the public/admin B2B routes, raw webhook parser, keeper worker, and deposit-webhook registration are disabled. Existing generic webhook-outbox rows continue draining. Activation requires `FLOW_VARIANT=mykobo`, all whitelabel credentials, three keys, read RPC, webhook secret, and the trusted factory address; production also requires the private orderflow RPC. Missing configuration aborts startup rather than partially enabling the flow. Partner registrations (invariant 12) additionally need `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID` and `MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, which startup accepts only together; without them the registration endpoint answers 403 and the keeper deploys nothing. The deployer key must differ from the attestor, guardian, keeper and float keys (it sends with implicit nonces, one EOA each), and the partner binding is compared lower-cased. 1. **Attestor key stays in env and out of logs** — `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` is read from the environment only, never persisted, never returned by an API response, and never included in log lines or error messages (the not-configured error names the variable, not the value). -2. **The attestor signature authorizes linking only** — the attestor signs exactly `keccak256(abi.encodePacked(block.chainid, forwarderAddress, hash))` (the chainid binding prevents cross-chain replay — review r1) where `hash` is `LINK_HASH_191`, the EIP-191 personal-message hash of `"I hereby declare that I am the address owner."` (the raw-keccak variant was removed after the G0 sandbox validation), or the optional non-zero `RECOVERY_HASH` reserved for a future distinct recovery message (kept `bytes32(0)`: per Monerium, T1 resolved 2026-08-26 verbally, the recovery flow validates the SAME ownership message as linking, so the whitelisted `LINK_HASH_191` already covers issuer recovery — the signature proves only address ownership; the recovery payout target is Monerium's controlled process, paying exclusively to the customer's own verified bank account). `VortexForwarder.isValidSignature` accepts nothing else, so a leaked attestor key can link addresses but can never move funds or change forwarder config. The backend MUST never sign arbitrary hashes with this key. +2. **The attestor signature authorizes linking only** — the attestor signs exactly `keccak256(abi.encodePacked(block.chainid, forwarderAddress, hash))` (the chainid binding prevents cross-chain replay — review r1) where `hash` is `LINK_HASH_191`, the EIP-191 personal-message hash of `"I hereby declare that I am the address owner."` (the raw-keccak variant was removed after the G0 sandbox validation), or the optional non-zero `RECOVERY_HASH` reserved for a future distinct recovery message (kept `bytes32(0)`: Monerium's recovery flow validates the SAME ownership message as linking (registry T1), so the whitelisted `LINK_HASH_191` already covers issuer recovery — the signature proves only address ownership; the recovery payout target is Monerium's controlled process, paying exclusively to the customer's own verified bank account). `VortexForwarder.isValidSignature` accepts nothing else, so a leaked attestor key can link addresses but can never move funds or change forwarder config. The backend MUST never sign arbitrary hashes with this key. 3. **Signature format matches the contract check** — 65-byte `r ‖ s ‖ v` with `v ∈ {27, 28}` and low-s (the contract rejects malleable signatures). Enforced by construction (viem canonical signatures) and pinned by unit test `attestor.test.ts`. 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. @@ -23,7 +23,7 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 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. +11. **No KYB submission path exists** — the whitelabel KYB submission mechanism is deliberately unbuilt (adr-0005 registry T3), so no identity-data submission code path exists in the B2B module or the shared client. Clients do not need one: they are onboarded and KYB-approved by Monerium, and the admin mapping imports that outcome as an approved `kyc_cases` row. 12. **Account mapping is admin-or-registration, atomic, and rooted in a trusted factory** — an account is created only by `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) or by the keeper for a partner registration, and both go through the same `provisionMoneriumB2bAccount`. A registration (`POST /v1/monerium-b2b/accounts`, `registration.ts`) is accepted only from the manager bound to the white-label app by `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID`, never with an admin impersonation token (403 `IMPERSONATION_NOT_ALLOWED`; [admin-impersonation.md](../01-auth/admin-impersonation.md) invariant 18), and only for a profile that app can read and Monerium has neither rejected nor closed (422 `MONERIUM_B2B_PROFILE_UNAVAILABLE`; a Monerium failure, timeout, rate limit or 403 is 503 `MONERIUM_B2B_PROVIDER_UNAVAILABLE`, never blamed on the partner's input). It is create-only per Monerium profile (unique row): an identical replay returns its state, any difference in destination, client reference or contact email (compared case-insensitively) is 409 `MONERIUM_B2B_DESTINATION_CONFLICT`, a profile that already has an account is 409, and a client conflict — the client reference already used with a different contact email, by a non-business or with another Monerium profile, the contact email used by another client, or another non-rejected registration with the same reference or email — is 409 `MONERIUM_B2B_CLIENT_CONFLICT`, checked at request time and again by the keeper before it pays for a deployment. The partner may register a rejected profile again, with the same or corrected data: the row is reset to `requested` (202). The registration reads the profile's state, never its kind, so a personal Monerium profile is accepted and mapped like a corporate one (an accepted scope decision, not a control). While `requested`, the snapshot carries `waitingReason`: `monerium_profile_pending` (created, incomplete, pending or review), `monerium_profile_not_visible` (Monerium answers 404), `deployment_pending`, `deployer_not_ready` (Vortex's deployer lacks the factory role or gas), `manager_inactive`, or `temporary_error` (RPC, Monerium or another transient failure). The keeper rejects a registration only for a definite reason: Monerium rejected or closed the profile; the factory refused the deployment's arguments (`InvalidConfigAddress`, `ZeroAddress`, `InvalidFeePolicy`); a client conflict; a mapping conflict that a retry cannot change; an operator mapped the profile by hand to a different destination; or an operator withdrew the registration. Every transient failure waits and is retried instead. Each cycle it evaluates the 20 least recently checked requested registrations (waiting ones never starve newer ones; one waiting for Monerium is asked again only after a minute) within a 15-second budget and sends at most one deployment (one key, implicit nonces), after the money steps and isolated in its own try/catch. It waits for the profile to be `approved`, then deploys the clone with the factory deployer key (a guardian-granted role, never the guardian key; its reach is bounded in Keeper invariant 1) at the CREATE2 salt `keccak256(abi.encode(moneriumProfileId, destination))`, which the factory binds to the whole initialization (destination, recovery address, fee policy), so nobody can occupy a client's predicted address with other settings, and a crash adopts the predicted clone instead of deploying twice. A deployment still in flight is waited for; one that reverted, or has no receipt after 10 minutes, is sent again. A clone found at the predicted address but out of the registry long after any deployment was revoked by the guardian; the registration is rejected, since the same destination would always land on it. An account an operator mapped by hand for the same profile is adopted when its destination matches the registration and it belongs to one of the registering manager's clients, and the registration is rejected otherwise. The keeper writes an outcome only while the row is still the one it loaded (status `requested`, same destination, reference and email) and holds a row lock while it maps, so an operator withdrawal or the partner's new attempt during a cycle wins and is never overwritten. Operators see every registration and the keeper's progress through `GET /v1/admin/monerium-b2b/registrations?status=requested|mapped|rejected` (ADMIN_SECRET; adds the registration id, contact email, manager profile, deployment hash and send time, and last check) and may withdraw one that is still `requested` with `POST /v1/admin/monerium-b2b/registrations/:registrationId/withdraw`: it becomes `rejected` with a Vortex-written reason and the partner registers the profile again with corrected data (409 `MONERIUM_B2B_REGISTRATION_NOT_WITHDRAWABLE` for any other status, 404 `MONERIUM_B2B_REGISTRATION_NOT_FOUND`). A clone already deployed for a withdrawn registration stays unused (no IBAN is linked to it) and the guardian may revoke it. Activation gates the money: only an `active`, non-dormant account swaps or forwards (`canConvert`), so a payment that reaches an account before the operator checked its destination waits on the clone (`DEPOSIT_UPDATED` and the deposits read show `waitingReason: account_not_active` for a minted or converting deposit on an account that cannot convert, cleared once it can) and the deadline refunds it. Accounts from registrations activate on their own only in the sandbox (`SANDBOX_ENABLED=true`, i.e. `DEPLOYMENT_ENV=sandbox`), once their IBAN is issued; in development, staging and production activation is the operator call. The gate is a keeper-side policy, not an on-chain guarantee: the deadline refund is automatic only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, after `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (default 120; `alert` only logs REFUND DUE until an operator marks the deposit, `off` does nothing), and once a clone's batch has been open for `TRIGGER_DELAY` (24 h) `swap` and `forwardAll` are permissionless on an unpaused clone, so an early payment nobody marked for recovery can be converted and forwarded to the unchecked destination. The mapping then verifies that the forwarder's immutable `FACTORY()` equals `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, queries `isForwarder` on that configured factory (never a self-reported address; a clone the guardian revoked is refused), 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 no recorded IBAN (an account with its IBAN is fully linked and only awaits activation; the IBAN landing is logged). Activation precedes the penny test and is the operator's call except for sandbox registrations (invariant 12): only an `active` account converts. 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. @@ -40,7 +40,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. -6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation 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. +6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. From 03047b64f38626983f6e52a28d478bbd167a04d6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:16:56 +0200 Subject: [PATCH 2/5] docs(api): align B2B code comments with the default fee policy --- .../api/services/monerium-b2b/account-provisioning.ts | 10 +++++----- apps/api/src/api/services/monerium-b2b/dormancy.ts | 3 +-- .../src/api/services/monerium-b2b/manager-events.ts | 2 +- apps/api/src/api/services/monerium-b2b/recovery.ts | 2 +- .../api/services/monerium-b2b/reference-rate.test.ts | 2 +- .../src/api/services/monerium-b2b/reference-rate.ts | 2 +- apps/api/src/api/services/monerium-b2b/registration.ts | 2 +- .../database/migrations/078-monerium-fee-policy-ppm.ts | 2 +- .../079-add-conversion-reference-and-subsidy.ts | 2 +- .../src/models/moneriumConversionExecution.model.ts | 2 +- 10 files changed, 14 insertions(+), 15 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 5ccf8e6c8..2fed208e7 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -28,7 +28,7 @@ export interface ProvisionMoneriumB2bAccountInput { contactEmail: string; destination: string; externalSubjectId: string; - /** Fee policy in ppm below the reference rate; defaults to the agreed launch policy. */ + /** Fee policy in ppm below the reference rate; defaults to Vortex's default fee policy (B1). */ floorPpm?: number; forwarderAddress: string; managerProfileId: string; @@ -53,7 +53,7 @@ function normalizeAddress(value: string, name: string): string { return value.trim().toLowerCase(); } -/** Launch fee policy (docs/adr-0005-monerium-b2b-onramp.md, B1): 12.5 bps target, 15 bps floor. */ +/** Default 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. @@ -189,7 +189,7 @@ async function verifyForwarderOnChain( } } -// Mirrors the whitelabel KYB outcome for a reliance-onboarded corporate: these +// Mirrors the whitelabel KYB outcome for a Monerium-approved client: these // profiles are onboarded and approved on Monerium's side before they are mapped // here, so the local provider records are imported directly as approved // (docs/operations-monerium-interface.md, profile lifecycle). @@ -312,8 +312,8 @@ export async function provisionMoneriumB2bAccount( let result: { account: { created: boolean; row: MoneriumAccount }; managedProfile: ProvisionManagedProfileResult }; try { result = await sequelize.transaction(async transaction => { - // The pilot reliance scope is KYB'd corporates only, so the child is always a - // business entity. Every local row is created in this transaction so a late + // Every B2B client is mapped as a business (adr-0007 decision 8), so the child is + // always a business entity. Every local row is created in this transaction so a late // account conflict cannot leave an orphaned approved identity behind. const managedProfile = await provisionManagedProfile( { diff --git a/apps/api/src/api/services/monerium-b2b/dormancy.ts b/apps/api/src/api/services/monerium-b2b/dormancy.ts index e8e444c4b..ea4952149 100644 --- a/apps/api/src/api/services/monerium-b2b/dormancy.ts +++ b/apps/api/src/api/services/monerium-b2b/dormancy.ts @@ -13,8 +13,7 @@ import { forwarderAbi, getGuardianWalletClient, getPublicClient } from "./chain" * stays `active` — the pause lives on-chain, `dormant_since` records the detection. * * Un-pause is MANUAL for now: guardian ops call setGuardianPaused(false) after the - * partner re-confirms the client relationship — re-confirmation mechanics are a - * partner-agreement item (adr-0005 registry B5). + * partner re-confirms the destination in writing (adr-0005 registry B5). */ /** Dormancy pause window — registry P5 (docs/adr-0005-monerium-b2b-onramp.md). */ 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 c3b46bb5f..4427c8201 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -436,7 +436,7 @@ async function emitDepositUpdatedEvents(deps: ManagerEventDeps): Promise { /** 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. + // ponytail: scans every mapped account each pass; fine at pilot scale. const accounts = await MoneriumAccount.findAll({ where: { vortexProfileId: { [Op.ne]: null } } }); for (const account of accounts) { try { diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index 6df24956b..5580c3c84 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -58,7 +58,7 @@ import { refundAccountFor } from "./refund-wallet"; */ export const REFUND_MEMO_PREFIX = "vortex-refund:"; -/** Monerium requires a supporting document above this amount; such refunds stay manual (rollout G1). */ +/** Monerium requires a supporting document above this amount; such refunds stay manual (runbook §2.7). */ 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; 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 e698d8d4c..38e6ce474 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,7 +1,7 @@ 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, +// The reference rate 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) { 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 69431078d..1876e3403 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -1,7 +1,7 @@ import { formatUnits, parseUnits } from "viem"; /** - * Partner reference rate for the forwarder fee bands (docs/adr-0005-monerium-b2b-onramp.md, P12): + * Reference rate for the forwarder fee bands (docs/adr-0005-monerium-b2b-onramp.md, P12): * 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 diff --git a/apps/api/src/api/services/monerium-b2b/registration.ts b/apps/api/src/api/services/monerium-b2b/registration.ts index 3a4759012..fec078c4e 100644 --- a/apps/api/src/api/services/monerium-b2b/registration.ts +++ b/apps/api/src/api/services/monerium-b2b/registration.ts @@ -91,7 +91,7 @@ export interface RegistrationDeps { * blamed on the partner's input. */ profileState(moneriumProfileId: string): Promise; - /** The clone's CREATE2 address, bound to its destination, recovery address, launch fee policy and salt. */ + /** The clone's CREATE2 address, bound to its destination, recovery address, default fee policy and salt. */ predictAddress(destination: Address, recoveryAddress: Address, salt: Hex): Promise
; isForwarder(address: Address): Promise; /** Simulates, then sends `deployForwarder` from the deployer key; a contract revert throws. */ 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 07bd7ab99..892e5788e 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 @@ -2,7 +2,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/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. +// fee_bps mirror is replaced by both policy values; defaults are the B1 default policy. export async function up(queryInterface: QueryInterface): Promise { await queryInterface.addColumn("monerium_accounts", "target_ppm", { allowNull: false, 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 18bcd794d..d1040f747 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,6 +1,6 @@ import { DataTypes, QueryInterface } from "sequelize"; -// Every swap is priced against a partner reference rate (the Coinbase bid/ask midpoint) +// Every swap is priced against a 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 + diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index 06d620720..3f091d379 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -34,7 +34,7 @@ export interface MoneriumConversionExecutionAttributes { subsidyRaw: string | null; usdcNetRaw: string | null; destination: string; - /** Partner reference the swap was priced against, ORACLE_DECIMALS; persisted before broadcast. */ + /** Reference rate the swap was priced against, ORACLE_DECIMALS; persisted before broadcast. */ referenceRateRaw: string | null; referenceSource: string | null; referenceAt: Date | null; From 54283f7fa30e61b6b6af7ebe48582fab417dc21d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:16:57 +0200 Subject: [PATCH 3/5] docs(repo): narrow the B2B rollout doc to engineering readiness --- docs/operations-monerium-b2b-rollout.md | 143 ++---------------------- 1 file changed, 7 insertions(+), 136 deletions(-) diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 8db49e7d0..4d43d11f1 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -1,50 +1,15 @@ # Monerium B2B Onramp — Rollout -What still stands between the implemented system and a live pilot: the gates, the deploy -checklist, and the engineering inputs for terms drafting. Decisions and parameters are +What still stands between the implemented system and a live pilot: the launch-readiness +gates, the deploy checklist, and the open items ledger. Decisions and parameters are final in [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). ## Gates -**G1 — written approval package from Monerium.** Everything below exists only as -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 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 - 2026-08-26 statement that recovery validates the same ownership message as linking** - (which is why it works against the forwarder as built). -4. IBAN pinning: authorization requirements for `PATCH /ibans` / `POST /addresses` on - whitelabel profiles (the S1 preventive control). -5. OAuth→whitelabel profile portability and whether the whitelabel `client_id` - auto-accesses existing profiles. -6. SEPA recall / fraud loss allocation after conversion+forwarding. -7. Per-IBAN suspension capability for incident response. -8. Corporate KYB mechanism for direct (non-reliance) clients — not needed for the - partner pilot, still an MSA item. -9. Advance notice of any change to the EIP-1271 ownership/link message (and the - recovery message): the forwarder whitelists their exact hashes, so an unannounced - change fail-closes new onboarding. - -**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-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.** Partner agreement signed (terms inputs below), reliance -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) The Sepolia sandbox follows its own procedure in the runbook (§8). @@ -105,113 +70,19 @@ The Sepolia sandbox follows its own procedure in the runbook (§8). that already holds accounts, run the runbook §1.7 check for `onboarding` accounts that already have an IBAN: they stop converting until activated. -## 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 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.) -2. **Destination warranty & CEX rotation (B5 — Tier A accepted).** Client/partner - 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: an operator check of the destination - before activation, an optional 5 USDC penny test right after it (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 - 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. 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: 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, - 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), 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 - 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. -5. **Processing SLA (B3 — decided: same business day).** Draft: - > 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 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 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 - 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 | Item | Owner | Status | |---|---|---| -| G1 package (9 items) | Marcel ↔ Monerium | All verbal; consolidate in writing | -| G2 legal scope | Counsel | Not started | | G3 audit | External | After PR merge; params final | -| Partner 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 | +| Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering | 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 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 | | 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) | +| 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 (Monerium requires a supporting document from that amount) | +| Sandbox SEPA simulation: payer counterpart | Engineering | Capture one real issue-order webhook to confirm `counterpart.identifier.iban` / `details.name` arrive as the spec says (the refund target) | +| Memo-routed mint to a refund wallet | Engineering | Open: detect it and handle it as a refund | +| Monerium read-back | Engineering | Open: read the issued IBAN and the issue order's payer IBAN and name back from Monerium's API instead of relying on webhook payloads alone (the payer IBAN is the refund target); check in the sandbox whether listing the app's webhook subscriptions returns their secrets (Monerium's guide says so, the response schema has no secret field), since every holder of the app's credentials can list Vortex's subscription | | 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 98fce383d496aeff2f81985b8babe30982741323 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:16:57 +0200 Subject: [PATCH 4/5] docs(repo): remove the B2B flow overview Its technical content now lives in the architecture doc, the ADRs and the runbook. --- docs/README.md | 3 +- docs/product-monerium-b2b-flow.md | 696 ------------------------------ 2 files changed, 1 insertion(+), 698 deletions(-) delete mode 100644 docs/product-monerium-b2b-flow.md diff --git a/docs/README.md b/docs/README.md index 2ee8e30a8..8d4965ea1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,12 +29,11 @@ The smaller set of general project documents stays directly in `docs/`: | [`operations-brl-tax-id-claim-release.md`](operations-brl-tax-id-claim-release.md) | Operator runbook for releasing a BRL tax ID squatted through `createSubaccount` (RISK-026) | | [`operations-demo-environment.md`](operations-demo-environment.md) | Setup and runbook for the sandbox sales-demo account | | [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | -| [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md) | Launch gates, deploy checklist, and terms inputs for the B2B onramp pilot | +| [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md) | Deploy checklist, launch readiness, and open items ledger for the B2B onramp | | [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md) | Operator procedures for the B2B onramp: onboarding, incidents, alert triage, dormancy, migration | | [`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, the partner 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-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md deleted file mode 100644 index 3d4085f67..000000000 --- a/docs/product-monerium-b2b-flow.md +++ /dev/null @@ -1,696 +0,0 @@ -# Monerium B2B Onramp: End-to-End Flow - -> **Status:** living overview, draft for alignment. Last updated 2026-10-06 with the -> destination endpoint, after Monerium's written answers and the call of 2026-09-30, and -> the KYB and sandbox decisions sent to the partner and Monerium on 2026-10-01. -> **Audience:** Vortex/SatoshiPay internally, the partner, and Monerium. -> **Scope:** the EUR to USDC onramp for the partner's 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 **[M2]** (Monerium), **[S1]** (partner) 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 -> confirmed them in writing. - -## 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 partner's 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, the client's own refund wallet, which Vortex holds. - -## 2. Who is involved - -| Party or component | Role | -|---|---| -| **Partner** | 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 the partner'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. Uses the partner's 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. | -| **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 - -```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["Client refund wallet"] - end - subgraph Vx["Vortex"] - KEEP["Keeper service"] - end - P["Partner"] - - 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" --> P - 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 the partner's white-label app at - Monerium, submitted by the partner 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 the partner's 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. -- 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 **the partner's account**, so webhooks and - API reads reach the partner. - -### 4.2 Onboarding flow - -The partner owns the white-label app at Monerium and submits each client's KYB there. -Monerium returns the new profile's ID, and the partner 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 P as Partner - participant M as Monerium - participant V as Vortex - participant C as Ethereum - participant CL as Client - - P->>M: Create the client profile in the partner's app - M-->>P: Profile ID - P->>M: Company details, form and verifications - P->>V: Destination wallet for this profile ID [V6] - V->>M: Check the profile exists in the partner's app - M-->>V: profile.updated, state approved - V->>C: Deploy forwarder with destination and fee policy - 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] - V->>P: Client active, IBAN readable through the API [S4, V4, V12] - P->>CL: Hand over IBAN, client starts sending EUR -``` - -Notes on the flow: - -- **The partner's app, shared credentials.** Monerium's reliance agreement is with - the partner, so the client profiles live in a white-label app in the partner's 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]**. 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.** The partner creates profiles and submits KYB. Vortex links - addresses, requests the IBAN, places refunds, and registers its own webhook - subscription. The partner must never link addresses, request or move IBANs, place - orders, or change Vortex's webhook subscription. Closing a profile also closes its - IBAN, so the partner coordinates closures with Vortex **[S2]**. -- **KYB.** Companies must use Monerium's reliance route: the partner delivers company - details, form, verifications and files to Monerium directly. The partner 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 the partner. -- **Destination handover by profile ID.** Creating a profile returns its ID. - The partner then calls `POST /v1/monerium-b2b/accounts` with that ID, the destination, - its own client reference and a contact email **[V6]**. Vortex checks the profile exists - in the partner's 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 the partner's written instruction. Vortex rejects - malformed and zero addresses, and the forwarder refuses token addresses, which marks - the registration rejected. Exchange deposit addresses are accepted like any wallet; - the agreement covers the risk that they rotate. Only the partner's manager key can - register. A registration that is only waiting (Monerium has not approved the profile, - a transient failure on Vortex's side) shows why, and one that cannot proceed is marked - rejected with the reason; the partner may register a rejected profile again, with the - same or corrected data. -- **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 the partner's 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 - -- The partner registers the client's destination by Monerium profile ID **[V6]**. Once - Monerium approves the profile, the keeper deploys the forwarder, maps the client, - links the forwarder and the refund wallet, and requests the IBAN **[V1]**. The IBAN is - recorded when Monerium confirms it. -- An operator then activates the account after checking the destination; only in the - sandbox does it activate once the IBAN is recorded. Payments convert only once the - account is active: one that arrives earlier waits, `DEPOSIT_UPDATED` shows it as - `account_not_active`, and it is refunded after two hours, so the partner hands a - client its IBAN once the account is active. The operator path (deploy, then - one admin call) remains for corrections. -- The partner can read the account and its IBAN through the Vortex API with its manager - key. There is no dashboard view **[V3]**. -- The backend that runs the B2B module uses the partner's app credentials and is bound to - the partner's manager key by configuration; separate credentials per partner app follow - with the second partner **[V8]**. Reading the IBAN and the payer's IBAN back from - Monerium's API is still open **[V7]**. -- The keeper already links the client's refund wallet next to the forwarder, built on - 2026-10-01. - -### 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. - -### 4.5 Sandbox phase before production credentials - -Monerium releases the production credentials for the partner's white-label app once it -sees test data in the partner's sandbox app: approved test client profiles onboarded -through the partner's KYB integration, IBANs issued, and test payments processed (agreed -on the call of 2026-09-30). Monerium's sandbox runs on Ethereum Sepolia. - -The partner's 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, once Vortex has set the partner's 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, register the Monerium profile ID and a Sepolia destination - wallet that the partner controls with `POST /v1/monerium-b2b/accounts`. - -Vortex then deploys the forwarder, links it and the client's refund wallet, and requests -the IBAN; in the sandbox the account activates once the IBAN is issued. 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. The sandbox refunds after -15 minutes instead of two hours, so the refund test fits into the session. - -## 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 P as Partner - - B->>M: SEPA transfer to the client's IBAN - M-->>V: order.created webhook, EUR arrived - 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 - V->>V: Deposit minted, 2-hour clock starts - V-->>P: DEPOSIT_RECEIVED webhook -``` - -- Vortex listens on **two channels**. Monerium's webhooks carry the order details: - 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. -- 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'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 - address is ignored and the payment mints to the forwarder as usual. The only other - 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 wallet is handled by operations **[V9]**. - -## 6. Conversion: EURe to USDC - -### 6.1 Chunks - -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. - -### 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, the partner 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. -- 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 - -```mermaid -sequenceDiagram - participant V as Vortex keeper - participant F as Forwarder - participant R as Client's refund wallet - participant U as Uniswap - participant FL as Float wallet - participant M as Monerium - participant B as Payer's bank - participant P as Partner - - 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 to the payer's IBAN - M->>B: SEPA payout from the client's IBAN - M-->>V: order.updated, redemption processed - V-->>P: DEPOSIT_RETURNED and DEPOSIT_UPDATED -``` - -- 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 - 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 - 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 per client, and a refund that waits for Vortex operations - blocks only that client's later refunds. Clients share one float wallet, so a float - transfer that is slow to confirm can delay another client's refund until Vortex - operations resolve it. Refunds - 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 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 partner terms. - -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: - -- **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 an allowlist of payer IBANs per client and a larger - contract change. - -## 9. Status, reporting and support - -### 9.1 Deposit status - -```mermaid -stateDiagram-v2 - direction LR - [*] --> pending: Monerium order - pending --> returned: Monerium rejects it - pending --> minted: EURe on the forwarder - 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 - recovery_failed --> refunded: refunded by hand - forwarded --> [*] - returned --> [*] - refunded --> [*] -``` - -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 the partner receives - -| Event | When | Key content | -|---|---|---| -| `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 | -|---|---| -| Accounts, all clients of the manager | Each client's account with IBAN and status, filterable by Monerium profile ID | -| Registrations, all of the manager | Each destination registration: waiting with what it waits for, mapped to its account, or rejected with the reason | -| 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` | - -- The accounts call uses the partner's 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. The partner verifies each one against Vortex's published public - key and deduplicates on the event ID. -- **Fallback:** the deposits call returns the current snapshot of every deposit, for - polling if a webhook is missed. -- **Reference IDs** in every snapshot: deposit, account, Vortex profile, Monerium - profile, Monerium order, the partner's 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 the partner asked for - -The partner's 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:** the partner's frontend fetches each sub-account's IBAN from the Vortex - backend. - -How each stage is reported: - -| 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 held because the account cannot convert | minted or converting | `DEPOSIT_UPDATED` with waiting reason `account_not_active` and start time: the account is not activated yet, suspended or paused. Cleared once it can convert | -| 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. Add them -when the partner needs them. - -### 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. -- Operators can pause conversion, force a refund, or correct a deposit's status through - admin endpoints. The runbook covers each case. -- A joint Slack channel with Monerium is the agreed channel for payment questions. - Named owners and the escalation path between Vortex, the partner 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 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 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 - 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 €10,000 | Operational, ceiling €50,000 | -| Minimum swap | €1 | Operational, can be raised but never below €1 | -| 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 Assumptions (2026-09-30) - -- Refunds run through per-client refund wallets. A fixed refund IBAN per client may - replace the dynamic payer IBAN later **[V13]**. -- The partner 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 partner 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 the partner's Monerium sandbox app on Sepolia before Monerium releases - the production credentials (section 4.5). - -### 12.2 Answered by Monerium (2026-09-30) - -| Topic | Answer | -|---|---| -| Whose white-label app | A dedicated app in the partner's Monerium account, under the partner's 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 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 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. The partner delivers KYB data and files directly; Vortex could proxy those calls later as tech provider. Decided 2026-10-01: no proxy, the partner calls the KYB endpoints itself. | -| Production credentials | Released after Monerium sees test data in the partner's sandbox app: approved test profiles onboarded through the partner's 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. | -| Communication | A joint Slack channel with Monerium is to be set up. | -| The partner's 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 | -|---|---|---|---|---| -| M2 | Can the partner's app have separate credentials for Vortex and the partner, 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 the partner or to Vortex, and is it one-off or recurring? | Commercial planning | Renegotiation ongoing | A discount for onboarding all partner 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 | | -| 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 Partner - -| 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 | Production credentials follow the sandbox sign-off, section 4.5 | | -| S3 | Hand over each destination through the Vortex endpoint by Monerium profile ID. Who at the partner approves a destination? | The destination is fixed in the contract | Endpoint built 2026-10-06; approver at the partner open | | -| S4 | How does the partner get each client's IBAN? | API and dashboard scope | Answered 2026-09-30 | API-first: the partner's 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 event model in section 9.3 cover the lifecycle requirement? Webhook endpoint and support contacts. | Status reporting | Built 2026-10-01, to confirm with the partner | The partner 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 | | -| 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 - -| 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 | Built 2026-10-06 | -| 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 the partner with clients, IBANs, deposits and refunds. Optional, since the partner 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, the partner and Monerium, including the joint Slack channel with Monerium. | Meeting | Open | -| V6 | Endpoint for the partner to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Built 2026-10-06 | -| 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 | Partly: the B2B backend runs on the partner's app, bound to its manager key (2026-10-06); per-partner credentials with the second partner | -| 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, 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 - -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 69b9754d6998a0763467717200c9b26db837d7b7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 10:02:07 +0200 Subject: [PATCH 5/5] docs(repo): align B2B doc statements with the code Review found statements that claimed more than the code does: memo-routed payments had no signal or followable procedure, destination rotation on an active account was described as detected, B3 implied automatic refunds in every recovery mode, and the white-label app was named as Vortex's. --- .../src/api/services/monerium-b2b/recovery.ts | 6 +++--- docs/adr-0005-monerium-b2b-onramp.md | 16 +++++++------- ...-0007-monerium-b2b-partner-registration.md | 8 ++++--- docs/architecture-monerium-b2b-onramp.md | 20 +++++++++++------- docs/operations-monerium-b2b-rollout.md | 8 +++---- docs/operations-monerium-b2b-runbook.md | 21 ++++++++++++++++--- .../05-integrations/monerium-b2b.md | 2 +- 7 files changed, 52 insertions(+), 29 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index 5580c3c84..e27d0e8b3 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -44,8 +44,8 @@ import { refundAccountFor } from "./refund-wallet"; * IBAN, which pays out of the client's own IBAN, and mark the deposit `refunded` when * Monerium processed it. * - * 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 + * Crash safety rests on the refund wallet being dedicated and empty between refunds (a + * payment memo-routed to it breaks that until refunded by hand: runbook §2.7): 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 PER CLIENT is what keeps those balances * meaningful: the executor refuses a second `recover` for an account while one is in flight @@ -58,7 +58,7 @@ import { refundAccountFor } from "./refund-wallet"; */ export const REFUND_MEMO_PREFIX = "vortex-refund:"; -/** Monerium requires a supporting document above this amount; such refunds stay manual (runbook §2.7). */ +/** Monerium requires a supporting document from this amount; such refunds stay manual (runbook §2.7). */ 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; diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 9de820d5c..4351ddded 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -253,9 +253,10 @@ the promised window. Decisions: 16 minutes, so a payment above roughly €70k could reach the two-hour window. The guardian can change the cap at any time up to the €50k ceiling. - **Penny test optional.** The penny test (B2) 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. + exchange destinations, and not an activation requirement. A wrong destination is + caught by the penny test only when one is run; an address an exchange retires after a + long idle period is caught by the dormancy gate; a rotation on an account that keeps + converting is caught by neither (B5). ## Amendment 2026-10-01: per-client refund wallets @@ -291,7 +292,7 @@ the promised window. Decisions: |---|---|---| | B1 | Default 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, **optional** (amended 2026-09-29): recommended for exchange destinations, not an activation requirement | -| B3 | Conversion window | **2 h from the mint** (P3; `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` default 120), refunded past it; weekend mints execute within the 52 h oracle window at possibly wider spreads | +| B3 | Conversion window | **2 h from the mint** (P3; `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` default 120). Past it the payment is due for a refund: marked automatically with `MONERIUM_B2B_AUTO_RECOVERY=auto`, reported for an operator with `alert`, left to the operator with the default `off` (runbook §2.7). Weekend mints convert too, because the Chainlink bound accepts a price up to 52 h old (P8), at possibly wider spreads | | B4 | Per-client volume limit | **Not enforced by the backend** (GA revisit); `perSwapCap` (P7) bounds each swap, not a client's daily volume | | B5 | Destination responsibility | The partner supplies and confirms each destination; dormancy re-activation on written partner confirmation | | B6 | Redemption limitation | A clone validates no redeem order, so its EURe is never redeemed to an IBAN directly: it leaves only by swap and forward to the destination, by recovery to the client's refund wallet, or through Monerium's issuer recovery (T1) | @@ -347,9 +348,10 @@ example (oversized-deposit allocation). control (authorization requirements on `PATCH /ibans` and `POST /addresses`) can exist only at Monerium; the association monitor is the detective control; response = rotate + suspend (runbook). -- **CEX destination rotation.** Not verifiable on-chain; left to the partner (B5), - with the dormancy gate and an optional penny test. Silent-loss risk - converts to a pause via the dormancy gate. +- **CEX destination rotation.** Not verifiable on-chain; left to the partner (B5). The + dormancy gate turns a rotation after 60 idle days into a pause, and an optional penny + test catches a wrong address when run; a rotation on an account that keeps converting + is not detected, and its forwards keep going to the old address. - **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 diff --git a/docs/adr-0007-monerium-b2b-partner-registration.md b/docs/adr-0007-monerium-b2b-partner-registration.md index 7802d9056..0adacaa5e 100644 --- a/docs/adr-0007-monerium-b2b-partner-registration.md +++ b/docs/adr-0007-monerium-b2b-partner-registration.md @@ -98,9 +98,11 @@ could deploy. never-converted account runs from it) and every status change is logged with its from and to status, destination and forwarder. Operators find the accounts waiting with `GET /v1/admin/monerium-b2b/accounts?status=onboarding`. -7. **No exchange-address distinction.** Every valid address is accepted the same way; - an exchange rotating a deposit address is caught by the dormancy gate or an optional - penny test (ADR-0005 B5). +7. **No exchange-address distinction.** Every valid address is accepted the same way. + An address an exchange retires after a long idle period is caught by the dormancy + gate, and a wrong address by an optional penny test; a rotation on an account that + keeps converting is not detected, so the destination stays with the partner + (ADR-0005 B5). 8. **Profile kind.** The keeper reads a profile's state, not its kind, so a personal (individual) Monerium profile in the partner's app is accepted like a corporate one and mapped with a mirrored business KYB record. Accepted scope decision of the diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index bcc73778c..7b33c4bc1 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -10,8 +10,8 @@ operator procedures in [`operations-monerium-b2b-runbook.md`](operations-moneriu ## The shape in one paragraph -Each corporate client is onboarded and KYB-approved by Monerium in Vortex's whitelabel -app and owns a dedicated Monerium profile. Vortex +Each corporate client is onboarded and KYB-approved by Monerium in the partner's +white-label app and owns a dedicated Monerium profile. Vortex deploys one `VortexForwarder` contract clone per client, links it to that profile with an 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 @@ -209,12 +209,16 @@ candidate. A live balance by itself is deliberately insufficient: this prevents from outrunning the watcher's reorg window and becoming impossible to attribute safely. A payer can name a chain and address in the SEPA memo; when that address is linked to the -client's profile, Monerium mints there instead of to the IBAN's default address. The only -other address linked to a client profile is the client's refund wallet, so such a payment -lands there: its order is skipped as referencing an unknown forwarder address, and it is -neither converted nor refunded automatically. Operators refund it by hand (runbook §2.7) -before the client's next refund, which would otherwise sweep that EURe to the float as -surplus. +client's profile, Monerium mints there instead of to the IBAN's default address. Besides +the current clone, a profile's linked addresses are the client's refund wallet and, after +a migration (runbook §5) or a refund-seed rotation (runbook §6), the old clone and the old +refund wallet, because Monerium has no unlink. Such a payment's order is skipped as +referencing an unknown forwarder address, and it is neither converted nor refunded +automatically. Detecting it is an open item (rollout ledger, "Memo-routed mint to a refund +wallet"); until then the only signal is that warning (runbook §3), and operators refund +the payment by hand (runbook §2.7, "Memo-routed payment"). On the current refund wallet +this has to happen before the client's next refund, which otherwise treats that EURe as +surplus and sweeps it to the float. ## How the mint watcher walks the chain diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 4d43d11f1..16d40f4dc 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -1,7 +1,7 @@ # Monerium B2B Onramp — Rollout -What still stands between the implemented system and a live pilot: the launch-readiness -gates, the deploy checklist, and the open items ledger. Decisions and parameters are +What still stands between the implemented system and a live pilot on the engineering +side: the external audit gate, the deploy checklist, and the open items ledger. Decisions and parameters are final in [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). @@ -42,14 +42,14 @@ The Sepolia sandbox follows its own procedure in the runbook (§8). 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 +6. Production credentials for the partner's white-label app from Monerium; configure the keeper backend (the mykobo flow variant only): credentials, attestor/keeper/guardian/deployer keys (four distinct; keeper and deployer funded), `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID` (the partner manager allowed to register destinations), `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. + defers. Keep `MONERIUM_B2B_ENABLED=false` until G3 is done and the launch is signed off. 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 → diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 43c4b79f9..ffe99be24 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -451,6 +451,18 @@ be pending. If its float top-up was dropped, park the refund, clear its hash (`U Record deposit id, recover tx, reverse-swap tx, float top-up, redeem order id and payer IBAN (masked) in the ops ledger. +**Memo-routed payment** (no deposit row; found through the §3 warning `webhook order ... +references unknown forwarder address`). Look the order up at Monerium by the id in the +warning: it gives the amount, the address it minted to, and the payer's IBAN and name. If +that address is an old clone (§5), `poke()` it, wait for `RECOVERY_DELAY` (2 h), and have +the keeper `recover` the EURe to that clone's refund wallet; an old refund wallet from a +seed rotation (§6) needs the old seed's key. Then redeem the exact amount from the refund +wallet to the payer as in step 5, with a memo naming the issue order instead of a deposit +(`vortex-refund:` is reserved for deposits). On the client's current refund wallet, do +this before the client's next refund starts: that refund treats any EURe on the wallet as +surplus and sweeps it to the float, from where it must first be moved back. Record the +order id, recover tx (if any) and redeem order id 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:`. @@ -464,6 +476,7 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- | `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 | +| `webhook order ... references unknown forwarder address, skipping` (warn) | An issue order minted to an address that is no account's forwarder: usually a payment memo-routed to a client's refund wallet, or to an old clone or refund wallet after a migration or seed rotation | Refund it by hand: §2.7, "Memo-routed payment" | | `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, an address other than the forwarder and the client's own refund wallet linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | | `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; the promised window long missed (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 | @@ -540,8 +553,10 @@ monitor's alerts are expected, then: 5. Update the `monerium_accounts` row (forwarder address), re-activate (§1.7), then optionally penny-test the new clone: only an active account converts. -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. +There is no unlink at Monerium and no custodial parking position: EURe mints to the +IBAN's current default address. The old clone stays linked, so a payer who names it in +the SEPA memo still mints there; that payment is refunded by hand (§2.7, "Memo-routed +payment"). ## 6. Key compromise quick reference @@ -549,7 +564,7 @@ the IBAN's current default address; the old clone stays linked but inert. |---|---|---| | 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 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 | +| 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, or after a payer memo-routes a payment to it until it is refunded by hand (§2.7), 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 unless a payment is memo-routed to them later (keep the old seed for that refund) | | Guardian | Pause/unpause, bounded params, timelocked fee policy, route whitelist (validated), vault limits and withdrawal to treasury, revoking clones — delay-only griefing plus Vortex-money exposure | Two-step `transferGuardian`/`acceptGuardian`; audit pause, pending-policy, route, vault, keeper and deployer state after (below) | | Deployer (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`) | Deploys a registered clone for any destination and refund address; no power over existing clones, funds or settings. Each such clone counts as a forwarder, so the vault pays it, up to its `dailyBudget` and `maxSubsidyPpm`, once its batch delay allows a swap | Guardian: `setDeployer(old,false)`, then `revokeForwarder` for its rogue clones (below); fresh key, fund it, `setDeployer(new,true)`, update the env | | Whitelabel API credentials | Control-plane: can re-link/move IBANs (future mints only) — S1 | §2.5 full sequence | diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index a8f69231d..a2c2bab7b 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -67,7 +67,7 @@ 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 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 per client at a time; the seed is env-only and never logged; rotating it means new clones for every client (runbook §5 and §6) | +| **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 during that client's recovery, or after a payer memo-routes a payment to it until an operator refunds it (runbook §2.7, "Memo-routed payment"); each wallet holds only its own client's funds; one refund per client 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 on the next keeper cycles and parks the refund for the operator after five failed 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 open recovery per client on an otherwise empty wallet; float sends serialized across clients | | **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) |