From 68e24b45c8b50dccadc2f72415b447bdf09a64b1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 13 Jul 2026 11:08:10 +0200 Subject: [PATCH 01/59] Create prd doc for Monerium EUR USDC onramp --- docs/prd/monerium-eur-usdc-onramp.md | 233 +++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/prd/monerium-eur-usdc-onramp.md diff --git a/docs/prd/monerium-eur-usdc-onramp.md b/docs/prd/monerium-eur-usdc-onramp.md new file mode 100644 index 000000000..c27dd7213 --- /dev/null +++ b/docs/prd/monerium-eur-usdc-onramp.md @@ -0,0 +1,233 @@ +# PRD: Quoteless EUR → USDC (Ethereum) Onramp via Monerium Whitelabel + +**Status:** Draft for architecture review +**Date:** 2026-07-13 +**Owner:** Vortex team +**Reviewer instructions:** see [§14 Reviewer checklist](#14-reviewer-checklist) + +--- + +## 1. Summary + +Vortex adds a new onramp flow: a user onboards once, receives a **dedicated virtual IBAN** (issued by Monerium under Vortex's whitelabel integration), and from then on any EUR they wire to that IBAN is **automatically converted to USDC on Ethereum mainnet and forwarded to a pre-configured, static destination address** — with no per-transfer quote, signature, or interaction. + +The user's IBAN is linked to a **user-owned smart contract account (Safe)** on Ethereum. Monerium mints EURe directly into that Safe. A constrained, **immutable Vortex module** on the Safe performs the EURe→USDC swap and forwards proceeds to the fixed destination. The design goal is that **Vortex is never a custodian**: at no point can Vortex redirect, withhold, or seize user funds — provably, at the contract level. + +## 2. Background + +- Monerium is a licensed EMI issuing EURe (ERC-20 e-money token, 1:1 EUR). Each KYC'd profile can link blockchain addresses (per chain) and receives a personal IBAN; incoming SEPA payments are minted as EURe to the linked address, typically within seconds (SEPA Instant: "within 5 seconds, including the time to mint or burn EURe tokens onchain" — [monerium.com/partners](https://monerium.com/partners/)). +- Vortex will operate Monerium's **Whitelabel plan**: profile creation, KYC/KYB submission, address linking, and IBAN issuance all run through the Monerium API under the Vortex brand ([docs.monerium.com/whitelabel](https://docs.monerium.com/whitelabel)). Monerium has confirmed (commercially) that users onboarded earlier via OAuth/legacy integrations can be taken over. +- Address linking requires a one-time signature over the exact message `"I hereby declare that I am the address owner."`. For contract accounts Monerium validates via **EIP-1271** `isValidSignature` on-chain ([docs.monerium.com/oauth/#eip-1271](https://docs.monerium.com/oauth/#eip-1271)). **The contract must already be deployed when the link is validated** — the docs do not mention ERC-6492/counterfactual signature support. Linking is **per-chain** (`ethereum` for this flow). +- Redeem orders (EURe → SEPA payout) also accept EIP-1271 signatures — relevant for the recovery path (§8.4). +- **Precedent:** Gnosis Pay uses the same primitive stack (Safe + Monerium EURe + constrained Zodiac modules) to build a non-custodial card product. This validates the general architecture; our module constraints differ but the trust model is analogous. + +### 2.1 Verified liquidity facts (as of 2026-07-10 — re-verify at build time) + +- EURe **V2** on Ethereum: `0x39b8B6385416f4cA36a20319F70D28621895279D`. The V1 token (`0x3231Cb...273f`) is deprecated; several stale V1 pools still show TVL — must not be used. +- There is **no meaningful direct EURe/USDC pool on Ethereum**. The real route is **EURe → EURC → USDC**: + - EURe/EURC Uniswap v3 0.05% (`0x2a817bd5018f9782f84398067639230121e07d4c`): ~$104k TVL — the bottleneck hop. + - EURC/USDC Uniswap v3 0.05% (`0x95dbb3c7546f22bce375900abfdd64a4e5bd73d6`) + v4 pools: >$5M TVL, ~$2.6M daily volume — effectively unconstrained at our sizes. +- Measured aggregator quotes: 10k EURe → USDC at spot (~zero impact); 50k EURe via pure AMM routing suffers **~3.6% impact** (bottleneck pool exhausted), while CoW Swap quoted ~spot at 50k via solver/RFQ liquidity. +- Consequence: v1 must enforce **per-swap size caps** (§7.3) and the routing layer must be replaceable (§7.4). + +## 3. Goals + +1. One-time onboarding: single passkey ceremony; **exactly one signature** (the Monerium link message). No wallet extension required. +2. Fully automatic post-onboarding flow: EUR in → USDC at destination, no user action. +3. **Non-custodial by construction**: Vortex cannot change the destination, cannot withdraw funds, cannot upgrade logic into something that can. Bounded worst-case loss from any Vortex compromise (§8.3). +4. Swap routing is replaceable over time (pools/DEXes change) **without** weakening guarantee 3. +5. Reasonable execution quality: bounded slippage vs. EUR/USD oracle rate. + +### Non-goals (v1) + +- Offramp (USDC → EUR) — future work; the same account layout supports it via Monerium redeem orders. +- Quotes, limit prices, or user-selectable destinations per transfer. +- Chains other than Ethereum mainnet for mint/swap/delivery. +- Supporting user-supplied external wallets as the deposit target. + +## 4. Actors & trust model + +| Actor | Role | Trust required | +|---|---|---| +| User | Owns passkey → owns Safe; sends EUR from their bank | Trusts Monerium (fiat/e-money layer) and audited contracts; does **not** need to trust Vortex for fund safety | +| Vortex backend | Whitelabel API client: onboarding, KYC submission, webhooks; runs the keeper | Can censor (not execute) and delay; cannot steal beyond bounded slippage margin (§8.3) | +| Monerium | EMI: KYC of record, IBAN issuance, EURe mint/burn | Fully trusted for fiat layer (licensed, regulated); EURe token has issuer admin powers (freeze/upgrade) inherent to e-money | +| Keeper (Vortex-operated) | Triggers swap+forward when EURe arrives | Untrusted for safety — all safety is enforced in the module; trusted only for liveness | +| Safe (per user) | Holds EURe transiently; owner = user passkey **only** | Battle-tested Safe v1.4.1+ contracts | +| VortexSwapModule | Immutable module enabled on the Safe at setup | The security-critical component — see invariants §8.2 | + +**Custody analysis:** the Safe's only owner is the user's passkey. Vortex is not an owner, holds no keys to the account, and its module cannot move assets anywhere except (a) into a swap that must return oracle-checked USDC, and (b) USDC to the immutable destination. Vortex's practical powers are limited to *not acting* (censorship/liveness failure), which does not constitute control of funds. Formal legal sign-off required (§13). + +## 5. End-to-end flows + +### 5.1 Onboarding (one passkey ceremony, one signature) + +1. User completes Vortex-branded KYC (whitelabel: Vortex submits identity data / SumSub applicant token to Monerium via `POST /profiles` + KYC endpoints; approval via webhook). +2. Browser creates a **passkey** (WebAuthn platform authenticator — Face ID / fingerprint / Windows Hello; synced via iCloud Keychain / Google Password Manager). +3. Vortex backend deploys, in one transaction (Vortex pays gas): + - the Safe (v1.4.1+, CREATE2, deterministic address), owner = the passkey's `SafeWebAuthnSigner`, threshold 1; + - enables `VortexSwapModule` (immutable, points at this Safe's immutable `destination` and config); + - (optional, §8.4) enables `RecoveryValidatorModule`. + Deployment **must complete before linking** (no counterfactual link — §2). +4. User performs **the one signature**: passkey signs `"I hereby declare that I am the address owner."` (WebAuthn assertion wrapped for Safe EIP-1271 verification). +5. Vortex backend calls Monerium `POST /addresses` (link address to profile, `chain: ethereum`, with signature). Monerium validates via `isValidSignature` on the deployed Safe. +6. Monerium issues the dedicated IBAN. Vortex shows the user: their IBAN + the fixed destination address + fee/rate disclosure. + +The **destination address** is collected and confirmed during onboarding (see Open Question Q2 on ownership attestation) and is baked into the module config before the user signs the link message — the user's single signature therefore implicitly ratifies the destination. + +### 5.2 Steady-state transfer + +1. User wires EUR (SEPA/SEPA Instant) to their IBAN. +2. Monerium mints EURe (V2) to the user's Safe on Ethereum. Vortex detects via Monerium webhook **and** an on-chain `Transfer` watcher (defense in depth). +3. Keeper calls `VortexSwapModule.swapAndForward(safe, routeData)`: + - module reads the Safe's full EURe balance (subject to per-swap cap; splits large balances across multiple executions); + - computes `minOut` from Chainlink EUR/USD (§7.2); + - approves exactly `amountIn` EURe to the route's router (allowlisted, §7.4), executes the swap **from the Safe's context via `execTransactionFromModule` (call only, no delegatecall)**; + - verifies post-conditions (USDC delta ≥ `minOut`, EURe delta ≤ `amountIn`, approval reset to 0); + - transfers the Safe's full USDC balance to `destination`; + - emits an event; keeper tx submitted via private orderflow (Flashbots Protect) to avoid sandwiching. +4. Vortex notifies the user (email/app): amount received, USDC delivered, tx hash. + +### 5.3 Exit / recovery + +- **User-initiated (passkey):** the user is the Safe owner and can always execute arbitrary transactions — withdraw EURe/USDC, disable modules, change destination, or abandon the flow. User-initiated actions are rare; they run through ERC-4337 with a Vortex paymaster (or a Vortex-relayed Safe tx) so the user never needs ETH. +- **Passkey loss:** the automated flow **keeps working** (keeper needs no user signature), so in-flight and future deposits still reach the destination. Only user-initiated recovery is lost. Mitigations: passkeys are cloud-synced by default; optionally add a user-controlled recovery owner (email-based recoverer such as Candide/Safe Recovery Hub) — decide in Q3. +- **Stranded EURe** (swap route dead, or swaps paused): see §8.4 recovery module — EURe can be redeemed back to the **user's own bank IBAN** via a Monerium redeem order pre-authorized at setup. Never sweep raw EURe to `destination` — if destination is an exchange deposit address, EURe would be unsupported and lost. + +## 6. System components + +1. **Vortex backend (apps/api)** — new Monerium whitelabel service: profile/KYC lifecycle, address linking, webhook ingestion (profile approved, payment received, order state), IBAN issuance; persistence of user ↔ Safe ↔ destination mapping; keeper job scheduling. Follows the existing phase/state-machine pattern (new ramp type `monerium_onramp` with phases: `awaitDeposit → detectMint → swapAndForward → notifyComplete`). +2. **Frontend** — onboarding flow (KYC UI, passkey ceremony, destination input + confirmation, disclosure screen); status page (deposits, conversions, tx links). No wallet-connect requirement. +3. **Contracts** (new package or `contracts/` dir): + - `VortexAccountFactory` — deploys Safe + WebAuthn signer + module wiring atomically via CREATE2. + - `VortexSwapModule` — immutable per-deployment singleton, parameterized per Safe (see §8). + - `RouterRegistry` — Vortex-owned allowlist of swap routers, behind a timelock (§7.4). + - `RecoveryValidatorModule` (optional v1, §8.4). +4. **Keeper service** — watches mints, builds route calldata (v1: Uniswap path; later: aggregator calldata), submits via private relay, retries, alerts. +5. **Oracle** — Chainlink EUR/USD feed on mainnet (verify address, heartbeat ~24h / deviation ~0.15% at build time; staleness guard in module). + +## 7. Swap routing + +### 7.1 v1 route: Uniswap v3 multi-hop, single call + +Uniswap v3's router supports multi-hop swaps in **one call**: `exactInput(path)` with the encoded path `EURe → (0.05%) → EURC → (0.05%) → USDC`. No aggregator dependency, fully on-chain, deterministic. This answers the "can Uniswap swap over two pools in one call" question: yes, natively. + +### 7.2 Execution bounds + +- `minOut = amountIn × chainlinkEURUSD × (1 − maxSlippageBps) × 10^(−12)` (EURe 18 decimals → USDC 6 decimals). +- `maxSlippageBps` is an **immutable constant** in the module (proposal: 100 bps). Note this bound implicitly tolerates EURe/EUR and USDC/USD depegs up to the same margin; a hard depeg beyond it makes swaps revert (fail-safe: funds sit as EURe until resolved or recovered via §8.4). +- Oracle staleness check: revert if `updatedAt` older than heartbeat + grace. + +### 7.3 Size caps & batching + +- Per-swap cap (proposal: **€10,000 equivalent**, constant in module or registry) — sized to the bottleneck EURe/EURC pool. Larger balances are swapped in successive keeper executions spaced over time. +- Min-swap threshold (proposal: €25) to avoid uneconomical dust swaps; dust accumulates until threshold. +- These parameters should live in the `RouterRegistry` (timelocked, bounded: cap can never exceed a module-immutable ceiling; slippage never above the immutable 100 bps). + +### 7.4 Route replaceability without custody risk + +The module's **logic is immutable**; only **route data** is replaceable: + +- `RouterRegistry` (owned by Vortex multisig behind a **7-day timelock**, all changes evented/public) maps `routeId → (router address, selector allowlist)`. +- Keeper passes `(routeId, calldata)`; module checks router is registered, executes, then enforces post-conditions (§8.2). Because post-conditions are checked in the immutable module, **even a malicious registered router cannot extract more than the slippage margin** (§8.3). +- v2 candidates behind the same interface: 1inch/Paraswap executor calldata (validated by the same balance-delta checks), or **CoW Protocol programmatic orders** (ComposableCoW handler signing "sell all EURe for USDC, min = oracle × (1−slippage), receiver = destination" via EIP-1271) — this is the route that quoted the 50k clip at spot in testing and removes keeper gas + MEV concerns entirely. Recommended as the first post-launch iteration. + +### 7.5 MEV + +Keeper transactions go through private orderflow (Flashbots Protect / MEV-blocker). Worst-case sandwich loss is already capped by `minOut`, private submission just avoids donating the margin. + +## 8. Smart contract specification + +### 8.1 Per-user configuration (set at Safe deployment, before the link signature) + +| Param | Mutability | +|---|---| +| `EURE` (V2 token addr) | immutable | +| `USDC` token addr | immutable | +| `destination` | **immutable to Vortex**; changeable only via Safe owner (user passkey) transaction | +| `oracle` (Chainlink EUR/USD) | immutable (module version) | +| `maxSlippageBps` | immutable constant | +| `routerRegistry` | immutable pointer; registry contents timelocked (§7.4) | + +### 8.2 Module invariants (the security core — reviewer: attack these) + +- **I1**: The module can move only two tokens: EURe (into a registered router, exact-amount approval) and USDC (only to `destination`, full balance). +- **I2**: After `swapAndForward`: `usdcReceived ≥ minOut(oracle)`, `eureSpent ≤ amountIn`, EURe allowance to router reset to 0. Otherwise revert (atomic). +- **I3**: `execTransactionFromModule` is used with `operation = CALL` only — **no delegatecall ever** (a delegatecall would let a router rewrite Safe storage/owners). +- **I4**: The module cannot add/remove Safe owners, change threshold, or enable/disable modules. +- **I5**: The module has no upgrade mechanism, no `selfdestruct`, no owner-privileged functions beyond `swapAndForward` (keeper-gated or permissionless — see Q5) and view functions. +- **I6**: Vortex's only levers are: registry contents (timelocked, bounded by immutable caps) and choosing when/whether to call the keeper function. +- **I7**: Reentrancy-guarded; balance deltas measured against pre-call snapshots on the Safe itself. +- **I8**: Only the user (Safe owner) can disable the module or change `destination`. +- **I9**: The module rejects `routeData` whose router is not currently registered, and enforces a calldata selector allowlist per router entry. + +### 8.3 Bounded worst-case (full Vortex compromise) + +If Vortex's registry multisig is compromised AND the 7-day timelock elapses unnoticed AND a malicious router is registered: the router still must return `minOut` USDC to pass I2, so the maximum extractable value is **`maxSlippageBps` (+ oracle deviation) per swap** — ~1.15% of throughput, not principal. Vortex compromise can additionally *halt* swaps (liveness), never redirect principal. This bound should be stated in user-facing terms and verified by the auditor. + +### 8.4 Recovery path for stranded EURe (recommended for v1) + +`RecoveryValidatorModule`: an immutable module that makes the Safe's EIP-1271 `isValidSignature` return valid **only** for messages matching Monerium's redeem template `"Send EUR to at "` where `` hash-matches a `refundIban` pinned at setup (the user's own external bank account, collected at onboarding; changeable only by user passkey). This lets the Vortex backend place a Monerium redeem order returning stranded EURe **to the user's own bank account** with no user signature — non-custodial because the only authorizable payout target is the user's own pinned IBAN. Constraints: template must be parsed/validated strictly (amount ≤ balance, timestamp freshness); coordinate with Monerium that link-message validation is unaffected (the link message must also validate — the validator whitelists exactly these two message shapes). + +### 8.5 Known external powers (accepted, disclose) + +- EURe and USDC are issuer-controlled tokens (upgradeable; freeze/blacklist powers). A blacklisted `destination` (USDC) or frozen Safe (EURe) strands funds pending issuer/compliance resolution — inherent to fiat-backed stablecoins, mitigated by §8.4 and user-changeable destination. +- Chainlink feed failure → swaps revert (fail-safe, funds idle as EURe). + +## 9. Fees & unit economics (open — Q1) + +- Costs per user: one-time Safe+module deployment (mainnet, Vortex-paid — estimate and monitor; low at current basefees) + per-swap keeper gas (~350–600k gas). +- No quote is shown, so the fee must be a **disclosed, deterministic rule**, e.g. an immutable `feeBps` in the module skimmed to a Vortex treasury address at forward time (transparent, auditable, part of the signed-off config), plus disclosure "conversion at market rate, max slippage 1%". Spread-capture (silently keeping the slippage margin) is rejected: it's opaque and undermines the non-custody story. +- MiCA/consumer transparency: even quoteless, the flow needs pre-contractual disclosure of fee rule and rate mechanism (legal review, Q7). + +## 10. Compliance notes + +- Monerium whitelabel MSA covers the "client money / third-party use" ToS restriction at the fiat layer: each user is Monerium's e-money customer; Vortex never holds fiat or e-money on its own account in this flow. +- Non-custody at the crypto layer per §4/§8.3 — needs formal legal opinion for target jurisdictions (MiCA CASP analysis: does orchestrating an automatic conversion constitute an exchange service even without custody?). +- **Destination ownership (Q2)**: if `destination` must be user-owned (attested at onboarding), the flow is self-transfer-shaped (cleaner: no travel-rule counterparty, weaker "payment service" characterization). Allowing third-party destinations turns this into a payments product with materially heavier obligations. v1 recommendation: require attestation of user ownership. +- Monerium performs AML screening on incoming SEPA; large/first-party-mismatched deposits may be held for review — surface "pending compliance review" state in UX. Verify with Monerium whether incoming mints have amount thresholds analogous to the €15k redeem-document rule. + +## 11. Failure modes + +| Failure | Behavior | Mitigation | +|---|---|---| +| Swap reverts (slippage/oracle stale/pool drained) | EURe idles in Safe | Keeper retries with backoff; alerting; route change via registry; §8.4 recovery after timelock | +| Keeper down | No swaps (funds safe in Safe) | Redundant keepers; optionally make `swapAndForward` permissionless (Q5) so anyone can execute | +| Monerium webhook missed | Delayed detection | On-chain Transfer watcher as second trigger | +| Deposit > per-swap cap | Multiple sequential swaps | Automatic split; disclose timing to user | +| EUR sent from a bank account not matching profile name | Monerium compliance hold or return | Surface state; instruct users to send from own account | +| Passkey lost | Automation unaffected; user-initiated actions blocked | Cloud-synced passkeys; optional recovery owner (Q3) | +| Destination blacklisted by Circle | Forward reverts; USDC stuck in Safe | User changes destination via passkey; support runbook | +| EURe frozen by issuer | Nothing moves | Compliance resolution with Monerium | +| Depeg beyond slippage bound | Swaps revert (by design) | Monitor; product decision to pause/notify | + +## 12. Rollout + +1. **M0 — Spike:** deploy Safe+passkey+module on Sepolia/fork; validate Monerium sandbox EIP-1271 link end-to-end (sandbox.monerium.dev); confirm WebAuthn signer gas on mainnet (EIP-7951 precompile path post-Fusaka vs Solidity fallback). +2. **M1 — Contracts:** module + registry + factory, full test suite (fork tests against real pools incl. V1-pool poisoning tests), invariant/fuzz tests on I1–I9, external audit. +3. **M2 — Backend/Frontend:** whitelabel onboarding, keeper, state machine, notifications; internal pilot with capped amounts (e.g. €1k/user/day). +4. **M3 — GA:** raise caps; add CoW programmatic-order route (§7.4) for large-clip execution. + +## 13. Open questions + +- **Q1 — Fee model:** immutable `feeBps` in-module vs off-chain billing vs loss-leader. Blocks module finalization. +- **Q2 — Destination ownership:** require user-owned destination attestation in v1? (Recommended: yes.) +- **Q3 — Passkey recovery:** rely on platform sync only, or add an opt-in recovery owner in v1? +- **Q4 — Recovery module (§8.4) in v1 scope?** (Recommended: yes — it's the only fix for stranded EURe + lost passkey.) +- **Q5 — Keeper gating:** `swapAndForward` restricted to Vortex keeper vs fully permissionless (better liveness/decentralization; safe because all safety is in invariants — but consider griefing via unfavorable-timing executions within the slippage bound). +- **Q6 — Monerium specifics to confirm in sandbox/MSA:** whitelabel address-link flow identical to the OAuth EIP-1271 docs; ERC-6492 support (else deploy-before-link stands); incoming-payment compliance thresholds; who bears mint gas (expected: Monerium); redeem-order EIP-1271 details for §8.4. +- **Q7 — Legal:** non-custody opinion; MiCA CASP scoping; disclosure requirements for quoteless conversion. + +## 14. Reviewer checklist + +You are reviewing for architectural flaws. Specifically attempt to break: + +1. **Theft vectors:** any path where Vortex (backend, keeper, registry multisig, module deployer) redirects or extracts user principal. Include: malicious router within I1–I9; oracle manipulation (Chainlink EUR/USD compromise or staleness games); approval leakage; reentrancy through router callbacks; delegatecall smuggling; CREATE2 redeployment tricks; malicious `SafeWebAuthnSigner` substitution at factory level; front-running the link (linking a Monerium profile to a Safe the user doesn't actually control — who verifies factory integrity?). +2. **The one-signature claim:** does anything in practice require a second user signature (Monerium re-verification, destination change, 4337 deployment quirks)? +3. **Stranding vectors:** enumerate every state where funds are stuck and no §8.4/§5.3 path applies. +4. **Liveness/censorship:** consequences of Vortex disappearing permanently — can users self-rescue with only their passkey and public docs? +5. **§8.3 bound:** verify the claimed worst-case (slippage margin, not principal) holds under composed failures (compromised registry + compromised keeper + oracle at deviation edge). +6. **Liquidity math:** per-swap cap vs the $104k bottleneck pool; behavior under pool migration/deprecation; V1-token poisoning. +7. **Compliance shape:** does the §10 reasoning hold; is the "not a custodian" claim defensible given the module is Vortex-authored and Vortex-deployed? +8. **Recovery module (§8.4):** can the message-template validator be abused (IBAN substring games, amount/timestamp manipulation, replay across chains/profiles, interference with the link-message validation)? +9. **Operational realism:** webhook loss, chain reorgs around mint detection, multiple rapid deposits, decimals (EURe 18 / USDC 6), gas spikes. From 0f2462ea211d0391eb8fba28eb54a3099dc37400 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 13 Jul 2026 11:32:13 +0200 Subject: [PATCH 02/59] Add review --- ...ium-eur-usdc-onramp-architecture-review.md | 934 ++++++++++++++++++ 1 file changed, 934 insertions(+) create mode 100644 docs/prd/monerium-eur-usdc-onramp-architecture-review.md diff --git a/docs/prd/monerium-eur-usdc-onramp-architecture-review.md b/docs/prd/monerium-eur-usdc-onramp-architecture-review.md new file mode 100644 index 000000000..9e2fce802 --- /dev/null +++ b/docs/prd/monerium-eur-usdc-onramp-architecture-review.md @@ -0,0 +1,934 @@ +# Architecture Review: Quoteless EUR → USDC Onramp via Monerium + +**Reviewed document:** [monerium-eur-usdc-onramp.md](./monerium-eur-usdc-onramp.md) +**Review date:** 2026-07-13 +**Review status:** Blocking issues identified; author response requested +**Recommended decision:** **No-go for implementation or audit until the P0 findings are resolved** + +## 1. Purpose of this review + +This document is a deliberately adversarial review of the proposed Monerium EUR → USDC architecture. It is intended to be handed back to the authoring agent for a point-by-point response. + +For each finding, the author should state one of: + +- **Accept** — the PRD will be changed as recommended. +- **Reject** — explain why the concern does not apply and provide evidence. +- **Modify** — propose a different resolution and explain the resulting trust assumptions. +- **Needs validation** — identify the spike, Monerium confirmation, legal opinion, or contract prototype needed to decide. + +The review distinguishes between: + +- on-chain safety after EURe has reached the Safe; +- provisioning and fiat-ingress authority before mint; +- operational liveness and recovery; +- user-facing and legal claims; +- compatibility with Vortex's existing one-shot ramp architecture. + +## 2. Executive conclusion + +The product primitive is credible: a personal IBAN can mint EURe to a linked smart account, and an automated account policy can convert it to USDC. The current proposal, however, overstates the end-to-end security guarantee. + +The central claim — that Vortex can never redirect, withhold, or seize user funds and that a full Vortex compromise can lose only the slippage margin — is not established across the complete system. It currently excludes: + +1. Monerium address and IBAN-management authority; +2. malicious or incorrect Safe provisioning; +3. fallback-handler and recovery authority; +4. generic router calldata and unrelated Safe approvals; +5. passkey/domain dependence when Vortex disappears; +6. fees and future CoW execution; +7. the fact that a reusable IBAN is not a one-shot ramp. + +The architecture can be salvaged, but v1 should be materially smaller and the trust statement should be rewritten as a set of scoped guarantees rather than a single absolute claim. + +## 3. What appears sound + +The following foundations are reasonable, subject to sandbox and contract-level verification: + +- Monerium documents automatic issuance to the wallet linked to a dedicated IBAN. +- Monerium documents off-chain EIP-1271 verification for smart-contract-wallet address linking. +- Safe supports WebAuthn/passkey contract owners. +- Ethereum mainnet has supported the EIP-7951 P-256 precompile since Fusaka. +- Uniswap v3 supports a two-hop exact-input path in one router call. +- An immutable module that constructs tightly constrained calls and verifies token deltas is a sound direction. +- Oracle-based minimum output, strict staleness checks, no delegatecall, exact approvals, atomic forwarding, and invariant/fuzz tests are appropriate defenses. +- Explicitly acknowledging issuer freeze/upgrade powers and fail-safe reverts is correct. + +These strengths do not resolve the findings below, but they make a reduced v1 viable. + +## 4. Priority summary + +| ID | Priority | Finding | Blocks | +|---|---|---|---| +| F01 | P0 | Monerium control plane may redirect future deposits | Non-custody claim, GA | +| F02 | P0 | “Full Vortex compromise” excludes provisioning and fiat ingress | Security model, audit | +| F03 | P0 | The one signature does not bind destination or Safe configuration | Onboarding claim, destination attestation | +| F04 | P0 | Per-user module topology is internally contradictory | Contract design | +| F05 | P0 | RecoveryValidator is not an ordinary Safe module and may bypass ownership | Recovery, EIP-1271, custody | +| F06 | P0 | Router plus selector allowlisting is insufficient | Principal-safety invariant | +| F07 | P0 | CoW cannot preserve the synchronous module invariants behind the same interface | Route replaceability claim | +| F08 | P0 | A permanent IBAN cannot be represented as one terminal Vortex ramp | Backend architecture | +| F09 | P1 | Oracle math and economic bound are underspecified | Contract finalization | +| F10 | P1 | The proposed fee contradicts I1 and changes the compromise bound | Contract finalization | +| F11 | P1 | Passkey-only self-rescue depends on Vortex's RP domain and infrastructure | Liveness claim | +| F12 | P1 | Liquidity caps rely on snapshot TVL rather than executable depth | Availability and rollout | +| F13 | P1 | Webhook, reorg, concurrency, and deposit attribution rules are missing | Backend correctness | +| F14 | P1 | Immutable modules have no credible incident or migration path | Production safety | +| F15 | P1 | Compliance, data protection, and payment-reversal responsibilities are incomplete | Launch readiness | +| F16 | P1 | Dust, gas griefing, and destination edge cases break the automatic-flow promise | Product behavior | +| F17 | P2 | Several failure-mode statements need correction or sharper scope | Specification accuracy | +| F18 | P2 | v1 composes too many overlapping Safe extension mechanisms | Auditability and maintainability | + +## 5. Detailed findings + +### F01 — Monerium control-plane authority may redirect future deposits + +**Priority:** P0 +**Affected PRD sections:** §1, §4, §5.1, §8.3, §10 + +#### Concern + +The custody analysis begins after EURe reaches the expected Safe. It does not establish that Vortex lacks authority to change where future incoming EUR is minted. + +Monerium's published whitelabel documentation states that: + +- a whitelabel client can link addresses to a customer profile; +- `PATCH /ibans/{iban}` can re-associate an existing IBAN with a different linked address or chain; +- incoming payments are routed to the newly associated address; +- a SEPA memo can route issuance to another linked address. + +From the public API shape, a compromised Vortex backend appears able to link an attacker-controlled address — for which the attacker can provide a valid proof of ownership — and move the customer's IBAN to it. This is an inference that must be confirmed with Monerium, but it invalidates the current absolute claim unless Monerium applies additional scopes or user-authorization checks not described publicly. + +#### Required resolution + +Obtain a written and sandbox-verified Monerium guarantee that, for these profiles: + +1. Vortex cannot move the IBAN after activation without authorization from the currently linked Safe; +2. a new address cannot become the default mint address without equivalent end-user authorization; +3. memo-based routing can be disabled or constrained; +4. whitelabel credentials are scoped so their compromise cannot redirect customer issuance; +5. every attempted association change produces an independently monitored event. + +If Monerium cannot provide those controls, rewrite the trust model: + +> Vortex is trusted not to change the Monerium IBAN association. The on-chain non-custody guarantee starts only after EURe is finalized at the verified Safe. + +#### Questions for the author + +- What exact authorization does Monerium require for `PATCH /ibans/{iban}`? +- Can Vortex link its own address to a customer's profile using Vortex-controlled proof of that address? +- Can profiles be configured with exactly one non-movable Ethereum address? +- Can memo routing be disabled? +- How are legacy profiles with existing linked addresses handled? + +### F02 — The “full Vortex compromise” bound is not a full-compromise bound + +**Priority:** P0 +**Affected PRD sections:** §4, §8.2, §8.3, §14 + +#### Concern + +The §8.3 analysis covers a compromised keeper and registry multisig. A full Vortex compromise also includes: + +- Monerium client credentials; +- factory bytecode and deployment calldata; +- frontend-provided configuration; +- Safe singleton and proxy-factory selection; +- Safe owners and threshold; +- enabled modules, guard, module guard, and fallback handler; +- passkey signer coordinates and verifier configuration; +- destination, fee recipient, recovery IBAN, and recovery policy. + +A Safe can contain the user's passkey owner and still contain an attacker owner or unrestricted module. The fixed Monerium ownership message can validate through the user's owner without proving that the remainder of the Safe configuration is safe. + +#### Required resolution + +Rename §8.3 to **“Post-deployment keeper and route-governance compromise”** and add separate threat bounds for: + +1. provisioning compromise; +2. frontend compromise; +3. Monerium credential compromise; +4. oracle compromise; +5. passkey compromise; +6. stablecoin issuer compromise; +7. dependency upgrade or code-substitution compromise. + +Before address linking, both frontend and backend should independently verify the deployed account against a versioned public manifest containing: + +- chain ID; +- exact Safe singleton runtime hash and version; +- canonical proxy factory; +- owners and threshold; +- enabled modules; +- fallback handler; +- transaction guard and module guard; +- module runtime hash and immutable arguments; +- destination; +- fee configuration; +- oracle and registry addresses; +- recovery configuration. + +#### Questions for the author + +- Which components are assumed honest during provisioning? +- What prevents a compromised frontend and backend from presenting a Safe with an extra module? +- What independent evidence can a user or external auditor use to verify a specific deployed account? + +### F03 — The one Monerium signature does not bind the destination or configuration + +**Priority:** P0 +**Affected PRD sections:** §3.1, §5.1, §10 Q2 + +#### Concern + +The fixed message `I hereby declare that I am the address owner.` contains no: + +- chain ID; +- Safe address in the signed text; +- destination; +- module implementation or runtime hash; +- fee rule; +- router/oracle configuration; +- recovery policy. + +The assertion proves control of the configured Safe owner under the Safe's current EIP-1271 behavior. It does not “implicitly ratify” an off-chain destination shown in the UI. + +Requiring cryptographic ownership of an arbitrary external destination creates a second conflict: ownership must be proven by the destination's key, which cannot generally be proven by the Safe passkey and may require a wallet extension or separate signer. + +#### Required resolution + +Choose one explicit model: + +1. **Two-signature model:** retain the Monerium link assertion and add an EIP-712 deployment-intent signature covering the complete manifest. Add a separate destination signature if destination ownership is required. +2. **Safe-as-destination model:** retain USDC in the passkey-owned Safe and remove arbitrary destination attestation and forwarding. +3. **Trusted provisioning model:** keep one signature but state that the user trusts Vortex's frontend and provisioning backend to deploy the displayed configuration. +4. **Custom signer model:** create and audit a custom WebAuthn signer whose validation of the fixed Monerium hash also commits to immutable account configuration. This adds substantial bespoke cryptographic risk and is not recommended for v1. + +#### Questions for the author + +- Is “one signature” a hard product constraint or a preference? +- What constitutes destination ownership for exchanges, smart accounts, and custodial deposit addresses? +- Is a UI confirmation intended as legal consent, cryptographic consent, or both? + +### F04 — The module topology is internally contradictory + +**Priority:** P0 +**Affected PRD sections:** §5.1, §6.3, §8.1, I8 + +#### Concern + +The PRD describes `VortexSwapModule` as: + +- an immutable per-deployment singleton; +- parameterized per Safe; +- containing per-user immutable destination/configuration; +- allowing the user to change destination. + +A single Solidity deployment cannot have different constructor immutables per Safe. A contract-level immutable destination also cannot be changed. If configuration is stored in a mapping, it is storage, not immutable, and requires an initialization/update authorization design. + +#### Required resolution + +Specify one topology in pseudocode and storage layout: + +**Option A — per-Safe minimal clone with immutable arguments** + +- one module instance per Safe; +- destination, oracle, maximum slippage, registry, and fee configuration encoded as immutable arguments; +- changing destination means deploying and owner-authorizing a new module, then disabling the old one. + +**Option B — singleton with Safe-keyed configuration** + +- configuration initialized atomically during Safe setup; +- initialization cannot be front-run or repeated; +- updates are accepted only when the corresponding Safe itself calls through an owner-authorized transaction; +- events and explicit versioning cover every mutation. + +Pin an exact audited Safe version and deployed addresses. `v1.4.1+` is not a reproducible security dependency. + +#### Questions for the author + +- Is the module one deployment per user or one deployment for all users? +- Where exactly is destination stored? +- What on-chain operation implements “change destination”? +- Does the custom `VortexAccountFactory` add value beyond canonical Safe factories sufficient to justify its audit surface? + +### F05 — The recovery validator is not an ordinary Safe module + +**Priority:** P0 +**Affected PRD sections:** §5.3, §6.3, §8.4, Q3, Q4 + +#### Concern + +Enabling a normal Safe module does not change Safe's `isValidSignature`. ERC-1271 support is normally provided by a fallback handler. The design also needs ERC-4337, whose `Safe4337Module` acts as both module and fallback handler. The future CoW proposal expects an extensible fallback-handler path. These cannot be installed independently without an explicit composition design. + +Further issues: + +1. ERC-1271 receives a message hash and signature. It cannot parse the original redeem string unless that string is encoded into the signature payload and re-hashed on-chain. +2. Monerium documents both full and shortened IBAN forms. The policy needs one canonical representation. +3. Monerium accepts timestamps close to the present or in the future; the contract must impose its own narrow validity window. +4. “Whitelisting the link-message shape” is dangerous. If this means returning valid without a genuine passkey owner signature, anyone could satisfy Monerium's ownership check for the Safe. +5. A Vortex-triggerable redemption, even to a pinned bank account, gives Vortex power to dispose of user assets and changes the legal custody analysis. +6. A refund IBAN can be closed, reassigned, or cease belonging to the user. +7. Replay protection needs more than timestamp parsing: chain, Safe, Monerium profile, order identity, exact amount, currency, canonical IBAN, and prior-use state must be bound. + +#### Required resolution + +The recommended v1 resolution is to remove automatic EIP-1271 redemption and use: + +- the user's passkey owner; plus +- an independent, user-controlled recovery owner; +- an owner-authorized Monerium redeem order when recovery is required. + +If automated redemption remains, write a separate protocol specification covering: + +- the exact fallback-handler composition; +- signature byte schema; +- message reconstruction and hash equality; +- canonical amount, timestamp, currency, and IBAN encoding; +- replay state; +- caller independence; +- passkey validation for the address-link message; +- interaction with ERC-4337 and CoW domains; +- legal treatment of Vortex's unilateral redemption authority. + +#### Questions for the author + +- Is `RecoveryValidatorModule` intended to be a module, fallback handler, owner, or ERC-7579 validator? +- How does it obtain the original message bytes from the ERC-1271 call? +- Who or what produces the `signature` bytes for a policy-authorized redemption? +- Does the link message still require a genuine passkey assertion? + +### F06 — Router address plus selector allowlisting is insufficient + +**Priority:** P0 +**Affected PRD sections:** §5.2, §7.4, I1, I2, I3, I7, I9 + +#### Concern + +A selector does not constrain the semantic fields inside calldata. Uniswap `exactInput`, for example, still contains: + +- arbitrary token path; +- recipient; +- amount in; +- minimum output; +- deadline. + +Aggregator entry points can contain arbitrary nested calls. A malicious router may also exploit unrelated token or Permit2 allowances previously created by the Safe owner. Post-conditions over EURe and USDC do not prove that no other approved asset was taken. + +The current I1 statement is therefore stronger than what the described implementation enforces. + +#### Required resolution + +For each supported route, use an immutable, router-specific adapter or have the module construct calldata internally. Enforce: + +- exact router address; +- `value == 0`; +- `operation == CALL`; +- exact token path `EURe V2 → EURC → USDC`; +- exact `amountIn`; +- recipient equal to the Safe during an atomic swap-and-forward flow; +- router-level `amountOutMinimum >= module minOut`; +- a short deadline or execution-validity rule; +- successful return from every `execTransactionFromModule` call; +- safe ERC-20 handling for missing/false return values; +- exact allowance reset; +- expected balance changes and final transfer success. + +Scope the guarantee to EURe and USDC in a dedicated Safe. State explicitly that unrelated assets or approvals introduced by the user are outside the guarantee unless independently protected. + +Governance should allow **immediate route revocation** but require delay for additions or authority expansion. Waiting seven days to remove a compromised router is unacceptable. + +#### Questions for the author + +- Will route calldata be constructed by the module, decoded by the module, or trusted from the keeper? +- How are aggregators with arbitrary executor calldata intended to satisfy I1? +- Is the Safe contractually/product-restricted to this flow only? + +### F07 — CoW is not replaceable behind the synchronous router interface + +**Priority:** P0 +**Affected PRD sections:** §7.4, §8.2 + +#### Concern + +ComposableCoW is a persistent order-authorization system followed by asynchronous settlement. It uses EIP-1271 through an extensible fallback handler and introduces a different lifecycle: + +- conditional-order authorization; +- watchtower discovery; +- order generation; +- solver execution; +- settlement-time signature verification; +- allowance and receiver constraints; +- expiry, cancellation, replay, and possibly partial fills. + +It does not execute inside one `swapAndForward` call where the module can synchronously snapshot balances, call a router, reset approval, verify deltas, and forward output. + +#### Required resolution + +Remove CoW from the claim that all future routes fit the same interface and invariants. Treat it as a separate v2 architecture with its own threat model and fallback-handler composition. + +That v2 specification must cover: + +- exact sell token and maximum amount; +- exact buy token and receiver; +- oracle-derived minimum buy amount; +- order validity and replay domain; +- partial fills; +- cancellation and migration; +- settlement allowance; +- fallback-handler coexistence with ERC-4337 and recovery; +- output attribution to fiat deposits. + +#### Questions for the author + +- Is CoW intended as a synchronous router, a standing order, or a recurring conditional order? +- How would the current post-swap balance-delta invariant be preserved across asynchronous settlement? + +### F08 — A permanent IBAN is not a one-shot Vortex ramp + +**Priority:** P0 +**Affected PRD sections:** §5.2, §6.1, §11 + +#### Concern + +The proposed IBAN can receive deposits repeatedly for years. Vortex's existing ramp model represents one quote and one transaction that recursively reaches a terminal `complete` or `failed` phase. + +Reusing that state machine creates unresolved questions: + +- What reopens a completed ramp? +- How are two rapid deposits represented? +- Which output belongs to which bank deposit if balances are batched? +- How are duplicate Monerium webhooks handled? +- What is the status of the account when one deposit succeeds and another is held for compliance? +- How are historical fees and execution rates represented? + +The current phase processor also documents a non-atomic multi-instance lock and retry-exhaustion gap. A persistent, repeatedly funded Safe should not rely on those semantics without database-enforced serialization. + +#### Required resolution + +Introduce separate persistent models: + +```text +MoneriumAccount + profileId + iban + safeAddress + currentConfigVersion + onboarding/compliance/account status + +FiatDeposit + moneriumOrderId + amount/currency + payment status + mint tx hash + log index + block + compliance status + +ConversionExecution + safeAddress + included deposit IDs + EURe input + USDC gross output + fee + USDC net output + destination + execution tx/status/error +``` + +Use unique Monerium order IDs and `(chainId, txHash, logIndex)` as idempotency keys. Serialize execution by Safe using an atomic database lock or queue. If deposits are batched, define an auditable allocation and rounding rule. + +#### Questions for the author + +- Is the Vortex public API expected to expose one ramp forever or one transaction per deposit? +- Are deposits converted independently or batched? +- What webhook/status object does a partner subscribe to for recurring deposits? + +### F09 — Oracle math and the economic bound are underspecified + +**Priority:** P1 +**Affected PRD sections:** §7.2, §8.3, §11 + +#### Concern + +The formula mixes human-readable and raw token/feed units. For raw values with: + +- EURe: 18 decimals; +- Chainlink EUR/USD: expected 8 decimals, but must be queried; +- USDC: 6 decimals; + +the scale denominator is `10^(18 + feedDecimals - 6)`, which is `10^20` for an 8-decimal feed. The written `10^(-12)` is understandable only if the rate is already a unitless human value, which Solidity will not receive. + +The bound also assumes one USDC equals one USD. EUR/USD does not directly price either EURe market basis or USDC/USD basis. Chainlink deviation threshold is not a complete bound on either stablecoin. + +FX feeds also have market-hours/weekend behavior while the AMM is continuously tradable. + +#### Required resolution + +Specify executable pseudocode including: + +- reading `decimals()`; +- `answer > 0`; +- `updatedAt != 0`; +- maximum age and weekend policy; +- round validity checks appropriate to the chosen feed contract; +- `mulDiv` ordering and overflow safety; +- whether minimum output rounds down; +- use or rejection of a USDC/USD feed; +- behavior during EURe or USDC depeg; +- a maximum oracle age that is an immutable safety ceiling, even if an operational value can be lowered. + +Rewrite the loss statement as: + +> The swap cannot deliver less than the configured percentage of the accepted oracle-model value, assuming the oracle is honest and both token contracts behave as modeled. + +Do not call that a bound on principal under oracle or stablecoin failure. + +#### Questions for the author + +- Is USDC/USD explicitly assumed to be 1.0? +- What happens from Friday FX close through Monday updates? +- What exact proxy address, feed decimals, heartbeat, and failure policy will be pinned? + +### F10 — The fee proposal contradicts the invariants + +**Priority:** P1 +**Affected PRD sections:** §3.3, I1, §8.3, §9, Q1 + +#### Concern + +I1 says USDC can move only to the user's destination. §9 proposes sending an in-module fee to a Vortex treasury. That adds another permitted recipient and changes: + +- custody language; +- user net-output calculation; +- minimum-output calculation; +- balance-delta post-conditions; +- worst-case Vortex extraction; +- event and accounting requirements. + +The module cannot be finalized while fee behavior remains open. + +#### Required resolution + +Choose before contract design: + +1. zero fee/loss-leader for v1; +2. off-chain subscription or billing; +3. immutable on-chain `feeBps`, immutable treasury, immutable maximum fee, and exact calculation from swap output. + +If option 3 is chosen, update I1 and §8.3 and distinguish the disclosed fee from slippage and malicious extraction. + +#### Questions for the author + +- Is the fee assessed per deposit, per batch, or per execution? +- Who pays when multiple small deposits are batched? +- Can the fee or treasury ever change, and under whose authorization? + +### F11 — Passkey-only self-rescue depends on Vortex's domain and services + +**Priority:** P1 +**Affected PRD sections:** §5.3, §11, Q3 + +#### Concern + +WebAuthn credentials are scoped to a relying-party ID. A community recovery site on an unrelated domain cannot invoke a passkey registered for Vortex's RP ID. + +Self-rescue also requires: + +- access to the relevant RP domain/origin; +- a recoverable or discoverable credential; +- credential metadata where needed; +- a transaction builder that knows the Safe/passkey signature format; +- an RPC provider; +- ETH or a Vortex-independent bundler/paymaster/relayer. + +Cloud synchronization is not guaranteed for every credential, device, policy, or authenticator. Safe's own guidance recommends combining passkeys with other authentication methods. + +#### Required resolution + +Make an independent, user-controlled recovery owner mandatory for v1, such as a second passkey under an independent RP, a hardware wallet, or a carefully audited user-controlled recovery scheme. + +Publish and test a disaster-recovery package that can: + +- reconstruct the account from public chain data; +- invoke the user's credential under the correct RP ID; +- build a direct Safe transaction without Vortex's API; +- fund gas without Vortex's paymaster; +- disable the module, change destination, or redeem EURe. + +Document domain-control continuity if Vortex ceases operation. + +#### Questions for the author + +- What RP ID will be used? +- Are credentials required to be discoverable and backup-eligible? +- How does recovery work if Vortex loses its domain or backend database? +- Why is recovery optional if permanent self-custody is a core claim? + +### F12 — The liquidity cap is based on a snapshot, not a durable bound + +**Priority:** P1 +**Affected PRD sections:** §2.1, §7.3, §7.4, §12 + +#### Concern + +Pool TVL is not equivalent to executable depth within 100 bps. Concentrated liquidity can move out of range, providers can withdraw, and routing can change between measurements. + +“Successive executions spaced over time” is keeper policy, not an on-chain invariant. A compromised keeper or permissionless caller can submit multiple cap-sized transactions in rapid succession. Even honest executions may not recover liquidity merely because time passed. + +The quoted 10k and 50k results are also not reproducible without block numbers, quote parameters, gas/fee treatment, route, quote expiry, and provider response. + +#### Required resolution + +- Record a reproducible liquidity-assessment methodology and block number. +- Treat `minOut` as the safety condition and the size cap as an availability parameter. +- Monitor executable on-chain quotes and active liquidity continuously. +- Define launch and pause thresholds. +- If pacing is a requirement, enforce an aggregate per-Safe or global rate limit on-chain; otherwise describe pacing as best-effort only. +- Do not raise caps solely from TVL. + +#### Questions for the author + +- What exact evidence produced “~zero impact” and “~3.6% impact”? +- Is the cap intended to protect price, liveness, platform exposure, or all three? +- What automatically pauses execution when the pool migrates or liquidity disappears? + +### F13 — Webhook, reorg, concurrency, and attribution rules are incomplete + +**Priority:** P1 +**Affected PRD sections:** §5.2, §6.1, §11, §14.9 + +#### Concern + +“Webhook plus on-chain watcher” is a trigger strategy, not an idempotency or reconciliation design. + +Missing requirements include: + +- Monerium HMAC verification over raw request bytes; +- timestamp/replay-window validation; +- constant-time signature comparison; +- persisted webhook-ID deduplication; +- returning `200` promptly and processing asynchronously; +- event ordering and out-of-order updates; +- canonical-chain confirmation policy; +- reorg reconciliation using block hash and log identity; +- unique keeper nonce management across instances; +- serialization of concurrent executions for one Safe; +- stale private-relay transaction replacement; +- allocation of one batched output across several fiat deposits; +- reconciliation between Monerium order amount and actual minted amount; +- notification correction if an event is later reorged or reversed operationally. + +#### Required resolution + +Add an operational state and idempotency specification. On-chain balance should be the execution safety source, while Monerium issue-order IDs should be the accounting identity source. + +Use a database-enforced per-Safe execution lock. Contract-level reentrancy protection does not serialize separate transactions from multiple keeper processes. + +#### Questions for the author + +- How many confirmations are required before conversion and before notification? +- Can two deposits be intentionally combined into one swap? +- How are fees and output allocated when that happens? +- What prevents two keeper instances from racing the same Safe balance? + +### F14 — Immutable modules need an incident and migration path + +**Priority:** P1 +**Affected PRD sections:** §3.3, §7.4, I5, §11, §12 + +#### Concern + +Immutability prevents Vortex from introducing a malicious upgrade, but it also prevents patching a discovered module bug. Users with lost passkeys may continue receiving future deposits into an account whose automation is disabled or vulnerable. + +The registry can halt routes but cannot repair module logic. Moving an IBAN to a new Safe reintroduces F01 and requires an explicit user-authorization model. + +#### Required resolution + +Define before launch: + +- immediate route-disable authority; +- how Monerium deposits are paused or rejected during an incident; +- how users are notified before sending more EUR; +- module version discovery; +- owner-authorized migration to a new module/Safe; +- whether the existing IBAN can move only with the old Safe's signature; +- treatment of users who lost all recovery methods; +- sunset and support duration for old versions. + +#### Questions for the author + +- What happens after a critical module vulnerability is discovered at 02:00 UTC? +- Can Monerium suspend an individual IBAN immediately? +- How is a safe migration reconciled with the “one signature ever” promise? + +### F15 — Compliance and data-protection architecture is incomplete + +**Priority:** P1 +**Affected PRD sections:** §5.1, §6.1, §10, Q6, Q7 + +#### Concern + +The whitelabel flow makes Vortex responsible for handling or orchestrating sensitive identity, banking, and wallet data. The PRD does not define: + +- personal-only versus corporate scope; +- KYC sharing versus KYC reliance prerequisites; +- controller/processor roles and DPA terms; +- retention and deletion rules; +- encryption and access control for IBAN/profile data; +- log redaction and support tooling; +- sanctions and destination screening; +- periodic re-screening of a static destination; +- profile suspension, re-KYC, or closure behavior; +- SEPA recall/fraud claim allocation after EURe has been swapped and forwarded; +- legacy profile migration consent; +- source-of-funds and expected-volume monitoring; +- travel-rule and third-party destination consequences. + +#### Required resolution + +For v1, strongly consider personal, newly onboarded users only. Add a data-flow diagram and retention/access table. Obtain explicit MSA answers for recalls, fraud, compliance holds, profile suspension, IBAN changes, and losses after immediate conversion. + +Do not present the legal non-custody or MiCA conclusion as established until counsel has assessed both on-chain module authority and Monerium control-plane authority. + +#### Questions for the author + +- Is corporate onboarding genuinely in v1 scope? +- Who bears loss if a SEPA transfer is later recalled or alleged fraudulent? +- Can Vortex continue converting while a profile is under review or suspended? +- Who screens and re-screens the destination? + +### F16 — Dust, gas griefing, and destination edge cases break the automatic promise + +**Priority:** P1 +**Affected PRD sections:** §3, §5.1, §5.2, §7.3, §9, §11 + +#### Concern + +The headline says any EUR wired to the IBAN is automatically converted and forwarded. The €25 minimum means a smaller transfer may remain indefinitely as EURe. Mainnet gas can also make €25 uneconomical. + +A user can create recurring keeper costs by sending many small transfers. Vortex cannot prevent transfers to an already issued IBAN, so API-side rate limiting is insufficient. + +Destination risks include: + +- zero/burn/token/router/module addresses; +- wrong chain despite a valid EVM address; +- a contract with no method to recover received ERC-20s; +- exchange minimum-deposit thresholds; +- exchange address rotation or closure; +- destination sanctions/blacklisting after onboarding; +- destination equal to the Safe, where forwarding is redundant; +- loss of access to the destination despite continued automatic transfers. + +#### Required resolution + +- State a minimum deposit and maximum processing delay in user-facing terms. +- Define whether dust is refunded, accumulated, or held indefinitely. +- Make the operational threshold responsive to gas while retaining an immutable safety ceiling/floor where necessary. +- Batch deposits deliberately and disclose batching latency. +- Define destination validation and denylist rules. +- Warn that Vortex cannot prove recoverability of arbitrary contracts or exchange deposits. +- Monitor destination blacklist/sanctions state and define what happens to future deposits. + +#### Questions for the author + +- Who pays gas when fees are below execution cost? +- What happens to a €1 deposit if no further transfer arrives? +- What happens if the destination exchange raises its minimum deposit above the user's normal amount? + +### F17 — Failure-mode statements need correction and sharper scope + +**Priority:** P2 +**Affected PRD sections:** §5.2, §8.5, §11 + +#### Concern + +Several statements should be corrected or qualified: + +1. If swap and USDC forwarding are one atomic transaction, a Circle-blacklisted destination should make the final transfer revert and roll back the swap. Newly acquired USDC should not remain in the Safe from that execution. +2. “Vortex can censor, not execute” is inaccurate if Vortex controls a module capable of causing swap and transfer. The intended distinction is that Vortex can execute only the constrained policy. +3. “Cannot withhold” conflicts with acknowledged keeper censorship, registry route removal, and possible Monerium control-plane changes. Prefer “cannot redirect already-minted assets outside the enumerated policy under stated assumptions.” +4. “Maximum extractable value is 1.15%” is relative to the oracle model, excludes fees, stablecoin basis, provisioning, Monerium authority, other Safe assets/allowances, and oracle failure. +5. Passkeys may be synced; they are not universally cloud-synced by default. +6. Mainnet EIP-7951 is already live as of this PRD date; the spike should benchmark the exact chosen Safe/passkey implementation rather than treat post-Fusaka support as hypothetical. + +#### Required resolution + +Replace absolute language with a matrix of guarantees and assumptions. Every user-facing security claim should specify: + +- asset and lifecycle stage; +- trusted dependencies; +- authorized Vortex actions; +- excluded compromise classes; +- liveness versus theft protection; +- recovery requirements. + +### F18 — v1 combines too many overlapping extension mechanisms + +**Priority:** P2 +**Affected PRD sections:** §6, §7.4, §8.4, §12 + +#### Concern + +The proposed v1/v2 path requires engineers and auditors to reason simultaneously about: + +- Safe owner signatures; +- WebAuthn encoding and P-256 verification; +- ERC-4337 module/fallback behavior; +- a custom swap module; +- a router registry and timelock; +- a custom recovery EIP-1271 policy; +- potentially an extensible fallback handler; +- potentially ComposableCoW; +- a custom account factory; +- a persistent off-chain state machine. + +Much of the underlying problem is intrinsically complex, but the proposed expression adds avoidable cross-product complexity. In particular, recovery, ERC-4337, and CoW all touch Safe extension/fallback behavior, while the generic registry attempts to make materially different execution models look interchangeable. + +#### Required resolution + +Apply design sacrifice to v1: + +- one exact Safe deployment path; +- one passkey owner plus one independent recovery owner; +- one per-Safe swap module topology; +- one hard-coded Uniswap adapter; +- no generic aggregator calldata; +- no CoW; +- no automatic redeem validator; +- no mutable fee until finalized; +- one persistent-account/deposit/execution data model. + +Add complexity only after a concrete operational need and a new threat model justify it. + +## 6. Proposed reduced v1 architecture + +This is a strawman for discussion, not a final specification. + +### 6.1 Scope + +- Personal profiles only. +- Newly onboarded users only; legacy migration deferred. +- Ethereum mainnet only. +- EURe V2 → EURC → USDC through one Uniswap v3 route. +- One destination configured at onboarding. +- Explicit minimum deposit and processing SLA. +- No automated offramp/redeem. +- No CoW or generic aggregators. + +### 6.2 Monerium prerequisite + +Do not proceed unless Monerium provides an enforceable mechanism under which the IBAN association cannot be moved and no alternative mint destination can be added or selected without authorization from the currently linked Safe. + +If that is unavailable, preserve the product but change the security claim to acknowledge Vortex trust before mint. + +### 6.3 Safe + +- Pin exact canonical Safe contracts and runtime hashes. +- Passkey signer as one owner. +- Independent user-controlled recovery owner from launch. +- Threshold/recovery structure explicitly documented. +- Canonical Safe deployment components preferred over a custom factory unless atomic custom deployment is demonstrably necessary. +- Publish a configuration manifest and verifier. + +### 6.4 Swap policy + +- One per-Safe immutable module clone. +- Module constructs Uniswap calldata internally. +- Permissionless trigger if timing-grief analysis accepts it; otherwise a narrowly authorized keeper with a liveness fallback. +- `CALL` only, `value == 0`. +- Exact EURe approval, exact path, Safe recipient, module-derived `minOut`, short deadline. +- Atomic swap, approval reset, output check, fee calculation if any, and final transfer. +- Immediate route disable; no new route types in v1. + +### 6.5 Pricing + +- Exact Chainlink feed address and decimal handling. +- Explicit USDC/USD assumption or second feed. +- Immutable maximum staleness and maximum slippage. +- Weekend policy. +- Reproducible rounding and overflow behavior. + +### 6.6 Backend + +- Persistent `MoneriumAccount`. +- One `FiatDeposit` per Monerium issue order. +- One `ConversionExecution` per swap or intentional batch. +- Atomic per-Safe job serialization. +- HMAC-verified, deduplicated Monerium webhooks. +- On-chain watcher as reconciliation and trigger backup. +- Explicit reorg/finality policy. + +### 6.7 Recovery and migration + +- User or independent recovery owner signs any Safe exit or Monerium redeem order. +- No Vortex-only EIP-1271 redemption bypass. +- Public, tested disaster-recovery tooling. +- Immediate account/route pause and user notification during incidents. +- Owner-authorized module or account migration. + +### 6.8 Fees + +Prefer zero fee or off-chain billing for the first pilot. If an on-chain fee is required, finalize it before audit and include its immutable recipient and maximum in the signed configuration and contract invariants. + +## 7. Required acceptance gates + +The design should not advance from architecture review until all of the following are complete: + +- [ ] Monerium confirms IBAN/address reassociation authorization and memo-routing controls in writing and sandbox. +- [ ] The trust model is split by lifecycle stage and compromise class. +- [ ] One concrete module topology and storage layout is selected. +- [ ] The onboarding signature model honestly addresses configuration and destination consent. +- [ ] Recovery is redesigned or removed from v1. +- [ ] Router calldata is constructed or fully semantically validated on-chain. +- [ ] CoW is removed from the same-interface claim or specified separately. +- [ ] The backend uses persistent accounts plus per-deposit/per-execution records. +- [ ] Oracle pseudocode, decimals, staleness, weekend behavior, and rounding are finalized. +- [ ] Fee behavior is finalized and added to invariants. +- [ ] A Vortex-independent user recovery path is demonstrated. +- [ ] Liquidity measurements are reproducible and continuously monitored. +- [ ] Webhook, reorg, concurrency, and idempotency behavior is specified. +- [ ] Incident pause, module migration, and IBAN migration procedures are documented. +- [ ] Legal, compliance, privacy, sanctions, and payment-reversal responsibilities are signed off. +- [ ] User-facing minimums, timing, rate basis, fees, and failure behavior are drafted. +- [ ] Exact dependency versions, addresses, runtime hashes, and audit artifacts are pinned. + +## 8. Requested author response + +Please respond by copying this table and filling in the final two columns. + +| ID | Disposition (`Accept`, `Reject`, `Modify`, `Needs validation`) | Author response / proposed PRD change | +|---|---|---| +| F01 | | | +| F02 | | | +| F03 | | | +| F04 | | | +| F05 | | | +| F06 | | | +| F07 | | | +| F08 | | | +| F09 | | | +| F10 | | | +| F11 | | | +| F12 | | | +| F13 | | | +| F14 | | | +| F15 | | | +| F16 | | | +| F17 | | | +| F18 | | | + +The most important requested answer is not whether each mechanism can be implemented individually. It is whether the revised composition supports a precise, end-to-end security statement that remains true under every authority Vortex actually holds. + +## 9. Primary references + +- [Monerium Whitelabel documentation](https://docs.monerium.com/whitelabel/) +- [Safe smart-account modules](https://docs.safe.global/advanced/smart-account-modules) +- [Safe fallback handlers](https://docs.safe.global/advanced/smart-account-fallback-handler) +- [Safe and ERC-4337](https://docs.safe.global/advanced/erc-4337/4337-safe) +- [Safe and passkeys](https://docs.safe.global/advanced/passkeys/passkeys-safe) +- [Safe passkey signer guidance](https://docs.safe.global/sdk/signers/passkeys) +- [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) +- [EIP-7951 P-256 precompile](https://eips.ethereum.org/EIPS/eip-7951) +- [Ethereum Fusaka overview](https://ethereum.org/roadmap/fusaka/) +- [Chainlink Ethereum EUR/USD feed](https://data.chain.link/feeds/ethereum/mainnet/eur-usd) +- [ComposableCoW architecture](https://cowswap.mintlify.app/composable-cow/architecture) +- [Vortex state-machine security specification](../security-spec/03-ramp-engine/state-machine.md) +- [Historical Vortex Monerium security specification](../security-spec/05-integrations/monerium.md) From 0069fdc71aa8126a5672f0f1cb99065c5db75f5b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 13 Jul 2026 11:32:18 +0200 Subject: [PATCH 03/59] Revise PRD doc --- docs/prd/monerium-eur-usdc-onramp.md | 406 ++++++++++++++++----------- 1 file changed, 235 insertions(+), 171 deletions(-) diff --git a/docs/prd/monerium-eur-usdc-onramp.md b/docs/prd/monerium-eur-usdc-onramp.md index c27dd7213..52e67d2b0 100644 --- a/docs/prd/monerium-eur-usdc-onramp.md +++ b/docs/prd/monerium-eur-usdc-onramp.md @@ -1,233 +1,297 @@ # PRD: Quoteless EUR → USDC (Ethereum) Onramp via Monerium Whitelabel -**Status:** Draft for architecture review +**Version:** 2.0 (response to [architecture review](./monerium-eur-usdc-onramp-architecture-review.md); v1 in git history) +**Status:** Revised draft — awaiting re-review and external gates (§13) **Date:** 2026-07-13 **Owner:** Vortex team -**Reviewer instructions:** see [§14 Reviewer checklist](#14-reviewer-checklist) + +**Changes since v1 (for the re-reviewer):** + +- Trust model rewritten as scoped guarantees per lifecycle stage; the absolute "Vortex can never redirect funds" claim is retracted (F01, F02, F17). +- Monerium control-plane authority (`PATCH /ibans/{iban}` on bearer auth alone) verified against live docs and added as launch gate G1 (F01). +- One-signature claim corrected: provisioning is a trusted step, made verifiable via a published configuration manifest; no cryptographic-consent claim (F02, F03). +- Module topology fixed: one immutable singleton with Safe-keyed configuration, atomic initialization, Safe-only mutation (F04). +- Automatic EIP-1271 redeem validator **removed from v1**; recovery redesigned around a mandatory independent recovery owner (F05, F11). +- Keeper-supplied route calldata removed: the module constructs Uniswap calldata internally; generic router registry removed from v1; instant pause vs. timelocked expansion (F06). +- CoW removed from the "same interface" claim; deferred to a separate v2 specification (F07). +- ERC-4337 removed from v1 entirely; rescue path is plain `execTransaction` (F18, F05). +- Backend redesigned around persistent `MoneriumAccount` / `FiatDeposit` / `ConversionExecution` models with idempotency and per-Safe serialization; does not reuse the one-shot ramp state machine (F08, F13). +- Oracle math specified with raw-unit pseudocode, explicit USDC/USD assumption, weekend policy (F09). +- Fee finalized structurally: `feeBps` in per-account config (pilot = 0), immutable `MAX_FEE_BPS` and treasury in the singleton; I1 updated (F10). +- Liquidity caps reframed as availability parameters; `minOut` is the safety condition; reproducible measurement + monitoring required (F12). +- Incident, migration, compliance, dust, and destination-edge sections added (F14, F15, F16). +- Failure-mode corrections applied, incl. atomic-revert behavior on blacklisted destination (F17). +- Filled review response table embedded as Appendix A. --- ## 1. Summary -Vortex adds a new onramp flow: a user onboards once, receives a **dedicated virtual IBAN** (issued by Monerium under Vortex's whitelabel integration), and from then on any EUR they wire to that IBAN is **automatically converted to USDC on Ethereum mainnet and forwarded to a pre-configured, static destination address** — with no per-transfer quote, signature, or interaction. +Vortex adds a new onramp: a user onboards once, receives a **dedicated virtual IBAN** (issued by Monerium under Vortex's whitelabel integration) linked to a **user-owned Safe** on Ethereum. EUR wired to that IBAN is minted as EURe into the Safe and automatically converted to USDC and forwarded to a **destination address fixed at onboarding** — no per-transfer quote, signature, or interaction. -The user's IBAN is linked to a **user-owned smart contract account (Safe)** on Ethereum. Monerium mints EURe directly into that Safe. A constrained, **immutable Vortex module** on the Safe performs the EURe→USDC swap and forwards proceeds to the fixed destination. The design goal is that **Vortex is never a custodian**: at no point can Vortex redirect, withhold, or seize user funds — provably, at the contract level. +**Security posture (honest version):** this design does *not* claim Vortex can never touch user funds. It provides **scoped guarantees per lifecycle stage** (§4): before mint, Vortex and Monerium are trusted parties with defined, monitored, contractually constrained authority; after mint, an immutable on-chain policy limits Vortex's authority to executing a fixed conversion, pausing it, and tuning bounded availability parameters — it cannot redirect minted principal outside the enumerated policy, under the stated assumptions. -## 2. Background +## 2. Scope (reduced v1) -- Monerium is a licensed EMI issuing EURe (ERC-20 e-money token, 1:1 EUR). Each KYC'd profile can link blockchain addresses (per chain) and receives a personal IBAN; incoming SEPA payments are minted as EURe to the linked address, typically within seconds (SEPA Instant: "within 5 seconds, including the time to mint or burn EURe tokens onchain" — [monerium.com/partners](https://monerium.com/partners/)). -- Vortex will operate Monerium's **Whitelabel plan**: profile creation, KYC/KYB submission, address linking, and IBAN issuance all run through the Monerium API under the Vortex brand ([docs.monerium.com/whitelabel](https://docs.monerium.com/whitelabel)). Monerium has confirmed (commercially) that users onboarded earlier via OAuth/legacy integrations can be taken over. -- Address linking requires a one-time signature over the exact message `"I hereby declare that I am the address owner."`. For contract accounts Monerium validates via **EIP-1271** `isValidSignature` on-chain ([docs.monerium.com/oauth/#eip-1271](https://docs.monerium.com/oauth/#eip-1271)). **The contract must already be deployed when the link is validated** — the docs do not mention ERC-6492/counterfactual signature support. Linking is **per-chain** (`ethereum` for this flow). -- Redeem orders (EURe → SEPA payout) also accept EIP-1271 signatures — relevant for the recovery path (§8.4). -- **Precedent:** Gnosis Pay uses the same primitive stack (Safe + Monerium EURe + constrained Zodiac modules) to build a non-custodial card product. This validates the general architecture; our module constraints differ but the trust model is analogous. +**In scope:** -### 2.1 Verified liquidity facts (as of 2026-07-10 — re-verify at build time) +- Personal Monerium profiles only; **newly onboarded users only** (legacy migration deferred). +- Ethereum mainnet only. +- One swap route: EURe V2 → EURC → USDC via one pinned Uniswap v3 router; calldata constructed by the module. +- One destination per account, set at onboarding; changeable only by the Safe's owners. +- Explicit minimum deposit and processing SLA (§10.3). +- Mandatory independent recovery owner (§8). +- Zero on-chain fee for the pilot; fee structure finalized in the contract regardless (§9). -- EURe **V2** on Ethereum: `0x39b8B6385416f4cA36a20319F70D28621895279D`. The V1 token (`0x3231Cb...273f`) is deprecated; several stale V1 pools still show TVL — must not be used. -- There is **no meaningful direct EURe/USDC pool on Ethereum**. The real route is **EURe → EURC → USDC**: - - EURe/EURC Uniswap v3 0.05% (`0x2a817bd5018f9782f84398067639230121e07d4c`): ~$104k TVL — the bottleneck hop. - - EURC/USDC Uniswap v3 0.05% (`0x95dbb3c7546f22bce375900abfdd64a4e5bd73d6`) + v4 pools: >$5M TVL, ~$2.6M daily volume — effectively unconstrained at our sizes. -- Measured aggregator quotes: 10k EURe → USDC at spot (~zero impact); 50k EURe via pure AMM routing suffers **~3.6% impact** (bottleneck pool exhausted), while CoW Swap quoted ~spot at 50k via solver/RFQ liquidity. -- Consequence: v1 must enforce **per-swap size caps** (§7.3) and the routing layer must be replaceable (§7.4). +**Out of scope for v1** (each requires its own future spec): offramp/automated redeem, CoW or any aggregator, ERC-4337, corporate profiles, legacy-user migration, other chains, per-transfer destinations, memo-routing features. -## 3. Goals +## 3. Verified external facts -1. One-time onboarding: single passkey ceremony; **exactly one signature** (the Monerium link message). No wallet extension required. -2. Fully automatic post-onboarding flow: EUR in → USDC at destination, no user action. -3. **Non-custodial by construction**: Vortex cannot change the destination, cannot withdraw funds, cannot upgrade logic into something that can. Bounded worst-case loss from any Vortex compromise (§8.3). -4. Swap routing is replaceable over time (pools/DEXes change) **without** weakening guarantee 3. -5. Reasonable execution quality: bounded slippage vs. EUR/USD oracle rate. +Re-verify all before build; dates note when checked. -### Non-goals (v1) +- **Monerium link message** (2026-07-13): fixed string `"I hereby declare that I am the address owner."`; smart-contract accounts validated via on-chain EIP-1271 `isValidSignature(bytes32,bytes)`; contract must be deployed at validation time (no ERC-6492 documented); linking is per-chain. Redeem orders also accept EIP-1271. ([docs.monerium.com/oauth/#eip-1271](https://docs.monerium.com/oauth/#eip-1271)) +- **Monerium control plane** (2026-07-13, **F01 verified**): `PATCH /ibans/{iban}` — "Move an existing IBAN to a specified address an chain. All incoming EUR payments will automatically be routed to the address on that chain." Authorization: **API client bearer token only**; no signature from the currently linked address. `POST /addresses` requires a signature only from the **new** address's owner. Consequence: whoever holds Vortex's whitelabel credentials can redirect future mints. Memo-based routing was **not** found in current docs (review's sub-claim unconfirmed). ([docs.monerium.com/api](https://docs.monerium.com/api)) +- **Safe passkeys**: WebAuthn credentials as Safe owners via Safe's WebAuthn signer contracts; Ethereum mainnet has the EIP-7951 P-256 precompile since Fusaka (Dec 2025). Exact signer contracts, addresses, and gas to be pinned and benchmarked in spike G0. +- **Liquidity snapshot** (2026-07-10; see methodology caveat §7.4): no meaningful direct EURe/USDC pool on Ethereum. Route: EURe/EURC Uniswap v3 0.05% (`0x2a817bd5018f9782f84398067639230121e07d4c`, ~$104k TVL — bottleneck) → EURC/USDC 0.05% (`0x95dbb3c7546f22bce375900abfdd64a4e5bd73d6`, >$5M TVL). Aggregator quotes: ~spot at 10k EURe; ~3.6% impact at 50k via pure AMM. These are **snapshots, not durable bounds** (F12). +- **EURe V2 (Ethereum)**: `0x39b8B6385416f4cA36a20319F70D28621895279D`. V1 (`0x3231Cb...273f`) is deprecated; several stale V1 pools still show TVL and must be excluded from routing and tests. -- Offramp (USDC → EUR) — future work; the same account layout supports it via Monerium redeem orders. -- Quotes, limit prices, or user-selectable destinations per transfer. -- Chains other than Ethereum mainnet for mint/swap/delivery. -- Supporting user-supplied external wallets as the deposit target. +## 4. Trust model — scoped guarantees by lifecycle stage -## 4. Actors & trust model +Replaces v1 §4/§8.3. Every user-facing security claim must be traceable to one row. -| Actor | Role | Trust required | -|---|---|---| -| User | Owns passkey → owns Safe; sends EUR from their bank | Trusts Monerium (fiat/e-money layer) and audited contracts; does **not** need to trust Vortex for fund safety | -| Vortex backend | Whitelabel API client: onboarding, KYC submission, webhooks; runs the keeper | Can censor (not execute) and delay; cannot steal beyond bounded slippage margin (§8.3) | -| Monerium | EMI: KYC of record, IBAN issuance, EURe mint/burn | Fully trusted for fiat layer (licensed, regulated); EURe token has issuer admin powers (freeze/upgrade) inherent to e-money | -| Keeper (Vortex-operated) | Triggers swap+forward when EURe arrives | Untrusted for safety — all safety is enforced in the module; trusted only for liveness | -| Safe (per user) | Holds EURe transiently; owner = user passkey **only** | Battle-tested Safe v1.4.1+ contracts | -| VortexSwapModule | Immutable module enabled on the Safe at setup | The security-critical component — see invariants §8.2 | +| Stage | Guarantee | Trusted dependencies | Vortex's authority | Excluded / residual | +|---|---|---|---|---| +| **S0 Provisioning** (onboarding) | Deployed account configuration is **verifiable** against a published manifest before first deposit; fraud is detectable, not cryptographically prevented | Vortex frontend + backend + deployment path at time of onboarding; Safe & signer contracts as audited | Full (Vortex constructs the account) | A compromised provisioning pipeline can deploy a hostile account. Mitigation: manifest + independent verifier (§6.4); no cryptographic consent claim is made (F03) | +| **S1 Fiat ingress** (bank → Monerium → mint) | Deposits mint to the linked Safe **while the IBAN association is unchanged**; association changes are monitored and alarmed | Monerium (regulated EMI); **Vortex's Monerium API credentials** (F01) | Can re-associate the IBAN via `PATCH /ibans` (bearer token only) → redirect *future* mints, absent Monerium-side controls (gate G1) | Monerium insolvency/compliance action; credential theft. Mitigations: G1 contractual/technical pinning, credential isolation (HSM/scoped tokens if available), continuous association monitoring + user alert + pause | +| **S2 On-chain conversion** (EURe in Safe → USDC) | Under assumptions A1–A4 (below): minted assets cannot leave the Safe except (a) into the fixed swap returning ≥ `minOut` USDC to the Safe, (b) USDC to `destination`, (c) fee ≤ `feeBps` (pilot 0) to the immutable treasury. Max adverse extraction per swap relative to the oracle model = slippage margin + configured fee | Chainlink EUR/USD (A1); EURe/EURC/USDC token contracts behave as modeled, incl. issuer powers (A2); audited Safe + module code (A3); USDC/USD ≈ 1 within the slippage margin (A4) | Execute the fixed policy; pause instantly; tune availability params within immutable bounds (§7.3); nothing else | Oracle compromise, stablecoin depeg beyond margin, token-issuer freeze/blacklist, undiscovered contract bugs. Not covered: unrelated assets/approvals the user adds to the Safe (§7.2) | +| **S3 Delivery** | USDC reaches `destination` exactly as forwarded | Destination remains valid, non-blacklisted, and accessible to the user | None (cannot change destination) | Destination attestation is legal, not cryptographic (§6.3); exchange address rotation, blacklisting (§10.4) | +| **S4 Recovery / exit** | The user can always exit with assets using their owners (passkey and/or recovery owner) without Vortex's API, given public tooling + any funded relayer | User retains ≥1 owner credential; Ethereum RPC access | None (cannot block `execTransaction`) | Passkey RP-ID depends on Vortex's domain (§8.2); loss of **all** owner credentials strands user-initiated actions (automation continues) | -**Custody analysis:** the Safe's only owner is the user's passkey. Vortex is not an owner, holds no keys to the account, and its module cannot move assets anywhere except (a) into a swap that must return oracle-checked USDC, and (b) USDC to the immutable destination. Vortex's practical powers are limited to *not acting* (censorship/liveness failure), which does not constitute control of funds. Formal legal sign-off required (§13). +**Liveness vs. safety:** Vortex can always *fail to act* (keeper down, pause engaged, Monerium relationship terminated). Liveness failures leave funds as EURe in the user's Safe (S2) or as unminted fiat claims at Monerium (S1); they do not move assets. ## 5. End-to-end flows -### 5.1 Onboarding (one passkey ceremony, one signature) +### 5.1 Onboarding + +1. Vortex-branded KYC (personal profiles only), submitted to Monerium via whitelabel API; approval via webhook. +2. **Passkey creation.** RP ID = Vortex's apex domain (pinned in docs); credential required to be discoverable and backup-eligible (enforced via WebAuthn `residentKey: required`, attestation-checked where possible). Documented explicitly: sync is typical, not guaranteed (F17.5). +3. **Recovery owner setup (mandatory, F11).** User chooses: second passkey on another device, an existing EOA/hardware wallet, or a **printable one-time recovery key** (EOA generated client-side, shown once, never stored by Vortex). Safe owners = [WebAuthnSigner, recoveryOwner], threshold 1. +4. **Destination collection.** Validation per §10.4; user attests ownership of the destination (checkbox + legal language — this is *legal consent*, not cryptographic proof; F03). +5. **Atomic deployment** via canonical Safe components (§6.1): proxy factory → Safe setup with a minimal audited setup library that enables `VortexSwapModule` and calls `initialize(destination, feeBps)` in the same transaction. Vortex pays gas. +6. **Manifest publication + verification (§6.4).** Onboarding halts unless the independent verifier confirms the deployed account matches the manifest. +7. **The Monerium link signature**: passkey signs the fixed link message; validated via the Safe's EIP-1271 (CompatibilityFallbackHandler → WebAuthn signer). This is the single signature the *flow* requires; the recovery setup may involve its own ceremony. "One signature" is a UX goal for the Monerium step, not a security claim (F03). +8. Vortex calls `POST /addresses` (chain: ethereum); Monerium issues the IBAN. +9. **Disclosure screen**: fee rule, rate basis (Chainlink EUR/USD ± slippage bound), minimum deposit, processing SLA, weekend behavior, failure behavior, S0–S4 trust summary in plain language. + +### 5.2 Steady-state deposit + +1. User wires EUR (SEPA / SEPA Instant) to their IBAN. Monerium mints EURe (V2) to the Safe. +2. Backend ingests the Monerium webhook (HMAC-verified, deduplicated; §11.2) and/or the on-chain Transfer watcher; records a `FiatDeposit`. +3. Keeper calls `swapAndForward(safe)` (§7.1) under a per-Safe database lock; submits via private orderflow (Flashbots Protect). +4. On confirmed execution: record `ConversionExecution`, allocate output to deposits (§11.4), notify the user with amounts and tx hash (notification correction path per §11.3). + +### 5.3 Exit and recovery + +- Any owner (passkey or recovery owner) can execute arbitrary Safe transactions via plain `execTransaction` — withdraw, disable the module, change `destination`, or sign a Monerium redeem order (EIP-1271). No ERC-4337 in v1: Vortex relays owner-signed transactions and pays gas; **independently**, any funded account can submit `execTransaction` with valid owner signatures. +- **Disaster-recovery package (mandatory deliverable, F11):** public, versioned tooling that reconstructs the account from chain data + manifest, produces the WebAuthn assertion under the correct RP ID (requires the RP domain — see §8.2), builds and submits `execTransaction` against any RPC, without any Vortex service. Tested in CI against a fork. +- **Passkey loss:** automation continues (keeper needs no user signature); the recovery owner restores user control. Loss of **both** owners: automation still delivers future deposits to `destination`; stranded EURe (paused route) is unrecoverable — disclosed at onboarding. + +## 6. Account provisioning + +### 6.1 Components (exact pins required before audit) + +- Canonical Safe v1.4.1: singleton, `SafeProxyFactory`, `CompatibilityFallbackHandler` — pinned by address **and runtime hash** in the manifest schema. No custom factory; deployment uses `createProxyWithNonce` + a minimal audited `VortexSetupLib` (delegatecalled from Safe `setup`) whose only job is `enableModule` + `module.initialize` (F04 Q4: the custom surface is one small library, not a factory). +- Safe WebAuthn signer contracts (shared verifier or per-user signer proxy — decide in G0 with gas benchmarks; EIP-7951 path preferred). +- Fallback handler is the canonical `CompatibilityFallbackHandler` **only** (needed for EIP-1271 link validation). No 4337 module, no custom handlers (F05, F18). + +### 6.2 `VortexSwapModule` topology (F04 — Option B) + +One immutable singleton deployment; per-Safe configuration in storage. + +```solidity +// Immutable (constructor): EURE, EURC, USDC, UNISWAP_ROUTER, ORACLE, ORACLE_DECIMALS, +// MAX_ORACLE_AGE, SLIPPAGE_BPS, MAX_FEE_BPS, FEE_RECIPIENT, PATH (EURe -0.05%- EURC -0.05%- USDC), +// MIN_SWAP_FLOOR, CAP_CEILING, LIVENESS_FALLBACK_DELAY +// Storage: +// struct Config { address destination; uint16 feeBps; uint64 initializedAt; bool userPaused; } +// mapping(address safe => Config) config; +// Ops params (bounded): minSwapAmount ∈ [MIN_SWAP_FLOOR, ...], perSwapCap ∈ [..., CAP_CEILING]; +// globalPaused; guardian (Vortex ops multisig); paramTimelock. +``` + +- `initialize(destination, feeBps)`: callable once per Safe, **only** with `msg.sender == safe` (holds during atomic setup: the setup library runs in the Safe's context and the module sees the Safe proxy as caller). `feeBps ≤ MAX_FEE_BPS`. Reverts on re-init. Not front-runnable: config is keyed by `msg.sender`, so only the Safe can create its own entry. +- `setDestination(addr)` / `setUserPaused(bool)`: `msg.sender == safe` only (i.e., an owner-signed Safe transaction). `feeBps` immutable after init. +- Every mutation emits events with a config version counter (F04, F14). + +### 6.3 Destination semantics + +Set at onboarding, part of the manifest and the disclosure. Changeable only via the Safe (S3). Ownership attestation is legal, not cryptographic — requiring a signature from the destination key would contradict the wallet-less UX and is explicitly not claimed (F03). + +### 6.4 Configuration manifest and verification (F02) + +Per account, a versioned JSON manifest: chain ID; Safe address; singleton + proxy factory + fallback handler addresses and runtime hashes; owners and threshold; enabled modules; module address, runtime hash, and full config (destination, feeBps); signer contract coordinates; oracle and router addresses; setup tx hash. Published to a public transparency log (repo + API). An **independent verifier** (open-source script, runnable by anyone against public RPC) checks live chain state against the manifest; onboarding blocks on it, and it re-runs continuously with alerting. This makes provisioning fraud *detectable before first deposit* — the claim stops there. -1. User completes Vortex-branded KYC (whitelabel: Vortex submits identity data / SumSub applicant token to Monerium via `POST /profiles` + KYC endpoints; approval via webhook). -2. Browser creates a **passkey** (WebAuthn platform authenticator — Face ID / fingerprint / Windows Hello; synced via iCloud Keychain / Google Password Manager). -3. Vortex backend deploys, in one transaction (Vortex pays gas): - - the Safe (v1.4.1+, CREATE2, deterministic address), owner = the passkey's `SafeWebAuthnSigner`, threshold 1; - - enables `VortexSwapModule` (immutable, points at this Safe's immutable `destination` and config); - - (optional, §8.4) enables `RecoveryValidatorModule`. - Deployment **must complete before linking** (no counterfactual link — §2). -4. User performs **the one signature**: passkey signs `"I hereby declare that I am the address owner."` (WebAuthn assertion wrapped for Safe EIP-1271 verification). -5. Vortex backend calls Monerium `POST /addresses` (link address to profile, `chain: ethereum`, with signature). Monerium validates via `isValidSignature` on the deployed Safe. -6. Monerium issues the dedicated IBAN. Vortex shows the user: their IBAN + the fixed destination address + fee/rate disclosure. +## 7. Conversion policy (on-chain) -The **destination address** is collected and confirmed during onboarding (see Open Question Q2 on ownership attestation) and is baked into the module config before the user signs the link message — the user's single signature therefore implicitly ratifies the destination. +### 7.1 `swapAndForward(safe)` -### 5.2 Steady-state transfer +Caller: authorized keeper set; **permissionless fallback** — anyone may call once the Safe's EURe balance has exceeded `minSwapAmount` for longer than `LIVENESS_FALLBACK_DELAY` (proposal: 24h). Timing-grief within the slippage bound is accepted and bounded (F06 Q, review §6.4). -1. User wires EUR (SEPA/SEPA Instant) to their IBAN. -2. Monerium mints EURe (V2) to the user's Safe on Ethereum. Vortex detects via Monerium webhook **and** an on-chain `Transfer` watcher (defense in depth). -3. Keeper calls `VortexSwapModule.swapAndForward(safe, routeData)`: - - module reads the Safe's full EURe balance (subject to per-swap cap; splits large balances across multiple executions); - - computes `minOut` from Chainlink EUR/USD (§7.2); - - approves exactly `amountIn` EURe to the route's router (allowlisted, §7.4), executes the swap **from the Safe's context via `execTransactionFromModule` (call only, no delegatecall)**; - - verifies post-conditions (USDC delta ≥ `minOut`, EURe delta ≤ `amountIn`, approval reset to 0); - - transfers the Safe's full USDC balance to `destination`; - - emits an event; keeper tx submitted via private orderflow (Flashbots Protect) to avoid sandwiching. -4. Vortex notifies the user (email/app): amount received, USDC delivered, tx hash. +Atomic sequence (reverts as a unit; reentrancy-guarded; all external calls `CALL` with `value == 0`; safe-ERC20 handling for return values): -### 5.3 Exit / recovery +1. Require: not `globalPaused`, not `userPaused`, config initialized. +2. `balance = EURE.balanceOf(safe)`; require `balance ≥ minSwapAmount`; `amountIn = min(balance, perSwapCap)`. +3. Compute `minOut` (§7.3). +4. Via `execTransactionFromModule` (CALL only): `EURE.approve(UNISWAP_ROUTER, amountIn)` (force-approve pattern). +5. Via `execTransactionFromModule`: `UNISWAP_ROUTER.exactInput({path: PATH, recipient: safe, amountIn, amountOutMinimum: minOut})` — **calldata constructed entirely by the module** (F06); path, router, recipient hard-pinned; deadline semantics per the pinned router version (SwapRouter takes an explicit deadline — set `block.timestamp`; SwapRouter02 omits it — decide at pin time in G0). +6. Verify: EURe allowance to router == 0 (reset if router pulled less; then also verify EURe delta == amount actually swapped), `usdcDelta = USDC.balanceOf(safe) − pre ≥ minOut`. +7. `fee = usdcDelta × feeBps / 10_000` → `FEE_RECIPIENT` (pilot: 0); remaining full USDC balance → `config.destination`. If the destination transfer reverts (e.g. Circle blacklist), **the whole execution reverts and funds remain EURe** (F17.1). +8. Emit `SwapExecuted(safe, amountIn, usdcOut, fee, roundId)`. -- **User-initiated (passkey):** the user is the Safe owner and can always execute arbitrary transactions — withdraw EURe/USDC, disable modules, change destination, or abandon the flow. User-initiated actions are rare; they run through ERC-4337 with a Vortex paymaster (or a Vortex-relayed Safe tx) so the user never needs ETH. -- **Passkey loss:** the automated flow **keeps working** (keeper needs no user signature), so in-flight and future deposits still reach the destination. Only user-initiated recovery is lost. Mitigations: passkeys are cloud-synced by default; optionally add a user-controlled recovery owner (email-based recoverer such as Candide/Safe Recovery Hub) — decide in Q3. -- **Stranded EURe** (swap route dead, or swaps paused): see §8.4 recovery module — EURe can be redeemed back to the **user's own bank IBAN** via a Monerium redeem order pre-authorized at setup. Never sweep raw EURe to `destination` — if destination is an exchange deposit address, EURe would be unsupported and lost. +Scope statement (F06): the guarantee covers **EURe and USDC in a dedicated Safe provisioned by this flow**. Assets or approvals the user independently adds to the Safe are outside the policy's protection (the module never touches them, but a future user-granted allowance is the user's own act). -## 6. System components +### 7.2 What was removed (F06, F07) -1. **Vortex backend (apps/api)** — new Monerium whitelabel service: profile/KYC lifecycle, address linking, webhook ingestion (profile approved, payment received, order state), IBAN issuance; persistence of user ↔ Safe ↔ destination mapping; keeper job scheduling. Follows the existing phase/state-machine pattern (new ramp type `monerium_onramp` with phases: `awaitDeposit → detectMint → swapAndForward → notifyComplete`). -2. **Frontend** — onboarding flow (KYC UI, passkey ceremony, destination input + confirmation, disclosure screen); status page (deposits, conversions, tx links). No wallet-connect requirement. -3. **Contracts** (new package or `contracts/` dir): - - `VortexAccountFactory` — deploys Safe + WebAuthn signer + module wiring atomically via CREATE2. - - `VortexSwapModule` — immutable per-deployment singleton, parameterized per Safe (see §8). - - `RouterRegistry` — Vortex-owned allowlist of swap routers, behind a timelock (§7.4). - - `RecoveryValidatorModule` (optional v1, §8.4). -4. **Keeper service** — watches mints, builds route calldata (v1: Uniswap path; later: aggregator calldata), submits via private relay, retries, alerts. -5. **Oracle** — Chainlink EUR/USD feed on mainnet (verify address, heartbeat ~24h / deviation ~0.15% at build time; staleness guard in module). +No keeper-supplied calldata, no selector allowlists, no generic router registry, no aggregators, no CoW. Route governance in v1 is binary: the single pinned route can be **paused instantly** by the guardian (protective, instant) ; any expansion (new route/module version) is a new deployment + per-user owner-authorized migration (§12) — i.e., additions are maximally slow, removals are instant. -## 7. Swap routing +### 7.3 Oracle math (F09) -### 7.1 v1 route: Uniswap v3 multi-hop, single call +```text +(roundId, answer, , updatedAt, ) = ORACLE.latestRoundData() +require(answer > 0 && updatedAt != 0) +require(block.timestamp − updatedAt ≤ MAX_ORACLE_AGE) // immutable ceiling +// EURe: 18 dec; ORACLE_DECIMALS: read once at deploy (expect 8); USDC: 6 dec +// scale = 10^(18 + ORACLE_DECIMALS − 6) → 10^20 for an 8-dec feed +minOut = mulDiv(amountIn, uint256(answer) × (10_000 − SLIPPAGE_BPS), + 10 ** (12 + ORACLE_DECIMALS) × 10_000) // floor; conservative direction, error < 1 unit +``` -Uniswap v3's router supports multi-hop swaps in **one call**: `exactInput(path)` with the encoded path `EURe → (0.05%) → EURC → (0.05%) → USDC`. No aggregator dependency, fully on-chain, deterministic. This answers the "can Uniswap swap over two pools in one call" question: yes, natively. +- **A4 stated:** USDC/USD is assumed 1.0; the SLIPPAGE_BPS margin absorbs both stablecoin bases. USDC below the margin ⇒ swaps revert (protective). No USDC/USD feed in v1 (documented decision; revisit if margin proves tight). +- **Weekend policy:** Chainlink FX feeds hold the last market price outside trading hours. Verify in G0 whether heartbeat updates continue (staleness passes) or stop (swaps revert Fri→Mon). If they continue: execute normally — the slippage margin has historically absorbed weekend EUR/USD gaps — but keeper policy defers swaps above a size threshold to market hours; disclosed. If they stop: deposits queue until Monday; SLA disclosure reflects it. +- Loss statement (F09): *the swap cannot deliver less than `(1 − SLIPPAGE_BPS)` of the oracle-model value, assuming an honest oracle and modeled token behavior.* This is not a principal bound under oracle or stablecoin failure (see S2 assumptions). -### 7.2 Execution bounds +### 7.4 Liquidity: measurement, caps, monitoring (F12) -- `minOut = amountIn × chainlinkEURUSD × (1 − maxSlippageBps) × 10^(−12)` (EURe 18 decimals → USDC 6 decimals). -- `maxSlippageBps` is an **immutable constant** in the module (proposal: 100 bps). Note this bound implicitly tolerates EURe/EUR and USDC/USD depegs up to the same margin; a hard depeg beyond it makes swaps revert (fail-safe: funds sit as EURe until resolved or recovered via §8.4). -- Oracle staleness check: revert if `updatedAt` older than heartbeat + grace. +- `minOut` is the **safety** condition. `perSwapCap` / `minSwapAmount` are **availability** parameters (bounded by immutable floor/ceiling; lowering instant, raising behind the ops timelock). +- Launch methodology: record block-numbered `QuoterV2` static-call quotes at {1k, 5k, 10k, 25k} EURe plus active-tick liquidity for both pools; archive parameters for reproducibility. TVL alone never justifies raising caps. +- Continuous monitoring: executable quote at `perSwapCap` vs oracle; alert and auto-engage keeper pause when impact at `minSwapAmount` exceeds SLIPPAGE_BPS for a sustained period (launch/pause thresholds defined in runbook). +- Rapid successive executions beyond depth **revert on `minOut`** (availability loss, keeper gas waste — not fund loss); pacing is keeper policy, best-effort, and documented as such. -### 7.3 Size caps & batching +## 8. Keys and recovery -- Per-swap cap (proposal: **€10,000 equivalent**, constant in module or registry) — sized to the bottleneck EURe/EURC pool. Larger balances are swapped in successive keeper executions spaced over time. -- Min-swap threshold (proposal: €25) to avoid uneconomical dust swaps; dust accumulates until threshold. -- These parameters should live in the `RouterRegistry` (timelocked, bounded: cap can never exceed a module-immutable ceiling; slippage never above the immutable 100 bps). +### 8.1 Owners -### 7.4 Route replaceability without custody risk +Safe owners: `[SafeWebAuthnSigner(passkey), recoveryOwner]`, threshold 1. Either owner has full unilateral control — both are user-controlled; this is disclosed. Vortex holds no owner key. -The module's **logic is immutable**; only **route data** is replaceable: +### 8.2 RP-ID dependence (F11) -- `RouterRegistry` (owned by Vortex multisig behind a **7-day timelock**, all changes evented/public) maps `routeId → (router address, selector allowlist)`. -- Keeper passes `(routeId, calldata)`; module checks router is registered, executes, then enforces post-conditions (§8.2). Because post-conditions are checked in the immutable module, **even a malicious registered router cannot extract more than the slippage margin** (§8.3). -- v2 candidates behind the same interface: 1inch/Paraswap executor calldata (validated by the same balance-delta checks), or **CoW Protocol programmatic orders** (ComposableCoW handler signing "sell all EURe for USDC, min = oracle × (1−slippage), receiver = destination" via EIP-1271) — this is the route that quoted the 50k clip at spot in testing and removes keeper gas + MEV concerns entirely. Recommended as the first post-launch iteration. +The passkey works only via an origin under Vortex's RP ID. Mitigations: (a) the mandatory recovery owner is RP-independent (EOA/hardware/second passkey); (b) the disaster-recovery package includes a static, self-hostable page for the RP domain, and Vortex commits to a domain-continuity plan (registrar lock, escrowed transfer instructions) — documented limitation, not fully eliminable; (c) credentials required discoverable + backup-eligible. -### 7.5 MEV +### 8.3 Removed from v1 (F05) -Keeper transactions go through private orderflow (Flashbots Protect / MEV-blocker). Worst-case sandwich loss is already capped by `minOut`, private submission just avoids donating the margin. +The automatic EIP-1271 redeem validator ("redeem to pinned refund IBAN without user signature") is removed: it was wrong-layered (EIP-1271 lives in the fallback handler, not a module), required a bespoke signature-encoding protocol, risked weakening link-message validation, and gave Vortex unilateral disposal authority that changes the custody analysis. Stranded-EURe recovery in v1 = owner-signed redeem or withdrawal. Residual: loss of both owners + paused route strands EURe (disclosed; accepted). -## 8. Smart contract specification +## 9. Fees (F10) -### 8.1 Per-user configuration (set at Safe deployment, before the link signature) +- Structure finalized now: per-account `feeBps` set at `initialize`, **immutable thereafter**; global immutable `MAX_FEE_BPS` (proposal: 100) and immutable `FEE_RECIPIENT` in the singleton. Fee assessed per execution on gross swap output; batching therefore charges each batched deposit pro-rata by construction (§11.4). +- **Pilot: `feeBps = 0`** (loss-leader; Vortex pays deployment + keeper gas). GA fee value is a business decision (OQ1) — changing it means new accounts get a different `feeBps`; existing accounts keep theirs. +- I1 (S2 guarantee) enumerates the treasury as a permitted recipient bounded by `feeBps ≤ MAX_FEE_BPS`. Disclosure separates fee (deterministic) from slippage bound (worst-case market execution). -| Param | Mutability | -|---|---| -| `EURE` (V2 token addr) | immutable | -| `USDC` token addr | immutable | -| `destination` | **immutable to Vortex**; changeable only via Safe owner (user passkey) transaction | -| `oracle` (Chainlink EUR/USD) | immutable (module version) | -| `maxSlippageBps` | immutable constant | -| `routerRegistry` | immutable pointer; registry contents timelocked (§7.4) | +## 10. Product behavior: minimums, dust, destinations (F16) -### 8.2 Module invariants (the security core — reviewer: attack these) +### 10.1 Minimum deposit & SLA -- **I1**: The module can move only two tokens: EURe (into a registered router, exact-amount approval) and USDC (only to `destination`, full balance). -- **I2**: After `swapAndForward`: `usdcReceived ≥ minOut(oracle)`, `eureSpent ≤ amountIn`, EURe allowance to router reset to 0. Otherwise revert (atomic). -- **I3**: `execTransactionFromModule` is used with `operation = CALL` only — **no delegatecall ever** (a delegatecall would let a router rewrite Safe storage/owners). -- **I4**: The module cannot add/remove Safe owners, change threshold, or enable/disable modules. -- **I5**: The module has no upgrade mechanism, no `selfdestruct`, no owner-privileged functions beyond `swapAndForward` (keeper-gated or permissionless — see Q5) and view functions. -- **I6**: Vortex's only levers are: registry contents (timelocked, bounded by immutable caps) and choosing when/whether to call the keeper function. -- **I7**: Reentrancy-guarded; balance deltas measured against pre-call snapshots on the Safe itself. -- **I8**: Only the user (Safe owner) can disable the module or change `destination`. -- **I9**: The module rejects `routeData` whose router is not currently registered, and enforces a calldata selector allowlist per router entry. +User-facing: deposits ≥ €25 convert within a stated SLA (proposal: 1 business hour under normal conditions; next FX market open under the weekend policy). Deposits < €25 **accumulate** until the threshold is crossed; always recoverable via owner-signed exit. `minSwapAmount` is gas-responsive within its immutable floor. -### 8.3 Bounded worst-case (full Vortex compromise) +### 10.2 Gas griefing -If Vortex's registry multisig is compromised AND the 7-day timelock elapses unnoticed AND a malicious router is registered: the router still must return `minOut` USDC to pass I2, so the maximum extractable value is **`maxSlippageBps` (+ oracle deviation) per swap** — ~1.15% of throughput, not principal. Vortex compromise can additionally *halt* swaps (liveness), never redirect principal. This bound should be stated in user-facing terms and verified by the auditor. +Many small SEPA transfers can force keeper gas. Bounded by: min threshold + natural batching (balance sweep), sender is a KYC'd bank customer (low realistic abuse), and per-account keeper budget alerts. Vortex cannot prevent inbound SEPA to an issued IBAN; accepted operational cost. -### 8.4 Recovery path for stranded EURe (recommended for v1) +### 10.3 Batching -`RecoveryValidatorModule`: an immutable module that makes the Safe's EIP-1271 `isValidSignature` return valid **only** for messages matching Monerium's redeem template `"Send EUR to at "` where `` hash-matches a `refundIban` pinned at setup (the user's own external bank account, collected at onboarding; changeable only by user passkey). This lets the Vortex backend place a Monerium redeem order returning stranded EURe **to the user's own bank account** with no user signature — non-custodial because the only authorizable payout target is the user's own pinned IBAN. Constraints: template must be parsed/validated strictly (amount ≤ balance, timestamp freshness); coordinate with Monerium that link-message validation is unaffected (the link message must also validate — the validator whitelists exactly these two message shapes). +Deposits arriving before conversion batch naturally (module sweeps balance). Allocation rule in §11.4; batching latency covered by the SLA disclosure. -### 8.5 Known external powers (accepted, disclose) +### 10.4 Destination validation -- EURe and USDC are issuer-controlled tokens (upgradeable; freeze/blacklist powers). A blacklisted `destination` (USDC) or frozen Safe (EURe) strands funds pending issuer/compliance resolution — inherent to fiat-backed stablecoins, mitigated by §8.4 and user-changeable destination. -- Chainlink feed failure → swaps revert (fail-safe, funds idle as EURe). +At onboarding: EIP-55 checksum; deny zero/dead addresses, precompiles, the Safe itself, the module, the router, known token contracts; warn for contract destinations (recoverability unprovable) and exchange deposit addresses (rotation, minimum-deposit thresholds — user attests awareness); sanctions/blacklist screen at onboarding and **periodic re-screening** with conversion pause + user notification on a hit (F15, F16). Blacklisted destination at execution time ⇒ atomic revert, funds stay EURe (§7.1.7). -## 9. Fees & unit economics (open — Q1) +## 11. Backend (F08, F13) -- Costs per user: one-time Safe+module deployment (mainnet, Vortex-paid — estimate and monitor; low at current basefees) + per-swap keeper gas (~350–600k gas). -- No quote is shown, so the fee must be a **disclosed, deterministic rule**, e.g. an immutable `feeBps` in the module skimmed to a Vortex treasury address at forward time (transparent, auditable, part of the signed-off config), plus disclosure "conversion at market rate, max slippage 1%". Spread-capture (silently keeping the slippage margin) is rejected: it's opaque and undermines the non-custody story. -- MiCA/consumer transparency: even quoteless, the flow needs pre-contractual disclosure of fee rule and rate mechanism (legal review, Q7). +### 11.1 Data model — replaces the one-shot ramp machine for this product -## 10. Compliance notes +The existing `PhaseProcessor` is **not** reused: its documented non-atomic multi-instance lock (spec finding F-003) and retry-exhaustion gap (F-004) are unacceptable for a permanent, repeatedly funded account. -- Monerium whitelabel MSA covers the "client money / third-party use" ToS restriction at the fiat layer: each user is Monerium's e-money customer; Vortex never holds fiat or e-money on its own account in this flow. -- Non-custody at the crypto layer per §4/§8.3 — needs formal legal opinion for target jurisdictions (MiCA CASP analysis: does orchestrating an automatic conversion constitute an exchange service even without custody?). -- **Destination ownership (Q2)**: if `destination` must be user-owned (attested at onboarding), the flow is self-transfer-shaped (cleaner: no travel-rule counterparty, weaker "payment service" characterization). Allowing third-party destinations turns this into a payments product with materially heavier obligations. v1 recommendation: require attestation of user ownership. -- Monerium performs AML screening on incoming SEPA; large/first-party-mismatched deposits may be held for review — surface "pending compliance review" state in UX. Verify with Monerium whether incoming mints have amount thresholds analogous to the €15k redeem-document rule. +```text +MoneriumAccount: profileId, iban, safeAddress, configVersion, status (onboarding|active|suspended|closed), complianceState +FiatDeposit: moneriumOrderId (unique), amount, currency, paymentStatus, mintTx {chainId, txHash, logIndex, blockHash}, complianceStatus +ConversionExecution: safeAddress, includedDepositIds[], eureIn, usdcGross, fee, usdcNet, destination, txHash, status, error +``` + +Idempotency keys: `moneriumOrderId` (accounting identity) and `(chainId, txHash, logIndex)` (on-chain identity). **On-chain balance is the execution-safety source; Monerium order IDs are the accounting source.** + +### 11.2 Webhooks + +HMAC verification over raw request bytes, constant-time compare, timestamp/replay window, persisted webhook-ID dedup, immediate `200` + async processing, out-of-order tolerance (state machine on `FiatDeposit.paymentStatus` accepts only forward transitions). + +### 11.3 Chain handling + +Confirmation policy: detect mints at 1 confirmation; execution reads live balance (safety source) so reorged mints self-correct; user **notifications** only after the execution tx reaches N confirmations (proposal: 32 blocks) with block-hash re-verification; a reorg after notification triggers a correction notice. Keeper: unique nonce manager, stale private-relay tx replacement policy. + +### 11.4 Concurrency & attribution + +Per-Safe serialization via Postgres advisory lock (`pg_advisory_xact_lock(hash(safeAddress))`) — contract reentrancy guards do not serialize separate keeper processes (F13). Batched output allocation: pro-rata by deposit amount, floor to 6 dp, remainder to the largest deposit — deterministic and auditable; fees allocated identically. + +### 11.5 Partner/API surface (F08 Q) + +The public API exposes `MoneriumAccount` (long-lived) and per-deposit `FiatDeposit`/`ConversionExecution` objects with webhooks per deposit — **not** one eternal ramp object. + +## 12. Incidents and migration (F14) + +- **Pause:** guardian pauses globally or per-account **instantly** (protective-only action). Unpause instant (config is user/immutable-controlled, so unpause cannot enact a hostile change). +- **02:00-UTC module vulnerability runbook:** pause all → ask Monerium to suspend affected IBANs (capability to be confirmed in MSA — G1) → notify users to stop sending EUR (email/app + status page) → assess → ship migration. +- **Migration:** deploy new module singleton (new audit) → each user authorizes `enableModule(new)` + `disableModule(old)` via an owner signature (relayed by Vortex; also possible via the DR package). The Safe address — and therefore the IBAN link — **does not change**, avoiding F01's re-association path. Users who never migrate keep the paused old module; funds remain owner-recoverable. +- Module/config version discovery: on-chain events + manifest log. Old-version support/sunset policy published. +- Users who lost all owners: automation (if unpaused) still forwards; otherwise funds sit; no backdoor exists by design — disclosed. + +## 13. Launch gates (external dependencies) + +- **G0 — Technical spike:** Monerium sandbox E2E (deploy → EIP-1271 link → mint → swap on fork); pin Safe/WebAuthn contracts + gas benchmark (EIP-7951 path); confirm router version & deadline semantics; confirm Chainlink EUR/USD feed address, decimals, heartbeat, and **weekend update behavior**; reproducible liquidity baseline (§7.4). +- **G1 — Monerium MSA (blocking for the S1 claim, F01):** written + sandbox-verified answers on: authorization required for `PATCH /ibans` and `POST /addresses` on whitelabel profiles; whether an IBAN/profile can be locked to a single non-movable address absent end-user authorization; credential scoping; association-change event feed; per-IBAN suspension capability; SEPA recall/fraud loss allocation **after** conversion+forwarding; behavior of conversions during profile review/suspension. If pinning is unavailable: launch is still possible with the S1 trust statement as written (Vortex trusted pre-mint) + monitoring — a product/legal decision to make explicitly. +- **G2 — Legal/compliance sign-off (F15):** custody analysis covering module authority **and** Monerium control-plane authority; MiCA scoping; DPA/controller-processor roles with Monerium; retention/access table and data-flow diagram; sanctions screening procedure; disclosure texts. +- **G3 — Audit:** contracts (module + setup lib) with invariant/fuzz suites covering §7.1 post-conditions, init front-running, pause semantics, oracle edge cases, V1-token poisoning; DR package tested on fork. +- **G4 — Pilot:** invite-only, personal profiles, `feeBps = 0`, `perSwapCap` conservative (≈ €5–10k), €1k/user/day operational limit, full monitoring live. + +## 14. Open questions (reduced) + +- **OQ1** — GA fee value (structure is finalized; §9). +- **OQ2** — `LIVENESS_FALLBACK_DELAY`, SLA numbers, cap/threshold launch values (G0 data). +- **OQ3** — Weekend policy final form (depends on G0 feed behavior). +- **OQ4** — G1 outcome: does the S1 statement get upgraded (Monerium pinning) or stay trust-based? +- **OQ5** — Recovery-owner UX default (second passkey vs printable key as the recommended path). + +--- -## 11. Failure modes +## Appendix A — Response to architecture review findings -| Failure | Behavior | Mitigation | +| ID | Disposition | Response / change | |---|---|---| -| Swap reverts (slippage/oracle stale/pool drained) | EURe idles in Safe | Keeper retries with backoff; alerting; route change via registry; §8.4 recovery after timelock | -| Keeper down | No swaps (funds safe in Safe) | Redundant keepers; optionally make `swapAndForward` permissionless (Q5) so anyone can execute | -| Monerium webhook missed | Delayed detection | On-chain Transfer watcher as second trigger | -| Deposit > per-swap cap | Multiple sequential swaps | Automatic split; disclose timing to user | -| EUR sent from a bank account not matching profile name | Monerium compliance hold or return | Surface state; instruct users to send from own account | -| Passkey lost | Automation unaffected; user-initiated actions blocked | Cloud-synced passkeys; optional recovery owner (Q3) | -| Destination blacklisted by Circle | Forward reverts; USDC stuck in Safe | User changes destination via passkey; support runbook | -| EURe frozen by issuer | Nothing moves | Compliance resolution with Monerium | -| Depeg beyond slippage bound | Swaps revert (by design) | Monitor; product decision to pause/notify | - -## 12. Rollout - -1. **M0 — Spike:** deploy Safe+passkey+module on Sepolia/fork; validate Monerium sandbox EIP-1271 link end-to-end (sandbox.monerium.dev); confirm WebAuthn signer gas on mainnet (EIP-7951 precompile path post-Fusaka vs Solidity fallback). -2. **M1 — Contracts:** module + registry + factory, full test suite (fork tests against real pools incl. V1-pool poisoning tests), invariant/fuzz tests on I1–I9, external audit. -3. **M2 — Backend/Frontend:** whitelabel onboarding, keeper, state machine, notifications; internal pilot with capped amounts (e.g. €1k/user/day). -4. **M3 — GA:** raise caps; add CoW programmatic-order route (§7.4) for large-clip execution. - -## 13. Open questions - -- **Q1 — Fee model:** immutable `feeBps` in-module vs off-chain billing vs loss-leader. Blocks module finalization. -- **Q2 — Destination ownership:** require user-owned destination attestation in v1? (Recommended: yes.) -- **Q3 — Passkey recovery:** rely on platform sync only, or add an opt-in recovery owner in v1? -- **Q4 — Recovery module (§8.4) in v1 scope?** (Recommended: yes — it's the only fix for stranded EURe + lost passkey.) -- **Q5 — Keeper gating:** `swapAndForward` restricted to Vortex keeper vs fully permissionless (better liveness/decentralization; safe because all safety is in invariants — but consider griefing via unfavorable-timing executions within the slippage bound). -- **Q6 — Monerium specifics to confirm in sandbox/MSA:** whitelabel address-link flow identical to the OAuth EIP-1271 docs; ERC-6492 support (else deploy-before-link stands); incoming-payment compliance thresholds; who bears mint gas (expected: Monerium); redeem-order EIP-1271 details for §8.4. -- **Q7 — Legal:** non-custody opinion; MiCA CASP scoping; disclosure requirements for quoteless conversion. - -## 14. Reviewer checklist - -You are reviewing for architectural flaws. Specifically attempt to break: - -1. **Theft vectors:** any path where Vortex (backend, keeper, registry multisig, module deployer) redirects or extracts user principal. Include: malicious router within I1–I9; oracle manipulation (Chainlink EUR/USD compromise or staleness games); approval leakage; reentrancy through router callbacks; delegatecall smuggling; CREATE2 redeployment tricks; malicious `SafeWebAuthnSigner` substitution at factory level; front-running the link (linking a Monerium profile to a Safe the user doesn't actually control — who verifies factory integrity?). -2. **The one-signature claim:** does anything in practice require a second user signature (Monerium re-verification, destination change, 4337 deployment quirks)? -3. **Stranding vectors:** enumerate every state where funds are stuck and no §8.4/§5.3 path applies. -4. **Liveness/censorship:** consequences of Vortex disappearing permanently — can users self-rescue with only their passkey and public docs? -5. **§8.3 bound:** verify the claimed worst-case (slippage margin, not principal) holds under composed failures (compromised registry + compromised keeper + oracle at deviation edge). -6. **Liquidity math:** per-swap cap vs the $104k bottleneck pool; behavior under pool migration/deprecation; V1-token poisoning. -7. **Compliance shape:** does the §10 reasoning hold; is the "not a custodian" claim defensible given the module is Vortex-authored and Vortex-deployed? -8. **Recovery module (§8.4):** can the message-template validator be abused (IBAN substring games, amount/timestamp manipulation, replay across chains/profiles, interference with the link-message validation)? -9. **Operational realism:** webhook loss, chain reorgs around mint detection, multiple rapid deposits, decimals (EURe 18 / USDC 6), gas spikes. +| F01 | **Accept** (independently verified) | `PATCH /ibans` bearer-auth redirect confirmed against live docs; memo-routing sub-claim not found in current docs (noted, immaterial). Trust model rewritten (S1); Monerium controls made launch gate G1; association monitoring + credential isolation added. Absolute non-custody claim retracted | +| F02 | **Accept** | §8.3 replaced by per-stage guarantee matrix (§4); versioned config manifest + independent verifier + continuous re-verification (§6.4) | +| F03 | **Modify** | Finding accepted: link signature binds nothing beyond address ownership; "implicitly ratifies" retracted. Resolution = review's Option 3 (trusted provisioning, stated plainly) + manifest verification. Review's Option 1 (extra EIP-712 passkey signature) **rejected as a trust upgrade**: WebAuthn lacks what-you-see-is-what-you-sign, so under a compromised frontend it yields an audit artifact, not consent — equivalent to Option 3 in the threat model it targets. Destination attestation is legal consent (§6.3). "One signature" demoted to UX description (§5.1.7) | +| F04 | **Accept** | Option B selected: immutable singleton + Safe-keyed config, `initialize` once with `msg.sender == safe` (atomic via setup library; keyed-by-caller ⇒ not front-runnable), `setDestination` Safe-only, `feeBps` immutable post-init, versioned events (§6.2). Safe v1.4.1 pinned by address + runtime hash; custom factory dropped for canonical factory + minimal setup lib | +| F05 | **Accept** | Correct on all points (module ≠ fallback handler; hash-only 1271 input; IBAN canonicalization; replay; link-message weakening; unilateral-disposal custody impact). Auto-redeem validator removed from v1 (§8.3); recovery = mandatory independent owner + owner-signed redeems. Fallback handler = canonical CompatibilityFallbackHandler only | +| F06 | **Accept resolution; correct one argument** | v1: module constructs all calldata; pinned router/path/recipient; no keeper calldata; no registry; guarantee scoped to EURe/USDC in the dedicated Safe; instant pause vs deployment-grade additions (§7.1–7.2). Correction: balance-delta post-conditions already defeat recipient/path redirection of swap output atomically — the genuine residual was other assets/approvals and nested calls, which the scoping + internal-calldata resolution addresses | +| F07 | **Accept** | CoW removed from same-interface claim; deferred to a standalone v2 spec with its own lifecycle/threat model (order authorization, watchtower, settlement-time 1271, partial fills, fallback-handler coexistence) | +| F08 | **Accept** (verified in-repo) | Spec findings F-003/F-004 confirmed in `docs/security-spec/03-ramp-engine/state-machine.md`. New persistent model (§11.1), idempotency keys, advisory-lock serialization, per-deposit API objects (§11.5). New security-spec file required per repo sync rule; legacy `05-integrations/monerium.md` superseded-by link | +| F09 | **Accept** | Raw-unit pseudocode with `10^(12+ORACLE_DECIMALS)` scaling (=10^20 at 8 dec), read-once decimals, staleness ceiling, floor rounding, explicit A4 (USDC/USD = 1 within margin), weekend policy pending G0 feed-behavior check; loss statement rewritten as oracle-model-relative (§7.3) | +| F10 | **Accept** | Fee structurally finalized: per-account immutable `feeBps` (pilot 0), immutable `MAX_FEE_BPS` + treasury; I1/S2 updated to enumerate the fee recipient; per-execution assessment with pro-rata batch allocation (§9, §11.4) | +| F11 | **Accept** | RP-ID dependence acknowledged (§8.2). Independent recovery owner **mandatory** at onboarding (§5.1.3); DR package (Vortex-independent, fork-tested, incl. self-hostable RP page) a launch deliverable; domain-continuity plan documented; credentials discoverable + backup-eligible required | +| F12 | **Accept resolution; correct one argument** | Caps reframed as availability parameters; `minOut` is the safety condition; block-numbered reproducible quoting methodology + continuous executable-depth monitoring + pause thresholds (§7.4). Correction: rapid cap-sized executions revert on `minOut` rather than execute at bad prices — availability/gas loss, not fund loss | +| F13 | **Accept** | Full webhook (HMAC/dedup/replay), confirmation/reorg, nonce, advisory-lock, and allocation spec added (§11.2–11.4); balance = safety source, order IDs = accounting source | +| F14 | **Accept** | Instant guardian pause; incident runbook incl. Monerium IBAN suspension (G1 question); owner-authorized module migration that **keeps the Safe address** (avoids F01 re-association); version discovery; sunset policy (§12) | +| F15 | **Accept** | v1 = personal, newly onboarded only; G2 gate for legal/DPA/retention/sanctions; SEPA-recall loss allocation moved into G1 MSA questions; destination re-screening added (§10.4) | +| F16 | **Accept** | Min deposit + accumulation + SLA disclosure (§10.1); gas-griefing bounded and accepted (§10.2); destination validation/denylist/warnings/re-screening (§10.4). Noted: griefing realism is low (KYC'd bank senders) but policy specified regardless | +| F17 | **Accept** | All six corrections applied: atomic revert on blacklisted destination (funds remain EURe); "Vortex executes only the constrained policy"; withhold/censor language scoped; extraction bound restated as oracle-model-relative incl. fee; passkey-sync nuance; EIP-7951 treated as live with G0 benchmarking of the pinned implementation | +| F18 | **Accept** | v1 composition cut to: canonical Safe + passkey signer + recovery owner + one module (internal calldata) + canonical fallback handler. Removed: 4337, custom factory, generic registry, aggregator calldata, CoW, auto-redeem validator, mutable fees. Matches review §6 with one divergence: module topology is Option B (singleton+config) rather than per-Safe clones — one audited deployment, no per-user bytecode, equivalent immutability of logic | + +**Requested end-to-end statement (review §8):** the composition now supports this security statement — *"Once EURe is minted to the user's Safe, Vortex's total authority is: execute the fixed EURe→USDC→destination conversion within an oracle-checked slippage bound and a disclosed fee; pause it; and tune bounded availability parameters. Redirecting or extracting minted principal beyond the slippage margin + fee requires breaking a stated assumption (oracle integrity, token-contract behavior, audited-code correctness) — not merely abusing any authority Vortex holds. Before mint, Vortex and Monerium hold monitored, contractually constrained, but real authority over deposit routing; users are told so."* From 7323ac575cafa93de4788aebb76a83db8e41dbd7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 13 Jul 2026 16:53:45 +0200 Subject: [PATCH 04/59] Add re-review --- ...m-eur-usdc-onramp-architecture-rereview.md | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md diff --git a/docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md b/docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md new file mode 100644 index 000000000..6bb1c16e8 --- /dev/null +++ b/docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md @@ -0,0 +1,411 @@ +# Architecture Re-review: Quoteless EUR → USDC Onramp via Monerium + +**Reviewed document:** [monerium-eur-usdc-onramp.md](./monerium-eur-usdc-onramp.md) v2.0 +**Prior review:** [monerium-eur-usdc-onramp-architecture-review.md](./monerium-eur-usdc-onramp-architecture-review.md) +**Review date:** 2026-07-13 +**Review status:** Materially improved; five blocking design gaps remain +**Recommended decision:** **No-go for production implementation freeze or security audit until R01–R05 are resolved in the specification.** G0 prototypes may proceed; external gates G0–G4 must still pass before launch. + +## 1. Executive conclusion + +Version 2 is a serious improvement. It retracts the unsafe end-to-end non-custody claim, removes the generic router, CoW, ERC-4337, and automatic redeem validator, chooses one module topology, and models the IBAN as a persistent account rather than a terminal ramp. The resulting v1 is credible and auditable in principle. + +The revised composition is nevertheless not ready to implement exactly as written. The remaining blockers are narrower than in v1, but concrete: + +1. the configuration manifest has no trust root independent of the provisioning system it is supposed to detect; +2. a second passkey is incorrectly treated as RP-independent recovery; +3. the delayed permissionless fallback cannot determine when an ERC-20 balance crossed the threshold from the proposed state; +4. a live-balance sweep can consume deposits that the database has not attributed, so the proposed pro-rata accounting is not yet deterministic; +5. the contract state does not contain the per-account guardian pause required by the incident, sanctions, limit, and permissionless-fallback behavior. + +The core on-chain safety direction is now sound: fixed token addresses, a fixed path and router, module-constructed calldata, oracle-derived `minOut`, `CALL` only, exact approvals, delta checks, atomic fee/forwarding, and owner-authorized migration. The remaining work is mostly about making the surrounding claims and state transitions match what the system can actually enforce. + +## 2. Cognitive-load assessment + +The revised v1 is now mostly `🧠`: one Safe deployment path, one conversion module, one route, one fallback handler, and one persistent backend model. The largest remaining `🤯` area is the interaction among live ERC-20 balances, Monerium orders, webhook ordering, permissionless execution, compliance holds, and accounting attribution. Those facts currently live in separate sections, but correctness depends on reasoning about all of them at once. R03–R06 propose one explicit lifecycle that reduces that cross-section load. + +## 3. Disposition of the original findings + +| Original | Re-review status | Comment | +|---|---|---| +| F01 | **Resolved at claim level; external gate open** | S1 now admits Vortex/Monerium authority. G1 remains a genuine launch gate, not an implementation detail. | +| F02 | **Partially resolved** | The stage-based trust model is good. The manifest/verifier does not yet make provisioning fraud independently detectable; see R01. | +| F03 | **Mostly resolved** | The link signature is no longer described as configuration consent. Opaque owner-signature risk after onboarding remains under-modeled; see R08. | +| F04 | **Mostly resolved** | Singleton plus Safe-keyed configuration is coherent. Pause state and parameter governance need completion; see R05 and R10. | +| F05 | **Resolved** | The automatic redeem validator and custom fallback behavior are removed. | +| F06 | **Mostly resolved** | Internal calldata and a pinned route establish the intended swap constraint. The liveness fallback is not implementable from the specified state; see R03. | +| F07 | **Resolved** | CoW is correctly deferred to a separate design. | +| F08 | **Partially resolved** | Long-lived account/deposit/execution objects are correct. The execution-to-deposit ledger is not yet race-safe; see R04. | +| F09 | **Resolved in design; validation open** | Math, rounding direction, assumptions, and loss wording are materially improved. Feed pinning and weekend behavior correctly remain in G0. | +| F10 | **Resolved** | Fee recipient, ceiling, per-account value, and pilot behavior are structurally defined. | +| F11 | **Partially resolved** | A recovery owner is mandatory, but one allowed choice is not RP-independent; see R02. | +| F12 | **Resolved in design; validation open** | Caps are correctly treated as availability controls and executable-depth measurement is required. | +| F13 | **Partially resolved** | Webhook authentication, deduplication, reorg policy, and database locking are covered. Durable receipt and attribution races remain; see R04 and R06. | +| F14 | **Partially resolved** | Migration is credible. The specified contract does not implement the claimed per-account guardian pause; see R05. | +| F15 | **Partially resolved** | G1/G2 are appropriate. Unsolicited EURe, the operational limit, and permissionless execution during a hold need explicit treatment; see R04 and R05. | +| F16 | **Mostly resolved** | Minimums, accumulation, gas cost, and destination validation are now explicit. Full-balance USDC behavior still needs a product rule; see R09. | +| F17 | **Mostly resolved** | Most absolute wording is corrected. S4 and the final statement still need the owner-signature caveat; see R08 and R11. | +| F18 | **Resolved** | The design sacrifice was applied effectively. | + +## 4. Priority summary + +| ID | Priority | Finding | Blocks | +|---|---|---|---| +| R01 | P0 | The manifest verifies consistency, not honest provisioning | S0 fraud-detection claim, audit scope | +| R02 | P0 | A second passkey is not RP-independent recovery | Recovery guarantee, onboarding UX | +| R03 | P0 | The delayed permissionless fallback has no enforceable start time | Contract design, liveness claim | +| R04 | P0 | Live balance sweeps race deposit attribution | Financial accounting, API correctness | +| R05 | P0 | Per-account guardian pause and operational-limit semantics are absent | Compliance holds, incident response | +| R06 | P1 | Webhooks must be durably accepted before returning `200` | Event loss, reconciliation | +| R07 | P1 | Mutable owner configuration conflicts with continuous manifest verification | Monitoring, false incident alerts | +| R08 | P1 | Opaque passkey owner signatures are omitted from the post-mint threat model | Security statement, user compromise | +| R09 | P1 | Full-USDC sweep and unsolicited EURe lack product/accounting rules | User expectations, ledger correctness | +| R10 | P1 | Operational parameter and role invariants are incomplete | Contract auditability, liveness | +| R11 | P2 | “Always exit” remains stronger than the stated assumptions | Specification accuracy | +| R12 | P2 | The response appendix obscures the normative specification | Maintainability | + +## 5. Detailed findings + +### R01 — The manifest verifies consistency, not honest provisioning + +**Priority:** P0 +**Affected PRD lines:** 64, 81, 130–132 + +#### Concern + +The PRD says a hostile provisioning pipeline is detectable because an open-source verifier checks the deployed Safe against a manifest published in a repository and API. That proves only: + +> deployed state == Vortex-published manifest + +It does not prove: + +> deployed state == independently approved Vortex policy + the user's intended destination and recovery owner + +If the frontend, backend, deployment path, and manifest publisher are compromised together—the S0 scenario—the attacker can deploy a hostile Safe and publish a matching hostile manifest. A verifier operated by the same onboarding backend will report success. A repository plus API is also not necessarily append-only, independently witnessed, or resistant to equivocation between users. + +#### Required resolution + +Choose one honest claim and implement its prerequisites: + +**Option A — independent detection:** + +1. Define a canonical policy schema with allowed Safe singleton/factory/handler/module/signer code hashes, threshold rules, immutable contract coordinates, and parameter bounds. +2. Have releases signed by keys outside the provisioning path, preferably an audit/release multisig with at least one independently operated signer. +3. Publish account manifests to an append-only, externally witnessed log; prevent presenting different histories to different observers. +4. Make the verifier compare live state both to the account manifest **and** to the independently signed policy release. +5. Define how destination and recovery-owner intent enter the trust root. A Vortex-controlled page displaying Vortex-controlled data is not an independent confirmation. +6. Specify who operates the independent check and what causes onboarding to halt if the provisioning backend itself is compromised. + +**Option B — narrower claim:** replace “fraud is detectable before first deposit” with “the deployed configuration is publicly auditable against a Vortex-published record.” Do not count this as a control against full provisioning compromise. + +### R02 — A second passkey is not RP-independent recovery + +**Priority:** P0 +**Affected PRD lines:** 43, 77–78, 96–97, 184–188, 270, 288 + +#### Concern + +The PRD permits “a second passkey on another device” as the mandatory independent recovery owner and later describes `(EOA/hardware/second passkey)` as RP-independent. A WebAuthn credential is scoped to its RP ID. A second device protects against loss of the first device; it does not protect against loss of control of the Vortex RP domain, browser origin, or domain-continuity infrastructure. + +The static recovery page also works only if it can be served from an origin accepted for the original RP ID. “Self-hostable” does not mean domain-independent. + +#### Required resolution + +1. Remove a same-RP passkey from the choices that satisfy the **independent** recovery requirement. +2. Require at least one RP-independent owner: hardware wallet, existing EOA, or offline recovery key. +3. A second passkey may remain as an additional convenience owner, but label it “device-redundant, RP-dependent.” +4. Threat-model the printable EOA: compromised-generation page, screenshots/cloud backup, printing, theft, inheritance, verification that the public address matches the paper secret, and rotation after suspected exposure. +5. Make the fork recovery test exercise the RP-independent owner with every Vortex service and the Vortex domain unavailable. Test the passkey/domain-continuity path separately. + +The W3C WebAuthn specification states that a credential can only be used with the RP ID for which it was registered. Device diversity does not alter that scope. + +### R03 — The delayed permissionless fallback has no enforceable start time + +**Priority:** P0 +**Affected PRD lines:** 107–120, 136–143, 267 + +#### Concern + +The module permits anyone to call only after the Safe's balance has exceeded `minSwapAmount` for `LIVENESS_FALLBACK_DELAY`. The proposed storage contains `initializedAt`, but no timestamp recording when a balance crossed the threshold. An ERC-20 transfer does not call the receiving Safe or module, so the module cannot observe and timestamp the crossing automatically. + +`initializedAt` cannot substitute for this: after an account is older than 24 hours, every new deposit would be immediately permissionless. + +#### Required resolution + +Select and specify one implementable policy: + +**Simplest:** make `swapAndForward` permissionless at all times. The fixed route and `minOut` already bound caller-controlled timing, but legal/compliance must accept that any observer may trigger conversion. + +**Delayed fallback:** add an explicit state machine: + +```text +arm(safe): + require(balance >= minSwapAmount) + if eligibleSince[safe] == 0: eligibleSince[safe] = block.timestamp + +swapAndForward(safe): + authorized keeper may execute immediately + other caller requires eligibleSince != 0 + other caller requires block.timestamp >= eligibleSince + delay + successful execution resets eligibleSince + a balance below threshold resets it without allowing a caller to move it forward +``` + +Define who may arm, whether arming itself is permissionless, how cap-sized partial executions re-arm residual balances, and how threshold/parameter changes affect an existing timer. Add these transitions to invariant and fuzz tests. + +### R04 — Live balance sweeps race deposit attribution + +**Priority:** P0 +**Affected PRD lines:** 88–91, 140–149, 220–242 + +#### Concern + +The database lock serializes Vortex keeper workers, but it cannot serialize external EURe transfers. Consider: + +1. the worker locks the Safe and selects deposits A and B; +2. deposit C mints on-chain before the keeper transaction executes, while its webhook/indexer record is delayed; +3. the module reads the live balance and sweeps A+B+C (subject to the cap); +4. `ConversionExecution.includedDepositIds` contains only A and B; +5. the PRD allocates all output pro-rata to A and B. + +The execution is safe on-chain but wrong in the accounting/API layer. The same problem occurs with direct third-party EURe transfers, delayed order-to-mint reconciliation, cap-sized partial consumption, and multiple issue orders sharing a batch. “On-chain balance is the safety source” does not by itself define accounting ownership. + +#### Required resolution + +Model funding and consumption as an event-sourced asset ledger: + +1. Record every EURe credit as a `FundingLot` keyed by `(chainId, txHash, logIndex)`, including amount, block position, source address, and classification `monerium_issue | direct_transfer | unresolved`. +2. Link Monerium orders to funding lots when evidence becomes available; do not require webhook arrival before the on-chain event can exist. +3. Create `ConversionExecution` from the confirmed execution receipt and emitted `amountIn/usdcOut/fee`, not from the pre-submission plan. +4. Consume funding lots in one documented order—FIFO by canonical block/transaction/log position is simpler than ad hoc pro-rata—and support partial lot consumption at `perSwapCap`. +5. If a consumed lot is not yet linked to a Monerium order, place its output in an accounting suspense bucket and reconcile later. Never silently allocate it to known deposits. +6. Handle execution reorgs by reversing ledger consumption and rebuilding from canonical logs. +7. Specify whether unsolicited EURe is converted, quarantined operationally, or merely classified after unavoidable conversion. + +An optional simplification is to let the keeper pass a bounded `amountIn` selected from its canonical snapshot while the module still constructs every destination/router/path field. This does not remove the need for receipt-based reconciliation, but it reduces accidental inclusion of a just-arrived deposit. + +### R05 — Per-account guardian pause and operational-limit semantics are absent + +**Priority:** P0 +**Affected PRD lines:** 116–120, 138–142, 175–178, 214–216, 248–262 + +#### Concern + +The incident section says the guardian can pause globally or per account, and destination screening says a hit causes a conversion pause. The proposed storage and execution checks contain only: + +- `Config.userPaused`, controlled by the Safe; and +- `globalPaused`, controlled by Vortex. + +There is no guardian-controlled per-Safe pause. Reusing `userPaused` would be unsafe semantically: the guardian must not clear a user's pause, and an owner action must not accidentally clear a compliance/incident hold. + +The pilot's “€1k/user/day operational limit” is also not a control as written. Vortex cannot stop an incoming SEPA payment, `perSwapCap` is proposed at €5–10k, and the permissionless fallback can execute without keeper policy. The document must say whether the limit is Monerium-enforced, contract-enforced, or merely monitored. + +#### Required resolution + +1. Add independent pause domains: + +```solidity +Config { ...; bool userPaused; } +mapping(address safe => bool) guardianPaused; +bool globalPaused; +``` + +2. Require all three to be false before conversion. +3. Define role-specific transitions: Safe owners control only `userPaused`; guardian controls only `guardianPaused` and `globalPaused`. +4. Define whether guardian unpause requires a second role, delay, resolved compliance state, or multisig policy. “Unpause is safe” is true for principal routing but not necessarily for sanctions/compliance obligations. +5. Make the delayed permissionless path honor both guardian pause levels. +6. Replace the ambiguous daily limit with an enforceable rule: a Monerium account limit, an on-chain rolling conversion limit, or an explicitly labeled monitoring/hold threshold. Define behavior for excess deposits and how the user exits. +7. Define the screening window between mint and permissionless eligibility. If the design promises a compliance hold, it needs enough deterministic time to set `guardianPaused` before fallback execution. + +### R06 — Webhooks must be durably accepted before returning `200` + +**Priority:** P1 +**Affected PRD line:** 234 + +#### Concern + +“Immediate `200` + async processing” is safe only if the verified event is committed durably before the response. If the API returns `200` and then crashes before persistence or queue publication, Monerium treats delivery as successful and will not retry it. + +#### Required resolution + +Specify the inbox transaction explicitly: + +1. read raw bytes and required signature headers; +2. validate timestamp/replay window and HMAC using constant-time comparison; +3. insert `{webhookId, webhookTimestamp, rawBody, signatureVersion, receivedAt, processingStatus}` under a unique constraint; +4. commit; +5. return `200` for a committed new event or already-committed duplicate; +6. return non-2xx if verification or durable insertion fails; +7. let a retryable worker process the inbox row and retain an error/dead-letter history. + +Monerium's current documentation signs `webhook-id`, `webhook-timestamp`, and the raw body and retries failed deliveries with the same ID. The PRD's desired controls match the provider; the missing point is ACK ordering. + +### R07 — Mutable owner configuration conflicts with continuous manifest verification + +**Priority:** P1 +**Affected PRD lines:** 123–132, 248–253 + +#### Concern + +The owner may change `destination`, while the manifest contains the “full config” and a continuous verifier checks live state against it. After a legitimate owner change, either: + +- the verifier reports a security incident forever; +- Vortex silently rewrites the manifest, weakening its historical value; or +- the owner must somehow publish an authenticated manifest revision, which is not specified. + +The same issue applies to owner/module changes performed through the disaster-recovery tool. + +#### Required resolution + +Split immutable and mutable evidence: + +- `DeploymentManifest`: immutable setup transaction, code hashes, initial owners/threshold/modules/config, signed policy release. +- `ConfigHistory`: append-only projection of canonical Safe/module events with block/log coordinates and reorg handling. +- `CurrentState`: derived from chain, with a classification of `authorized owner change`, `approved migration`, `unexpected code/state`, or `reorg pending`. + +The verifier should alarm on violations of policy and unexplained state transitions, not on every difference from genesis. Define how owner-authorized changes are authenticated and how the public record preserves old versions. + +### R08 — Opaque passkey owner signatures are omitted from the post-mint threat model + +**Priority:** P1 +**Affected PRD lines:** 64–68, 77–82, 93–97, 280, 297 + +#### Concern + +Appendix A correctly rejects the claim that an extra WebAuthn signature provides meaningful what-you-see-is-what-you-sign consent under a compromised frontend. The same limitation applies later: the passkey is a threshold-1 Safe owner that can disable the module, replace owners, change the destination, or transfer assets. A compromised Vortex origin can ask the user for an opaque passkey ceremony while presenting misleading UI. + +That is not unilateral Vortex authority—the attacker still needs user presence/verification—but it is a material route around the module policy and should not disappear from the S2/S4 threat model. + +#### Required resolution + +1. Add a residual risk: a compromised approved RP origin may induce a user to authorize a malicious Safe owner transaction because the authenticator does not display decoded Ethereum intent. +2. Make routine deposit/status flows never request a passkey assertion. Reserve owner signing for a visibly separate management/recovery flow. +3. Display decoded Safe transaction data, simulation, target code identity, and before/after owners/modules/destination in at least one independently implemented confirmation surface where feasible. +4. Decide whether dangerous owner operations need the RP-independent recovery owner as a second signature. If threshold 1 is retained, state the trade-off explicitly rather than treating the module as the only post-mint path. +5. Add an assumption to the final security statement: the user has not authorized a malicious owner transaction. + +### R09 — Full-USDC sweep and unsolicited EURe lack product/accounting rules + +**Priority:** P1 +**Affected PRD lines:** 143–151, 196–198, 204–216, 230–242 + +#### Concern + +The module charges the fee on `usdcDelta` but forwards the Safe's **full** remaining USDC balance. Therefore pre-existing or accidentally sent USDC is swept to `destination` without appearing in the conversion gross/net accounting. Likewise, anyone can send EURe to the public Safe; the module cannot know whether it came from the user's IBAN. + +This is not necessarily a principal-safety bug—the configured destination receives the assets—but it contradicts the impression that each forwarded amount maps cleanly to Monerium deposits and can create compliance and partner-API discrepancies. + +#### Required resolution + +Choose explicit rules: + +- Prefer forwarding exactly `usdcDelta - fee`, leaving pre-existing USDC under owner control; or state prominently that the Safe is an auto-sweeping account for **all** USDC. +- Treat direct EURe/USDC transfers as first-class ledger events, with source classification and an API representation. +- Decide whether direct EURe is supported, quarantined in accounting, or a disclosed unsupported action that may still convert because the contract cannot distinguish its provenance. +- Add tests with non-zero pre-swap USDC, direct EURe, fee-on/off, capped partial swaps, and late-arriving transfers. + +### R10 — Operational parameter and role invariants are incomplete + +**Priority:** P1 +**Affected PRD lines:** 111–124, 138, 173–178 + +#### Concern + +The storage sketch does not fully define whether operational parameters and keepers are global or per Safe, who may change each value, or which directions are delayed. It also permits contradictory values unless additional invariants are intended—for example `minSwapAmount > perSwapCap`, which permanently prevents execution. + +“Lowering instant, raising behind the timelock” is ambiguous because protective direction differs by parameter: lowering `perSwapCap` is protective, while lowering `minSwapAmount` causes more/smaller executions. + +#### Required resolution + +Define the complete setter table before audit: + +| Parameter | Scope | Invariant | Instant direction | Delayed direction | Role | +|---|---|---|---|---|---| +| `minSwapAmount` | global or per Safe | `MIN_SWAP_FLOOR <= minSwapAmount <= perSwapCap` | explicitly decide | explicitly decide | guardian/timelock | +| `perSwapCap` | global or per Safe | `minSwapAmount <= perSwapCap <= CAP_CEILING` | decrease | increase | guardian/timelock | +| keeper set | global | non-empty unless permissionless-only | removal during incident | addition/rotation policy | multisig/timelock | +| fallback delay | immutable or governed | bounded range | explicitly decide | explicitly decide | constructor/timelock | + +Specify pending-update cancellation, events, timelock identity, guardian rotation, lost-key response, and whether a global singleton creates an accepted all-account blast radius. Add invariant tests for every cross-parameter relationship. + +### R11 — “Always exit” remains stronger than the stated assumptions + +**Priority:** P2 +**Affected PRD lines:** 68, 93–97 + +#### Concern + +S4 says the user can “always exit with assets” given an owner credential, RPC, and funded relayer. Owner control guarantees the ability to authorize and submit a Safe transaction; it does not guarantee that EURe/USDC transfers or Monerium redemption will succeed under issuer freeze, blacklist, token pause/upgrade, chain censorship, or Monerium refusal. + +#### Required resolution + +Change the guarantee to: + +> Given a valid owner credential and transaction-submission path, the user can authorize arbitrary Safe transactions without Vortex and Vortex cannot veto them. Successful asset exit still depends on Ethereum, token contracts/issuers, and—when redeeming—Monerium. + +Add token/issuer restrictions to the S4 residual column. + +### R12 — The response appendix obscures the normative specification + +**Priority:** P2 +**Affected PRD lines:** 274–297 + +#### Concern + +The appendix is valuable review history, but it repeats security decisions and sometimes adds nuance not present in the normative sections. A future implementer now has to decide whether §1–§14 or Appendix A is authoritative. That is avoidable `🧠 → 🤯` load in an already cross-disciplinary specification. + +#### Required resolution + +After this review cycle: + +1. keep the PRD normative and self-contained; +2. move the response matrix to a separate decision/review log or mark it explicitly non-normative; +3. convert every accepted nuance into the applicable normative section; +4. add a compact invariant/role/state-transition appendix generated from the final design. + +## 6. Required acceptance gates for v3 + +Before production implementation freeze or security audit (G0 prototypes may proceed): + +- [ ] R01: manifest trust root and independent-verification claim are made precise. +- [ ] R02: recovery choices guarantee at least one RP-independent owner. +- [ ] R03: permissionless-fallback state machine is implementable and tested on paper. +- [ ] R04: funding-lot and execution-consumption ledger handles late/direct/reorged transfers. +- [ ] R05: user, guardian-account, and global pause domains are represented in storage and transitions. +- [ ] R06: webhook ACK occurs only after durable inbox commit. +- [ ] R07: manifest/config history handles legitimate owner changes. +- [ ] R08: opaque passkey owner-signature risk is included in the threat model and UX. +- [ ] R09: full-USDC and direct-token behavior is an explicit product rule. +- [ ] R10: all roles, setters, bounds, and timelock directions are specified. +- [ ] R11: S4 wording describes transaction authority rather than guaranteed asset exit. +- [ ] G0–G4 remain blocking for their stated implementation/launch stages. + +## 7. Requested author response + +Please respond with a disposition and proposed change for each item: + +| ID | Disposition (`Accept`, `Reject`, `Modify`, `Needs validation`) | Author response / proposed PRD change | +|---|---|---| +| R01 | | | +| R02 | | | +| R03 | | | +| R04 | | | +| R05 | | | +| R06 | | | +| R07 | | | +| R08 | | | +| R09 | | | +| R10 | | | +| R11 | | | +| R12 | | | + +The key requested answer is now narrower than in the first review: can the team define one deterministic lifecycle from “Monerium or direct EURe credit observed” through “eligible/paused,” “on-chain execution,” “canonical receipt,” and “deposit/output ledger allocation,” while preserving an independently recoverable owner and an independently meaningful provisioning record? + +## 8. Primary references checked during re-review + +- [Monerium Whitelabel webhooks](https://docs.monerium.com/whitelabel/) +- [Monerium API](https://docs.monerium.com/api/) +- [Safe fallback handler](https://docs.safe.global/advanced/smart-account-fallback-handler) +- [Safe and passkeys](https://docs.safe.global/advanced/passkeys/passkeys-safe) +- [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) +- [Uniswap v3 SwapRouter source](https://github.com/Uniswap/v3-periphery/blob/main/contracts/SwapRouter.sol) From 7d0ab77a42e2b0aed3ce4849b48ad5d0702a5b8d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 14 Jul 2026 14:38:42 +0200 Subject: [PATCH 05/59] Add B2B zero-touch variant doc --- .../monerium-eur-usdc-onramp-b2b-variant.md | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/prd/monerium-eur-usdc-onramp-b2b-variant.md diff --git a/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md b/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md new file mode 100644 index 000000000..2074f8e8e --- /dev/null +++ b/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md @@ -0,0 +1,143 @@ +# B2B Variant: Zero-Touch Onboarding via Attestor-Linked Forwarder + +**Status:** Design proposal for discussion — not yet reviewed, not approved by Monerium or legal +**Date:** 2026-07-14 +**Related:** [main PRD v2](./monerium-eur-usdc-onramp.md) · [review](./monerium-eur-usdc-onramp-architecture-review.md) · [re-review](./monerium-eur-usdc-onramp-architecture-rereview.md) + +--- + +## 1. TL;DR (for everyone) + +**What this is.** A variant of our Monerium EUR→USDC onramp aimed at **business clients that come to us through a partner**. Each client gets a personal IBAN. Any euros they wire to it are automatically converted to USDC and delivered to a crypto address they chose upfront. The special part: **the client never has to touch a Vortex app, install a wallet, or digitally sign anything.** Onboarding happens entirely through paperwork (KYB with Monerium plus a contract with us that states their payout address). + +**Why this wasn't possible before.** Connecting an IBAN to an on-chain account normally requires the client to digitally sign one declaration ("I own this address") — that was the single step in our consumer design that forced the client into a UI with a passkey. In this variant, the on-chain account is a small special-purpose program (a "forwarding contract") that **we** deploy for the client, and it is built so that **our own signature can complete that connection** — while the program itself physically cannot do anything except convert the client's euros and deliver them to the client's pre-agreed address. + +**Why we're still not the custodian.** The obvious worry: "if Vortex signs, doesn't Vortex control the money?" No — and this is checkable by anyone reading the contract code on-chain. Our signing power is restricted inside the contract to exactly one sentence: the connection declaration. It cannot approve payouts, withdrawals, or transfers. Nobody — including us — can send the funds anywhere except the client's pre-agreed address (plus the client-controlled failsafes below). Our remaining powers are: run the conversion, pause it, and tune bounded operational parameters. We can delay money; we cannot take or redirect it. + +**The trade-off, honestly.** Because the client holds no key, there is no all-purpose recovery lever if something unexpected breaks (a trading pool dries up, a price feed is retired, the payout address stops working). Every rescue path must be designed in upfront. That leads to two client tiers: + +- **Tier A (default):** the client also names one **fallback address they control** in the paperwork. All emergency flows point there. Near-full recoverability. +- **Tier C (opt-in with signed disclosure):** the client refuses any self-controlled address (e.g. they only use an exchange account). Then stuck funds **wait safely in the contract** — visible, not lost, but frozen — until the problem clears. They accept that in writing. + +(There was a Tier B — our partner holds the emergency keys for their clients — but the partner doesn't want that power either, since it would make *them* look like a custodian. Dropped.) + +**Exchange (CEX) payout addresses are allowed** — the partner requires this. We manage the known risks (exchanges rotate deposit addresses silently) with: a small test transfer before go-live, an automatic pause if an account is dormant too long until the address is re-confirmed, a minimum payout size, and contract terms that put address-validity risk on the client/partner — same way traditional payout providers handle wrong-bank-account risk. One iron rule in all tiers: **we never send raw EURe to an exchange address** (exchanges don't support the token; it would be lost). + +**What must happen before this ships.** (1) **Monerium must approve the pattern in writing** — the ownership declaration would be produced by us, not the client, and doing that without their blessing risks our account; (2) **legal review** — our non-custody argument is strong but "custody" isn't the only licensing question (automatically converting and forwarding for clients may itself be a regulated service under MiCA); (3) partner/client terms for the destination policy; (4) the open engineering findings from the ongoing architecture re-review also apply here. + +--- + +## 2. Context (technical from here on) + +The [main PRD v2](./monerium-eur-usdc-onramp.md) targets consumers: a Safe owned by the user's passkey plus a recovery owner, with a constrained swap module. Its one unavoidable UI moment is the Monerium link signature — the user's passkey signs `"I hereby declare that I am the address owner."`, validated via the Safe's EIP-1271. + +For partner-sourced business clients we want **zero Vortex-side interaction**. KYB happens with Monerium (legacy OAuth app now, whitelabel later — portability is a G1 MSA item; currently confirmed only informally). The destination address arrives via contract paperwork. The only blocker was the link signature. This variant removes it. + +## 3. Mechanism: the attestor-constrained forwarder + +### 3.1 How Monerium's check works + +Monerium validates a link by calling `isValidSignature(bytes32 hash, bytes signature)` (EIP-1271) on the to-be-linked contract — an off-chain `eth_call`, at link time. The message is a **fixed string**, so its EIP-191 hash is a compile-time constant. Redeem orders (`"Send EUR to at "`) are **also** EIP-1271-validated — this fact drives the whole design. + +### 3.2 Why the naive version is unsafe + +"Just make the deployer key the contract's 1271 owner" fails catastrophically: a general-purpose validation key doesn't just validate the link message — it validates **redeem orders too**. Vortex could then sign `"Send EUR to "` and Monerium would burn the EURe and pay out to an arbitrary bank account. Full disposal power: a theft path and unambiguous custody. The hard-coded forwarding logic is irrelevant because redemption bypasses it. + +### 3.3 The constrained version + +`isValidSignature` accepts **exactly one hash** and only from the Vortex attestor key, with the attestation bound to the specific contract: + +```solidity +bytes32 constant LINK_HASH = /* EIP-191 hash of the fixed Monerium link message */; + +function isValidSignature(bytes32 hash, bytes calldata sig) external view returns (bytes4) { + if (hash != LINK_HASH) return 0xffffffff; + // attestor signs keccak256(address(this), LINK_HASH): no cross-contract replay, + // and no third party can link this contract to a foreign Monerium profile + address signer = ECDSA.recover(keccak256(abi.encode(address(this), LINK_HASH)), sig); + return signer == VORTEX_ATTESTOR ? bytes4(0x1626ba7e) : bytes4(0xffffffff); +} +``` + +Consequences: + +- Vortex can complete the link with no client interaction. +- Vortex **cannot** sign redeem orders or anything else — the attestor key is provably not a "means of access" to the funds. +- Every other hash fails, so no future Monerium message type validates by accident. +- Restricting to the attestor key (rather than accepting the constant hash from anyone) prevents a third party with their own Monerium profile from linking our client's forwarder to *their* profile. + +### 3.4 Contract shape + +No Safe, no passkey, no fallback handler: the account **is** a minimal purpose-built forwarder (per client, or a singleton with per-client config — same Option B analysis as PRD v2 §6.2). It reuses the PRD v2 conversion policy unchanged: pinned EURe→EURC→USDC Uniswap v3 route, contract-constructed calldata, Chainlink EUR/USD `minOut` with staleness ceiling, `CALL` only, exact approvals with reset, atomic delta checks and forwarding, keeper + delayed permissionless trigger, guardian pause. Per-client config: `destination`, `fallbackAddress` (Tier A; zero for Tier C), `feeBps` (immutable post-init), tier flags. + +## 4. Custody and compliance + +| Power | Vortex? | Notes | +|---|---|---| +| Redirect minted funds to any address | **No** | Only `destination` / tiered failsafe targets; immutable logic | +| Sign redeem orders (fiat out) | **No** | 1271 constrained to the link hash | +| Withdraw / sweep to Vortex | **No** | No such function exists | +| Execute conversion, pause, tune bounded params | Yes | Delay-only powers; worst case within slippage bound + disclosed fee | +| Deploy the contract (provisioning) | Yes | S0 trust as in PRD v2; public config manifest still applies (re-review R01 caveat: the manifest is consistency evidence, not an independent trust root) | +| Move the IBAN at Monerium (`PATCH /ibans`) | Yes (credentials) | **Unchanged S1 risk** from PRD v2 — this variant neither worsens nor fixes it; G1 | + +Three non-negotiable caveats: + +1. **Monerium's written approval is required.** The link signature exists so Monerium can verify *the customer* controls the mint address; here the declaration is produced by Vortex about a contract in which the customer holds no key. Undisclosed, this is the "operating on behalf of third parties without prior written approval" pattern their ToS prohibits — heightened on the legacy OAuth app, where our whitelabel-portability assurances are informal. Present the pattern openly (immutable forwarder, on-chain-verifiable constraint, funds only reachable by the client); it is arguably *safer* for Monerium than a user EOA, but it's their call. New G1 item. +2. **Non-custody ≠ out of MiCA scope.** The constrained-attestor argument against custody (Art. 3(1)(17): no control of assets or means of access) is strong, and pause/params are delay-only powers. But *exchange of crypto-assets* and *transfer services on behalf of clients* are separate CASP services — automatic conversion+forwarding for clients may qualify regardless of custody. G2 counsel question; do not present "no custody" as "no license needed." +3. **Provisioning concentration.** With no user verification moment at all, S0 trust in Vortex is *higher* than in the consumer flow. The manifest/transparency machinery matters more here, not less. + +## 5. The recovery problem + +In the consumer design, the client's Safe ownership was a **universal escape hatch**: whatever broke, an owner could move the funds. With zero client keys, only pre-enumerated failures are recoverable; anything unforeseen is permanent. Concrete stuck states: + +| State | Behavior without failsafes | +|---|---| +| Route dies (the EURe/EURC pool is ~$100k TVL — one LP leaving kills it) | Swaps revert forever; EURe accumulates permanently | +| Chainlink retires the EUR/USD feed (routine, weeks of notice) | Staleness check fails forever; permanent | +| EURe depeg beyond slippage bound | Reverts by design; permanent if depeg persists | +| Destination blacklisted by Circle | Atomic revert; EURe accumulates permanently | +| **CEX rotates/closes the deposit address** | **Nothing reverts — funds keep arriving somewhere the client no longer controls. Silent loss; undetectable on-chain** | +| Vortex disappears | Permissionless trigger keeps the happy path alive; combined with any state above → stuck | + +## 6. Failsafe stack and destination policy + +Decisions (2026-07-14): **Tier A default, Tier C opt-in; Tier B (partner-held recovery role) rejected — the partner declines custody-like powers.** CEX destinations are allowed in both tiers (partner requirement). + +### Tier A — client names a fallback address (default) + +One additional paperwork field: a **self-custodied** `fallbackAddress`. It can call `updateDestination`, `sweep(token, to)`, and pause/unpause its own account. Plus an immutable **permissionless dead-man sweep**: anyone may move a stranded EURe balance to `fallbackAddress` after N days unconverted (proposal: 60). Non-custodial: all authority and all emergency targets are the client's. Note a useful quirk: Circle blacklisting blocks USDC transfers, not contract calls or EURe — even a blacklisted fallback can still rotate the destination and sweep EURe out. + +### Tier C — no client-controlled address exists (signed disclosure) + +Stranded funds **stay in the contract**: visible, auditable, frozen until conditions clear — never force-delivered. One bounded softener: after N days stranded, the swap may execute with a stepwise-relaxed slippage bound up to an immutable ceiling (proposal: 1% → 5%), still delivering USDC only to `destination`. Converts "stuck on a modest depeg / thin liquidity" into "delivered with a bounded haircut"; a fully dead route remains stuck-but-safe. Client signs: *if conversion fails and you provided no recovery address, funds wait in the contract; nobody — including Vortex — can move them.* + +### Both tiers — staleness must pause, not lose + +- **Never send raw EURe to a CEX destination.** Iron rule; EURe-recovery targets are the fallback (A) or the contract itself (C). +- **Penny test** at activation: small USDC forward, client/partner confirms exchange credit before the IBAN goes live (also catches exchanges that mis-credit contract-originated token transfers). +- **Dormancy gate:** no successful forward for X days (proposal: 60) ⇒ forwarding pauses until the destination is re-confirmed (partner API ping suffices — still zero client UI). Rotation risk concentrates in dormancy; this converts silent loss into a pause. +- **Minimum forward ≥ the exchange's minimum deposit**; below it, accumulate. +- **Liability allocation in the terms:** client/partner warrants destination validity and bears rotation losses; Vortex commits to the penny test and dormancy gate as diligence. This is how traditional payout processors carry the same risk (a wrong/closed bank account is the instructing party's loss) — contractual allocation is the only mechanism that can actually hold it, since a CEX address's continued validity is not verifiable on-chain. + +Residual loss scenario in Tier A: client loses the fallback key **and** the destination breaks — ordinary self-custody residual. In Tier C: any unforeseen terminal failure — accepted in writing. + +## 7. Differences vs the consumer flow (PRD v2) + +| | Consumer (PRD v2) | B2B variant | +|---|---|---| +| Client interaction | Passkey ceremony + one link signature | None on Vortex side (KYB with Monerium + paperwork) | +| Account | Safe v1.4.1, passkey + recovery owner | Minimal forwarder, no owners | +| Link signature | User passkey via Safe EIP-1271 | Vortex attestor via hash-constrained EIP-1271 | +| Universal recovery | Owners can do anything | None — tiered failsafes only (§6) | +| Destination changes | Owner-signed Safe tx | `fallbackAddress` only (Tier A); impossible (Tier C) | +| Custody posture | User owns account | No one holds spend keys; Vortex delay-only powers; stronger in one way (no opaque owner-signature surface, cf. re-review R08), weaker in another (higher S0 provisioning trust) | +| Conversion policy | Identical (route, oracle, invariants, keeper) | Identical | + +## 8. Open items + +1. **Monerium approval of the attestor pattern** (new G1 item) — blocking; raises account-termination risk if skipped. Related G1 items unchanged: IBAN pinning (`PATCH /ibans`), OAuth→whitelabel profile portability (currently Telegram-only), recall liability. +2. **G2 legal:** custody opinion for the constrained-attestor construction; MiCA exchange/transfer-service scoping; Tier C disclosure enforceability. +3. **Partner/client terms:** destination warranty, tier selection, liability allocation, dormancy re-confirmation mechanics. +4. **Inherited re-review findings** that apply to this variant and must be resolved in the normative spec: R01 (manifest trust root), R03 (enforceable start time for the delayed permissionless trigger — same mechanism used here for the dead-man sweep), R04 (balance-sweep vs deposit-attribution races), R05 (per-account pause semantics — load-bearing for the dormancy gate), R06 (webhook durability), R09 (unsolicited-token rules), R10 (parameter/role invariants). +5. **Adversarial review of this variant** — in particular the constrained `isValidSignature` (encoding, replay, interaction with any future Monerium message types) and the tier mechanics. This document is a proposal, not a reviewed spec. From 7f4a12c6d75d9b945efe133918099dd70b65fb7a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 11:25:20 +0200 Subject: [PATCH 06/59] Lock B2B scope: implementation plan, deferred-decisions registry, variant doc update --- docs/prd/monerium-b2b-implementation-plan.md | 99 +++++++++++++++++++ .../monerium-eur-usdc-onramp-b2b-variant.md | 45 +++++---- .../prd/monerium-onramp-deferred-decisions.md | 69 +++++++++++++ 3 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 docs/prd/monerium-b2b-implementation-plan.md create mode 100644 docs/prd/monerium-onramp-deferred-decisions.md diff --git a/docs/prd/monerium-b2b-implementation-plan.md b/docs/prd/monerium-b2b-implementation-plan.md new file mode 100644 index 000000000..f08063d16 --- /dev/null +++ b/docs/prd/monerium-b2b-implementation-plan.md @@ -0,0 +1,99 @@ +# Implementation Plan: Monerium B2B Zero-Touch Onramp + +**Date:** 2026-07-17 +**Basis:** [B2B variant](./monerium-eur-usdc-onramp-b2b-variant.md) (as updated 2026-07-17) + [re-review dispositions](#5-re-review-dispositions-for-the-b2b-build) below +**Deferred parameters:** every placeholder value in this plan is tracked in the [deferred-decisions registry](./monerium-onramp-deferred-decisions.md) — implementation does not block on them. + +**Locked scope decisions (2026-07-17):** B2B variant first (consumer flow is phase 2, out of this plan). Fallback address mandatory (single tier). Whitelabel API directly, developed against sandbox during MSA negotiation. Adversarial review parallel to implementation. + +## 1. Deliverables overview + +| # | Deliverable | Where | +|---|---|---| +| D1 | `contracts/` Foundry package: `VortexForwarder` implementation + `VortexForwarderFactory` + tests (unit, mainnet-fork, invariant) | new top-level `contracts/` (not a bun workspace) | +| D2 | Backend: Monerium whitelabel service + persistent account/deposit/execution models + keeper | `apps/api` | +| D3 | Ops: config manifest publication + verifier script, monitoring/alerts, runbooks | `contracts/script`, `apps/api`, docs | +| D4 | Terms inputs: disclosure texts, penny-test + dormancy runbook | docs (with G2/partner) | +| D5 | Parallel adversarial review of spec + code | review agent round | + +## 2. Contract architecture (D1) + +### 2.1 Topology + +Each client needs their **own address** (the IBAN links to it), so per-client contracts are unavoidable. Pattern: **EIP-1167 minimal proxies** (clones) of an immutable `VortexForwarder` implementation, deployed via `VortexForwarderFactory` (CREATE2, deterministic addresses), initialized atomically in the deploy transaction. + +```text +VortexForwarder (implementation, immutable): + immutables (implementation-level): EURE, EURC, USDC, ROUTER, PATH params, ORACLE, + ORACLE_DECIMALS, MAX_ORACLE_AGE, SLIPPAGE_BPS, MAX_FEE_BPS, FEE_RECIPIENT, + ATTESTOR, GUARDIAN (Vortex ops), FACTORY, MIN_SWAP_FLOOR, CAP_CEILING, + SWEEP_DELAY, TRIGGER_DELAY, LINK_HASH, [RECOVERY_HASH — pending T1] + per-clone storage (set once by factory in deploy tx): + destination, fallbackAddress, feeBps, initialized + mutable state: strandedSince (R03 marker), accountPaused (guardian OR fallback), + destination (fallback-updatable), fallbackAddress (fallback-updatable) +``` + +### 2.2 Functions + +- `initialize(destination, fallbackAddress, feeBps)` — factory-only, once, in the deploy tx. `feeBps ≤ MAX_FEE_BPS`; `destination`, `fallbackAddress` nonzero, mutually distinct from token/router/oracle addresses. +- `isValidSignature(bytes32 hash, bytes sig)` — returns magic value iff `hash == LINK_HASH` and `sig` is the ATTESTOR's signature over `keccak256(address(this), LINK_HASH)` (plus, pending T1, the analogous check for `RECOVERY_HASH`). Everything else fails. +- `poke()` — permissionless; records `strandedSince = block.timestamp` iff EURe balance ≥ minSwapAmount and `strandedSince == 0`. Cleared on successful swap or balance dropping below threshold. This is the enforceable start time for both delayed mechanisms (fixes R03 for this build). +- `swapAndForward()` — callable by GUARDIAN/keeper any time, or **anyone** once `strandedSince` is older than TRIGGER_DELAY. Atomic: oracle-checked `minOut` (PRD v2 §7.3 math verbatim), contract-constructed `exactInput` calldata (pinned path, recipient = self), exact approval + reset, delta checks, fee skim (pilot 0), full USDC balance → `destination`. Reverts as a unit (blacklisted destination ⇒ funds stay EURe). +- `sweepStrandedEure()` — permissionless once `strandedSince` older than SWEEP_DELAY; transfers full EURe balance to `fallbackAddress` (never `destination` — CEX rule). +- `fallbackOnly` functions: `setDestination`, `setFallbackAddress`, `setAccountPaused(bool)`, `sweep(token, to)` (any token incl. EURe/USDC/unsolicited — R09). +- `guardianOnly`: `setAccountPaused(bool)` per clone (compliance holds, dormancy gate — R05) and factory-level `setGlobalPaused(bool)`. Guardian pause is protective-only: it can never move funds, change config, or block `fallbackOnly` functions (fallback sweep/exit must work even when paused — invariant). +- Operational params (`minSwapAmount`, `perSwapCap`) live on the factory, guardian-settable within immutable floor/ceiling (R10 invariant table in the contract spec). + +### 2.3 Invariants (audit targets, adapted from PRD v2 + re-review) + +1. Assets leave a clone only via: swap (router, minOut-checked, output to self), USDC→`destination`, fee ≤ feeBps→FEE_RECIPIENT, EURe→`fallbackAddress` (delayed sweep), or `fallbackOnly` sweep. Exhaustive. +2. No delegatecall, no selfdestruct, CALL only, `value == 0`, reentrancy-guarded, safe-ERC20. +3. `isValidSignature` validates at most the two whitelisted hashes; neither authorizes asset movement by Vortex. +4. Guardian powers are delay-only; fallback powers are client-only; nothing is Vortex-upgradeable. +5. `strandedSince` cannot be manipulated to skip delays (monotonic per stranding episode; reset only by swap success/balance drop). + +## 3. Backend (D2) + +New `apps/api` module (not the one-shot ramp state machine), per PRD v2 §11 adapted: + +- **Models:** `MoneriumAccount` (profileId, iban, forwarderAddress, configVersion, status), `FiatDeposit` (moneriumOrderId unique, mint tx `(chainId, txHash, logIndex, blockHash)`, amounts, compliance status), `ConversionExecution` (includedDepositIds, eureIn/usdcGross/fee/usdcNet, txHash, status). +- **Whitelabel client:** profile creation, corporate KYB submission (mechanism pending T3 — build against sandbox, abstract the KYB step), address link (`POST /addresses` with attestor signature), IBAN issuance, webhook ingestion. Auth-layer abstracted; sandbox first. +- **Webhooks:** HMAC over raw bytes, constant-time compare, **durable persist before 200** (R06), webhook-ID dedup, forward-only status transitions. +- **Attribution (R04):** an execution record snapshots the EURe balance and the set of `FiatDeposit`s with mint block ≤ execution block that are not yet allocated; allocation pro-rata, floor 6 dp, remainder to largest deposit. Deposits discovered later join the next execution. On-chain balance = safety source; order IDs = accounting source. Per-forwarder serialization via Postgres advisory lock. +- **Keeper:** mint detection (webhook + Transfer watcher), `poke()` submission, `swapAndForward()` via private orderflow, nonce management, stale-tx replacement, retries with alerting. +- **Dormancy gate (R05):** backend job pauses accounts (guardian per-clone pause) after P5 days without a successful forward; unpause on partner re-confirmation. +- **Notifications:** after N confirmations with block-hash re-verification; correction path on reorg. + +## 4. Phases + +- **Phase 0 — G0 spike (parallel with Phase 1):** whitelabel sandbox E2E: deploy forwarder (testnet), attestor-sign link, `POST /addresses`, verify Monerium's EIP-1271 validation passes, mint test EURe, observe. Chainlink EUR/USD weekend behavior (T2). Router pin + EURC hop fee tiers (P10). Reproducible liquidity baseline. **Send T1 question to Monerium tech now.** +- **Phase 1 — Contracts:** D1 complete with fork tests against real pools (incl. V1-token poisoning tests) and invariant/fuzz suite on §2.3. Exit: green suite + spec-code adversarial review round (D5) started. +- **Phase 2 — Backend:** D2 against sandbox; integration tests with mocked/sandbox Monerium; manifest publication + verifier (R01: documented as consistency evidence, not a trust root). +- **Phase 3 — Ops + terms:** monitoring (association changes at Monerium, executable-depth quotes, stranded balances, dormancy), runbooks (incident pause, 02:00-UTC vulnerability, penny test), disclosure/terms drafts to G2 + partner. +- **Phase 4 — Gates + pilot:** G1 written package, G2 sign-off, G3 audit, then G4 invite-only pilot (fee 0, conservative caps). + +Phases 0–2 are engineering-internal and start immediately; 3 runs alongside; 4 depends on externals. + +## 5. Re-review dispositions for the B2B build + +The re-review (R01–R12) targeted the consumer PRD v2; dispositions here cover the B2B build. The consumer flow re-review response is deferred to phase 2 (out of scope of this plan). + +| ID | Disposition (B2B) | Resolution | +|---|---|---| +| R01 | Accept | Manifest + verifier ship (D3) but are documented as consistency evidence with no independent trust root; S0 claim in variant doc already scoped to "detectable, not prevented". No cryptographic fix claimed | +| R02 | N/A here | No passkeys in the B2B flow. Owned by consumer phase 2 | +| R03 | Accept — resolved | `strandedSince` poke marker (§2.2) gives both delayed mechanisms an enforceable on-chain start time | +| R04 | Accept — resolved | Snapshot-based allocation rule + advisory-lock serialization (§3) | +| R05 | Accept — resolved | Per-clone guardian pause in contract state (§2.2), protective-only invariant; dormancy gate and compliance holds build on it | +| R06 | Accept — resolved | Durable webhook persistence before 200 (§3) | +| R07 | Accept | Fallback-initiated config changes (destination/fallback updates) are evented; verifier treats owner-authorized changes as expected state transitions, not incidents | +| R08 | N/A here | No opaque owner signatures — no user keys. The analogous surface (fallback-address key compromise) is disclosed in client terms (client self-custody responsibility) | +| R09 | Accept — resolved | `fallbackOnly sweep(token, to)` handles unsolicited tokens; unsolicited USDC is swept to destination with the next forward (documented); accounting treats non-Monerium EURe inflows as unattributed (flagged, not allocated to deposits) | +| R10 | Accept — resolved | §2.3.4/§2.2 role + parameter bounds table is part of the contract spec; audit target | +| R11 | Accept | "Always exit" language replaced: exit guarantees are scoped to fallback-key availability + issuer backstop (best-effort pending T1) | +| R12 | Accept | This plan + updated variant doc are the normative spec for the B2B build; the PRD v2 appendix format stays as-is for the consumer flow | + +## 6. What this plan deliberately defers + +Everything in the [registry](./monerium-onramp-deferred-decisions.md): fee value, all timing/size parameters (P1–P10), T1–T5 clarifications, G1 written package, G2 legal, partner terms. None block Phases 0–2. diff --git a/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md b/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md index 2074f8e8e..ff1726407 100644 --- a/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md +++ b/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md @@ -1,8 +1,15 @@ # B2B Variant: Zero-Touch Onboarding via Attestor-Linked Forwarder -**Status:** Design proposal for discussion — not yet reviewed, not approved by Monerium or legal -**Date:** 2026-07-14 -**Related:** [main PRD v2](./monerium-eur-usdc-onramp.md) · [review](./monerium-eur-usdc-onramp-architecture-review.md) · [re-review](./monerium-eur-usdc-onramp-architecture-rereview.md) +**Status:** Selected for implementation (2026-07-17). Monerium compliance verbally accepted the pattern (Telegram; conditional on fallback capability, which is now mandatory by design). Written approval pending (G1), legal review pending (G2), adversarial review running in parallel with implementation. +**Date:** 2026-07-14, updated 2026-07-17 +**Related:** [main PRD v2](./monerium-eur-usdc-onramp.md) · [review](./monerium-eur-usdc-onramp-architecture-review.md) · [re-review](./monerium-eur-usdc-onramp-architecture-rereview.md) · [deferred decisions](./monerium-onramp-deferred-decisions.md) · [implementation plan](./monerium-b2b-implementation-plan.md) + +**Update 2026-07-17 — Monerium compliance outcome and scope decisions:** + +- Monerium compliance accepts the attestor pattern **provided fallback capabilities are maintained**, and requires that clients be **explicitly informed that this setup limits their ability to redeem EURe directly with Monerium**. We committed to that disclosure (registry item B6). +- **Tier C is dropped:** a self-custodied `fallbackAddress` is mandatory for every client. Sections below that describe Tier C are retained for historical context only. +- **Issuer recovery backstop confirmed with a catch:** Monerium can burn tokens from a linked address and pay out — only to the customer's own external bank account, currently no fees, re-verification possible. But their recovery flow validates a signature against the linked address, which our constrained `isValidSignature` would reject. Exact recovery-message format needed from their technical team (registry item T1) so the forwarder can whitelist its hash at compile time; if unanswered by deploy, ship without it — the mandatory fallback address carries recovery, and the issuer backstop becomes best-effort. +- **Scope:** B2B variant implemented first (consumer flow is phase 2); backend targets the **whitelabel API** directly, developed against the sandbox during MSA negotiation. --- @@ -14,12 +21,9 @@ **Why we're still not the custodian.** The obvious worry: "if Vortex signs, doesn't Vortex control the money?" No — and this is checkable by anyone reading the contract code on-chain. Our signing power is restricted inside the contract to exactly one sentence: the connection declaration. It cannot approve payouts, withdrawals, or transfers. Nobody — including us — can send the funds anywhere except the client's pre-agreed address (plus the client-controlled failsafes below). Our remaining powers are: run the conversion, pause it, and tune bounded operational parameters. We can delay money; we cannot take or redirect it. -**The trade-off, honestly.** Because the client holds no key, there is no all-purpose recovery lever if something unexpected breaks (a trading pool dries up, a price feed is retired, the payout address stops working). Every rescue path must be designed in upfront. That leads to two client tiers: +**The trade-off, honestly.** Because the client holds no key, there is no all-purpose recovery lever if something unexpected breaks (a trading pool dries up, a price feed is retired, the payout address stops working). Every rescue path must be designed in upfront. Our answer (and a condition of Monerium's acceptance): every client **must** name one **fallback address they control** in the paperwork. All emergency flows point there. As a break-glass backstop behind that, Monerium itself can recover tokens from the linked address and pay them out to the client's own verified bank account. Clients are told explicitly (Monerium requires this) that EURe received at the forwarding address cannot be redeemed directly from it — redemption runs via withdrawal to their fallback address, or via Monerium's recovery process as a last resort. -- **Tier A (default):** the client also names one **fallback address they control** in the paperwork. All emergency flows point there. Near-full recoverability. -- **Tier C (opt-in with signed disclosure):** the client refuses any self-controlled address (e.g. they only use an exchange account). Then stuck funds **wait safely in the contract** — visible, not lost, but frozen — until the problem clears. They accept that in writing. - -(There was a Tier B — our partner holds the emergency keys for their clients — but the partner doesn't want that power either, since it would make *them* look like a custodian. Dropped.) +(Earlier drafts had a "Tier C" for clients refusing any self-controlled address, and a "Tier B" where our partner holds emergency keys. Both are dropped: the partner declines custody-like powers, and Monerium's acceptance is conditioned on fallback capability.) **Exchange (CEX) payout addresses are allowed** — the partner requires this. We manage the known risks (exchanges rotate deposit addresses silently) with: a small test transfer before go-live, an automatic pause if an account is dormant too long until the address is re-confirmed, a minimum payout size, and contract terms that put address-validity risk on the client/partner — same way traditional payout providers handle wrong-bank-account risk. One iron rule in all tiers: **we never send raw EURe to an exchange address** (exchanges don't support the token; it would be lost). @@ -102,17 +106,18 @@ In the consumer design, the client's Safe ownership was a **universal escape hat ## 6. Failsafe stack and destination policy -Decisions (2026-07-14): **Tier A default, Tier C opt-in; Tier B (partner-held recovery role) rejected — the partner declines custody-like powers.** CEX destinations are allowed in both tiers (partner requirement). +Decision history: Tier B (partner-held recovery role) rejected 2026-07-14 — the partner declines custody-like powers. Tier C (no fallback, stuck-but-safe) dropped 2026-07-17 — Monerium's acceptance is conditioned on maintained fallback capability. **Final policy: exactly one tier; the fallback address is mandatory.** CEX destinations are allowed (partner requirement). -### Tier A — client names a fallback address (default) +### Mandatory fallback address (every client) One additional paperwork field: a **self-custodied** `fallbackAddress`. It can call `updateDestination`, `sweep(token, to)`, and pause/unpause its own account. Plus an immutable **permissionless dead-man sweep**: anyone may move a stranded EURe balance to `fallbackAddress` after N days unconverted (proposal: 60). Non-custodial: all authority and all emergency targets are the client's. Note a useful quirk: Circle blacklisting blocks USDC transfers, not contract calls or EURe — even a blacklisted fallback can still rotate the destination and sweep EURe out. -### Tier C — no client-controlled address exists (signed disclosure) +### Redemption disclosure and issuer backstop (Monerium requirements/commitments, 2026-07-16/17) -Stranded funds **stay in the contract**: visible, auditable, frozen until conditions clear — never force-delivered. One bounded softener: after N days stranded, the swap may execute with a stepwise-relaxed slippage bound up to an immutable ceiling (proposal: 1% → 5%), still delivering USDC only to `destination`. Converts "stuck on a modest depeg / thin liquidity" into "delivered with a bounded haircut"; a fully dead route remains stuck-but-safe. Client signs: *if conversion fails and you provided no recovery address, funds wait in the contract; nobody — including Vortex — can move them.* +- Client terms must state explicitly: *EURe received at the forwarding address cannot be redeemed directly with Monerium from that address; redemption requires withdrawal to your fallback address (from which you can redeem normally), or Monerium's recovery process as a last resort.* (Registry B6.) +- Issuer backstop: Monerium confirmed it can burn tokens from a linked address and pay out **only to the customer's own external bank account**, currently without fees, possibly requiring re-verification. Open item T1: their recovery flow validates a signature against the linked address — the forwarder must whitelist the recovery-message hash (compile-time constant) or the backstop fails-closed for contract addresses. If T1 is unanswered at deploy time, ship without the whitelist; the mandatory fallback carries recovery. -### Both tiers — staleness must pause, not lose +### Staleness must pause, not lose - **Never send raw EURe to a CEX destination.** Iron rule; EURe-recovery targets are the fallback (A) or the contract itself (C). - **Penny test** at activation: small USDC forward, client/partner confirms exchange credit before the IBAN goes live (also catches exchanges that mis-credit contract-originated token transfers). @@ -130,14 +135,16 @@ Residual loss scenario in Tier A: client loses the fallback key **and** the dest | Account | Safe v1.4.1, passkey + recovery owner | Minimal forwarder, no owners | | Link signature | User passkey via Safe EIP-1271 | Vortex attestor via hash-constrained EIP-1271 | | Universal recovery | Owners can do anything | None — tiered failsafes only (§6) | -| Destination changes | Owner-signed Safe tx | `fallbackAddress` only (Tier A); impossible (Tier C) | +| Destination changes | Owner-signed Safe tx | `fallbackAddress` only | | Custody posture | User owns account | No one holds spend keys; Vortex delay-only powers; stronger in one way (no opaque owner-signature surface, cf. re-review R08), weaker in another (higher S0 provisioning trust) | | Conversion policy | Identical (route, oracle, invariants, keeper) | Identical | ## 8. Open items -1. **Monerium approval of the attestor pattern** (new G1 item) — blocking; raises account-termination risk if skipped. Related G1 items unchanged: IBAN pinning (`PATCH /ibans`), OAuth→whitelabel profile portability (currently Telegram-only), recall liability. -2. **G2 legal:** custody opinion for the constrained-attestor construction; MiCA exchange/transfer-service scoping; Tier C disclosure enforceability. -3. **Partner/client terms:** destination warranty, tier selection, liability allocation, dormancy re-confirmation mechanics. -4. **Inherited re-review findings** that apply to this variant and must be resolved in the normative spec: R01 (manifest trust root), R03 (enforceable start time for the delayed permissionless trigger — same mechanism used here for the dead-man sweep), R04 (balance-sweep vs deposit-attribution races), R05 (per-account pause semantics — load-bearing for the dormancy gate), R06 (webhook durability), R09 (unsolicited-token rules), R10 (parameter/role invariants). -5. **Adversarial review of this variant** — in particular the constrained `isValidSignature` (encoding, replay, interaction with any future Monerium message types) and the tier mechanics. This document is a proposal, not a reviewed spec. +Tracked centrally in the [deferred-decisions registry](./monerium-onramp-deferred-decisions.md); summary: + +1. **G1 written package from Monerium** — verbal acceptance exists (attestor pattern, disclosure requirement, recovery backstop); must be consolidated in writing, together with the pre-existing items: IBAN pinning (`PATCH /ibans`), profile portability, recall liability, corporate-KYB mechanism (T3), and the recovery-message format (T1). +2. **G2 legal:** custody opinion for the constrained-attestor construction; MiCA exchange/transfer-service scoping; disclosure enforceability. +3. **Partner/client terms:** destination warranty, liability allocation, dormancy re-confirmation mechanics, redemption disclosure (B6). +4. **Inherited re-review findings** — dispositions and concrete resolutions live in the [implementation plan](./monerium-b2b-implementation-plan.md): R01 (manifest trust root), R03 (enforceable start time — resolved via on-chain `strandedSince` marker), R04 (sweep vs attribution races), R05 (per-account pause — load-bearing for the dormancy gate), R06 (webhook durability), R09 (unsolicited-token rules), R10 (parameter/role invariants). +5. **Adversarial review of this variant** — runs in parallel with implementation (decision 2026-07-17); must cover the constrained `isValidSignature` (encoding, replay, T1 whitelist interaction) and the fallback mechanics. diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md new file mode 100644 index 000000000..18a77cf37 --- /dev/null +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -0,0 +1,69 @@ +# Monerium Onramp — Deferred Decisions Registry + +**Purpose:** single place for every parameter and decision we deliberately postponed so implementation can start. Nothing in here blocks coding; each row states the placeholder used in code/spec until decided. Review this file at every phase gate. + +**Last updated:** 2026-07-17 + +## Business decisions (Marcel / partner) + +| # | Decision | Placeholder until decided | Needed by | +|---|---|---|---| +| B1 | GA `feeBps` value (structure is built; per-client, immutable at init) | `0` (pilot) | Before first paying client | +| B2 | Penny-test amount for destination verification | 5 USDC | Pilot onboarding runbook | +| B3 | Processing SLA wording for client terms (incl. weekend behavior) | "within 1 business hour; FX-market-hours caveat" | Terms drafting (with G2) | +| B4 | Pilot client list + per-client volume limits | €1k/client/day | G4 pilot start | +| B5 | Partner liability terms: destination warranty, rotation-loss allocation, dormancy re-confirmation mechanics | — | Partner agreement signing | +| B6 | Redemption-limitation disclosure text (Monerium requires it; commitment made in TG thread) | Draft in variant doc §6 | Terms drafting (with G2) | + +## Contract parameters (decide before mainnet deploy; placeholders fine for sandbox/testnet) + +| # | Parameter | Placeholder | Notes | +|---|---|---|---| +| P1 | `SLIPPAGE_BPS` | 100 (1%) | Immutable; absorbs EURe + USDC basis vs Chainlink EUR/USD | +| P2 | `MAX_FEE_BPS` | 100 (1%) | Immutable ceiling for per-client feeBps | +| P3 | Dead-man sweep delay (stranded EURe → fallbackAddress) | 60 days | Immutable; uses on-chain `strandedSince` marker (R03 fix) | +| P4 | Permissionless swap-trigger delay | 24 h | Same marker as P3 | +| P5 | Dormancy pause window (no successful forward → pause pending re-confirmation) | 60 days | Operational (backend-enforced via per-account pause), not immutable | +| P6 | `minSwapAmount` floor / operational value | €25; must also be ≥ CEX min deposit per client | Floor immutable, operational value adjustable within bounds | +| P7 | `perSwapCap` operational value + immutable ceiling | €10k / €50k | Availability parameter, not safety (minOut is safety) | +| P8 | `MAX_ORACLE_AGE` | 26 h | Pending G0 weekend-behavior check (T2) | +| P9 | Notification confirmation depth | 32 blocks | Backend only | +| P10 | Uniswap router pin (SwapRouter w/ deadline vs SwapRouter02 w/o) + EURC hop fee-tier re-verification | SwapRouter02 | G0 spike output | + +## Technical clarifications pending (external) + +| # | Item | Owner | Status | +|---|---|---|---| +| T1 | **Monerium recovery-burn mechanism for contract addresses**: exact message/hash their recovery flow validates via EIP-1271, so the forwarder can whitelist it (compile-time constant `RECOVERY_HASH`). If unanswered by deploy time: ship without it — fallback-address recovery covers us; issuer backstop becomes best-effort | Monerium tech team (compliance punted) | **Asked? No — send follow-up** | +| T2 | Chainlink EUR/USD weekend behavior (heartbeat continues vs stops) → weekend policy | G0 spike | Open | +| T3 | Corporate KYB mechanism under whitelabel: Monerium-run verification vs KYC-reliance (reliance requires licenses we may not hold) | Monerium MSA negotiation | Open — fold into G1 | +| T4 | Whitelabel sandbox: verify EIP-1271 link works against a deployed forwarder E2E (G0 headline item) | Us | Open | +| T5 | Whether Monerium rejects linking an address already linked to another profile (defense-in-depth question) | Monerium tech | Nice-to-have | + +## G1 — written approval package to collect from Monerium + +All currently Telegram-only. Consolidate into MSA or side letter: + +1. Attestor-pattern acceptance (compliance said "fine if fallback capabilities maintained" — fallback is now mandatory, so condition is met by design). +2. Redemption-limitation disclosure obligation (their explicit request; our commitment). +3. Issuer recovery backstop: burn from linked address + payout only to customer's own external bank account, no fees, re-verification possible (their statements 2026-07-16/17) + T1 mechanics. +4. IBAN pinning: authorization required for `PATCH /ibans` / `POST /addresses` on whitelabel profiles (pre-existing G1 item — unresolved). +5. OAuth→whitelabel profile portability + whether whitelabel `client_id` auto-accesses existing profiles (pre-existing; Telegram-only). +6. SEPA recall / fraud loss allocation after conversion+forwarding (pre-existing; unresolved). +7. Per-IBAN suspension capability for incident response (pre-existing; unresolved). +8. T3 corporate KYB mechanism. + +## G2 — legal review scope (unchanged, not started) + +Custody opinion on attestor construction; MiCA exchange/transfer-service scoping (non-custody ≠ out of scope); disclosure enforceability; DPA/controller-processor with Monerium; sanctions screening procedure for destinations. + +## Decisions already made (do not reopen without cause) + +- B2B variant first; consumer passkey flow is phase 2 (2026-07-17). +- Tier C dropped: self-custodied `fallbackAddress` mandatory for every client (2026-07-17; aligns with Monerium condition). +- Target whitelabel API directly, develop against sandbox; no legacy-OAuth interim build (2026-07-17). +- Adversarial review runs in parallel with implementation (2026-07-17). +- Attestor-constrained `isValidSignature` (link hash only, attestor key only, bound to contract address); never a general owner key. +- Never send raw EURe to a CEX destination; EURe recovery targets are `fallbackAddress` only. +- No on-contract redeem validator (F05 stands); redemption path = fallback sweep → client redeems from own address; issuer recovery as break-glass backstop (pending T1). +- Fee structure: per-client immutable `feeBps` at init, immutable `MAX_FEE_BPS` + treasury (pilot 0). From 77c4be78c8142b1e527059299427c1c260646ef9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 11:33:49 +0200 Subject: [PATCH 07/59] Add monerium-forwarder Foundry project: attestor-linked forwarder + factory + unit tests --- .gitmodules | 3 + contracts/README.md | 1 + contracts/monerium-forwarder/.gitignore | 3 + contracts/monerium-forwarder/README.md | 20 + contracts/monerium-forwarder/foundry.lock | 8 + contracts/monerium-forwarder/foundry.toml | 14 + contracts/monerium-forwarder/lib/forge-std | 1 + contracts/monerium-forwarder/package.json | 10 + .../src/VortexForwarder.sol | 415 ++++++++++++++++++ .../src/VortexForwarderFactory.sol | 147 +++++++ .../test/VortexForwarder.t.sol | 360 +++++++++++++++ package.json | 2 + 12 files changed, 984 insertions(+) create mode 100644 .gitmodules create mode 100644 contracts/monerium-forwarder/.gitignore create mode 100644 contracts/monerium-forwarder/README.md create mode 100644 contracts/monerium-forwarder/foundry.lock create mode 100644 contracts/monerium-forwarder/foundry.toml create mode 160000 contracts/monerium-forwarder/lib/forge-std create mode 100644 contracts/monerium-forwarder/package.json create mode 100644 contracts/monerium-forwarder/src/VortexForwarder.sol create mode 100644 contracts/monerium-forwarder/src/VortexForwarderFactory.sol create mode 100644 contracts/monerium-forwarder/test/VortexForwarder.t.sol diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..2187cab4a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "contracts/monerium-forwarder/lib/forge-std"] + path = contracts/monerium-forwarder/lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/contracts/README.md b/contracts/README.md index 23423fb7c..ae5a70c9b 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -15,3 +15,4 @@ This directory contains smart-contract projects managed as Bun workspaces. ## Current projects - `relayer/` - Relayer contract project (Hardhat) +- `monerium-forwarder/` - Attestor-linked forwarder for the Monerium B2B onramp (Foundry) diff --git a/contracts/monerium-forwarder/.gitignore b/contracts/monerium-forwarder/.gitignore new file mode 100644 index 000000000..83f939c72 --- /dev/null +++ b/contracts/monerium-forwarder/.gitignore @@ -0,0 +1,3 @@ +out/ +cache/ +broadcast/ diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md new file mode 100644 index 000000000..c80f1aeb5 --- /dev/null +++ b/contracts/monerium-forwarder/README.md @@ -0,0 +1,20 @@ +# Monerium B2B Onramp Forwarder + +Foundry project for the attestor-linked forwarder (Monerium B2B zero-touch onramp): +per-client EIP-1167 clones whose EIP-1271 `isValidSignature` accepts only the fixed +Monerium link message from the Vortex attestor, with an immutable EURe→EURC→USDC +conversion policy and client-controlled recovery. + +- Spec: [docs/prd/monerium-b2b-implementation-plan.md](../../docs/prd/monerium-b2b-implementation-plan.md) §2 + and [docs/prd/monerium-eur-usdc-onramp-b2b-variant.md](../../docs/prd/monerium-eur-usdc-onramp-b2b-variant.md) +- All placeholder parameter values (slippage, delays, caps, fee) are tracked in + [docs/prd/monerium-onramp-deferred-decisions.md](../../docs/prd/monerium-onramp-deferred-decisions.md) — + do not treat values in code as final. + +```bash +forge build +forge test # unit tests (mocks) +ETH_RPC_URL=... forge test # + mainnet fork tests (pending, task 2) +``` + +Linted by `forge fmt`/forge-lint, not Biome. diff --git a/contracts/monerium-forwarder/foundry.lock b/contracts/monerium-forwarder/foundry.lock new file mode 100644 index 000000000..31b0fe2e2 --- /dev/null +++ b/contracts/monerium-forwarder/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.2", + "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b" + } + } +} \ No newline at end of file diff --git a/contracts/monerium-forwarder/foundry.toml b/contracts/monerium-forwarder/foundry.toml new file mode 100644 index 000000000..0835e8dbf --- /dev/null +++ b/contracts/monerium-forwarder/foundry.toml @@ -0,0 +1,14 @@ +[profile.default] +src = "src" +out = "out" +test = "test" +libs = ["lib"] +solc_version = "0.8.26" +optimizer = true +optimizer_runs = 10000 + +[profile.default.fuzz] +runs = 512 + +# Mainnet fork tests (task 2) read this endpoint from env: +# ETH_RPC_URL must be set; fork tests are skipped without it. diff --git a/contracts/monerium-forwarder/lib/forge-std b/contracts/monerium-forwarder/lib/forge-std new file mode 160000 index 000000000..bf647bd60 --- /dev/null +++ b/contracts/monerium-forwarder/lib/forge-std @@ -0,0 +1 @@ +Subproject commit bf647bd6046f2f7da30d0c2bf435e5c76a780c1b diff --git a/contracts/monerium-forwarder/package.json b/contracts/monerium-forwarder/package.json new file mode 100644 index 000000000..660ff1ec5 --- /dev/null +++ b/contracts/monerium-forwarder/package.json @@ -0,0 +1,10 @@ +{ + "name": "@vortexfi/contracts-monerium-forwarder", + "version": "0.1.0", + "private": true, + "description": "Attestor-linked forwarder contracts for the Monerium B2B zero-touch onramp (Foundry)", + "scripts": { + "compile": "forge build", + "test": "forge test" + } +} diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol new file mode 100644 index 000000000..dfe545e87 --- /dev/null +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); +} + +/// Uniswap V3 SwapRouter02-style multi-hop interface (no deadline field; registry P10 +/// tracks the final router pin — if classic SwapRouter is chosen, add the deadline). +interface ISwapRouter02 { + struct ExactInputParams { + bytes path; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + } + + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); +} + +interface AggregatorV3Interface { + function decimals() external view returns (uint8); + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} + +interface IVortexForwarderFactory { + function guardian() external view returns (address); + function isKeeper(address account) external view returns (bool); + function globalPaused() external view returns (bool); + function minSwapAmount() external view returns (uint256); + function perSwapCap() external view returns (uint256); +} + +/// @title VortexForwarder +/// @notice Per-client forwarding account for the Monerium B2B onramp +/// (docs/prd/monerium-eur-usdc-onramp-b2b-variant.md, implementation plan §2). +/// Deployed as an EIP-1167 clone by VortexForwarderFactory; the clone address is +/// linked to the client's Monerium profile, EURe mints land here, and the only +/// ways assets can ever leave are: +/// 1. the pinned EURe -> EURC -> USDC swap (oracle-checked minOut, output to self), +/// 2. USDC to the client's `destination` (plus fee <= feeBps to FEE_RECIPIENT), +/// 3. EURe to the client's `fallbackAddress` (delayed permissionless sweep), +/// 4. anything, by the client's `fallbackAddress` itself (`sweep`). +/// Vortex (guardian/keeper) can execute the policy, pause it, and nothing else. +/// @dev EIP-1271 is deliberately constrained to the fixed Monerium link message hash +/// signed by ATTESTOR and bound to this clone's address — it must never validate +/// redeem orders (that would hand Vortex fiat-payout power; see variant doc §3.2). +contract VortexForwarder { + // ---------------------------------------------------------------- constants + + bytes4 private constant EIP1271_MAGIC = 0x1626ba7e; + bytes4 private constant EIP1271_FAIL = 0xffffffff; + uint16 private constant BPS = 10_000; + + string public constant LINK_MESSAGE = "I hereby declare that I am the address owner."; + + // ------------------------------------------------------------- immutables + // Immutables live in the implementation's code and are shared by all clones. + + IERC20 public immutable EURE; + IERC20 public immutable EURC; + IERC20 public immutable USDC; + ISwapRouter02 public immutable ROUTER; + AggregatorV3Interface public immutable ORACLE; // Chainlink EUR/USD + uint8 public immutable ORACLE_DECIMALS; + IVortexForwarderFactory public immutable FACTORY; + address public immutable ATTESTOR; // signs the Monerium link attestation + address public immutable FEE_RECIPIENT; + uint256 public immutable MAX_ORACLE_AGE; // registry P8 + uint16 public immutable SLIPPAGE_BPS; // registry P1 + uint16 public immutable MAX_FEE_BPS; // registry P2 + uint256 public immutable SWEEP_DELAY; // registry P3 + uint256 public immutable TRIGGER_DELAY; // registry P4 + uint24 public immutable POOL_FEE_EURE_EURC; // registry P10 + uint24 public immutable POOL_FEE_EURC_USDC; // registry P10 + + /// @dev EIP-191 personal-message hash and raw keccak of LINK_MESSAGE. Monerium's + /// exact hashing scheme is a G0 spike output (task 4); accepting both is safe + /// because both encode only the fixed link message. + bytes32 public immutable LINK_HASH_191; + bytes32 public immutable LINK_HASH_RAW; + + /// @dev Monerium issuer-recovery message hash (registry T1). bytes32(0) = disabled. + /// When enabled, allows Monerium's recovery burn to validate against this + /// contract; payout is constrained by Monerium to the client's own verified + /// bank account, so this grants Vortex no disposal power. + bytes32 public immutable RECOVERY_HASH; + + struct ImmutableConfig { + address eure; + address eurc; + address usdc; + address router; + address oracle; + address attestor; + address feeRecipient; + uint256 maxOracleAge; + uint16 slippageBps; + uint16 maxFeeBps; + uint256 sweepDelay; + uint256 triggerDelay; + uint24 poolFeeEureEurc; + uint24 poolFeeEurcUsdc; + bytes32 recoveryHash; + } + + // ---------------------------------------------------------------- storage + // Per-clone state, set once by the factory in the deployment transaction. + + bool public initialized; + address public destination; // client's payout address (may be a CEX deposit address) + address public fallbackAddress; // client's self-custodied recovery address (mandatory) + uint16 public feeBps; // immutable post-init (registry B1; pilot = 0) + + bool public clientPaused; // set by fallbackAddress only + bool public guardianPaused; // set by guardian only (protective-only; cannot block fallback paths) + + /// @dev R03 marker: when the EURe balance first crossed minSwapAmount with no + /// successful swap since. Start time for TRIGGER_DELAY and SWEEP_DELAY. + uint64 public strandedSince; + + uint256 private _reentrancyGuard; + + // ----------------------------------------------------------------- events + + event Initialized(address destination, address fallbackAddress, uint16 feeBps); + event Poked(uint64 strandedSince); + event SwapExecuted(address indexed caller, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded); + event StrandedEureSwept(address indexed caller, uint256 amount); + event DestinationUpdated(address previous, address current); + event FallbackAddressUpdated(address previous, address current); + event ClientPausedSet(bool paused); + event GuardianPausedSet(bool paused); + event TokenSwept(address indexed token, address indexed to, uint256 amount); + + // ----------------------------------------------------------------- errors + + error AlreadyInitialized(); + error NotFactory(); + error NotFallbackAddress(); + error NotGuardian(); + error NotAuthorizedYet(); + error Paused(); + error ZeroAddress(); + error InvalidConfigAddress(); + error FeeTooHigh(); + error BelowMinimum(); + error StalePrice(); + error InvalidPrice(); + error InsufficientOutput(); + error Overspend(); + error NotStranded(); + error DelayNotElapsed(); + error TransferFailed(); + error Reentrancy(); + + // ------------------------------------------------------------ constructor + + constructor(ImmutableConfig memory cfg) { + EURE = IERC20(cfg.eure); + EURC = IERC20(cfg.eurc); + USDC = IERC20(cfg.usdc); + ROUTER = ISwapRouter02(cfg.router); + ORACLE = AggregatorV3Interface(cfg.oracle); + ORACLE_DECIMALS = AggregatorV3Interface(cfg.oracle).decimals(); + FACTORY = IVortexForwarderFactory(msg.sender); + ATTESTOR = cfg.attestor; + FEE_RECIPIENT = cfg.feeRecipient; + MAX_ORACLE_AGE = cfg.maxOracleAge; + SLIPPAGE_BPS = cfg.slippageBps; + MAX_FEE_BPS = cfg.maxFeeBps; + SWEEP_DELAY = cfg.sweepDelay; + TRIGGER_DELAY = cfg.triggerDelay; + POOL_FEE_EURE_EURC = cfg.poolFeeEureEurc; + POOL_FEE_EURC_USDC = cfg.poolFeeEurcUsdc; + RECOVERY_HASH = cfg.recoveryHash; + + LINK_HASH_RAW = keccak256(bytes(LINK_MESSAGE)); + LINK_HASH_191 = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n45", LINK_MESSAGE)); + + // Brick the implementation itself; only clones can be initialized. + initialized = true; + } + + // ------------------------------------------------------------- modifiers + + modifier nonReentrant() { + if (_reentrancyGuard != 0) revert Reentrancy(); + _reentrancyGuard = 1; + _; + _reentrancyGuard = 0; + } + + modifier onlyFallback() { + if (msg.sender != fallbackAddress) revert NotFallbackAddress(); + _; + } + + modifier onlyGuardian() { + if (msg.sender != FACTORY.guardian()) revert NotGuardian(); + _; + } + + // ---------------------------------------------------------- initialization + + /// @notice Called by the factory in the same transaction as clone deployment. + function initialize(address destination_, address fallbackAddress_, uint16 feeBps_) external { + if (msg.sender != address(FACTORY)) revert NotFactory(); + if (initialized) revert AlreadyInitialized(); + _validateConfigAddress(destination_); + _validateConfigAddress(fallbackAddress_); + if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh(); + + initialized = true; + destination = destination_; + fallbackAddress = fallbackAddress_; + feeBps = feeBps_; + emit Initialized(destination_, fallbackAddress_, feeBps_); + } + + // -------------------------------------------------------------- EIP-1271 + + /// @notice Constrained EIP-1271: validates ONLY the fixed Monerium link message + /// (and, if enabled via RECOVERY_HASH, Monerium's recovery message), + /// signed by ATTESTOR over keccak256(address(this), hash). + /// Binding to address(this) prevents replay across clones; restricting to + /// ATTESTOR prevents third parties from linking this address to a foreign + /// Monerium profile. + function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { + bool isLink = (hash == LINK_HASH_191 || hash == LINK_HASH_RAW); + bool isRecovery = (RECOVERY_HASH != bytes32(0) && hash == RECOVERY_HASH); + if (!isLink && !isRecovery) return EIP1271_FAIL; + if (signature.length != 65) return EIP1271_FAIL; + + bytes32 r; + bytes32 s; + uint8 v; + // solhint-disable-next-line no-inline-assembly + assembly { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 0x20)) + v := byte(0, calldataload(add(signature.offset, 0x40))) + } + // Reject malleable signatures (high-s) and invalid v. + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) return EIP1271_FAIL; + if (v != 27 && v != 28) return EIP1271_FAIL; + + bytes32 bound = keccak256(abi.encodePacked(address(this), hash)); + address signer = ecrecover(bound, v, r, s); + if (signer == address(0) || signer != ATTESTOR) return EIP1271_FAIL; + return EIP1271_MAGIC; + } + + // ------------------------------------------------------------ stranding marker (R03) + + /// @notice Permissionless. Records when the EURe balance first crossed the swap + /// threshold (start time for TRIGGER_DELAY / SWEEP_DELAY), and clears the + /// marker if the balance dropped back below it. + function poke() external { + uint256 balance = EURE.balanceOf(address(this)); + if (balance >= FACTORY.minSwapAmount()) { + if (strandedSince == 0) { + strandedSince = uint64(block.timestamp); + emit Poked(strandedSince); + } + } else if (strandedSince != 0) { + strandedSince = 0; + emit Poked(0); + } + } + + // ------------------------------------------------------------------ swap + + /// @notice Convert EURe held by this contract to USDC and forward to `destination`. + /// Callable by guardian/keepers any time; by anyone once the stranding + /// marker is older than TRIGGER_DELAY (liveness fallback). + function swapAndForward() external nonReentrant { + if (clientPaused || guardianPaused || FACTORY.globalPaused()) revert Paused(); + + bool privileged = msg.sender == FACTORY.guardian() || FACTORY.isKeeper(msg.sender); + if (!privileged) { + if (strandedSince == 0) revert NotAuthorizedYet(); + if (block.timestamp - strandedSince < TRIGGER_DELAY) revert NotAuthorizedYet(); + } + + uint256 eureBefore = EURE.balanceOf(address(this)); + if (eureBefore < FACTORY.minSwapAmount()) revert BelowMinimum(); + uint256 amountIn = eureBefore; + uint256 cap = FACTORY.perSwapCap(); + if (amountIn > cap) amountIn = cap; + + uint256 minOut = _minOut(amountIn); + uint256 usdcBefore = USDC.balanceOf(address(this)); + + _approve(EURE, address(ROUTER), amountIn); + ROUTER.exactInput( + ISwapRouter02.ExactInputParams({ + path: abi.encodePacked( + address(EURE), POOL_FEE_EURE_EURC, address(EURC), POOL_FEE_EURC_USDC, address(USDC) + ), + recipient: address(this), + amountIn: amountIn, + amountOutMinimum: minOut + }) + ); + _approve(EURE, address(ROUTER), 0); + + uint256 usdcReceived = USDC.balanceOf(address(this)) - usdcBefore; + if (usdcReceived < minOut) revert InsufficientOutput(); + if (eureBefore - EURE.balanceOf(address(this)) > amountIn) revert Overspend(); + + uint256 fee = 0; + if (feeBps > 0) { + fee = (usdcReceived * feeBps) / BPS; + if (fee > 0) _transfer(USDC, FEE_RECIPIENT, fee); + } + + // Full-balance sweep: unsolicited USDC goes to the client's destination too (R09). + uint256 forwarded = USDC.balanceOf(address(this)); + _transfer(USDC, destination, forwarded); + + strandedSince = 0; + emit SwapExecuted(msg.sender, amountIn, usdcReceived, fee, forwarded); + } + + /// @dev minOut = amountIn * price * (1 - slippage), rescaled EURe(18) -> USDC(6). + /// Scale denominator: 10^(18 + oracleDecimals - 6). Floor rounding: conservative + /// direction; error < 1 unit of USDC. Assumes USDC/USD = 1 within SLIPPAGE_BPS + /// (documented assumption A4; PRD v2 §7.3). + function _minOut(uint256 amountIn) internal view returns (uint256) { + (, int256 answer,, uint256 updatedAt,) = ORACLE.latestRoundData(); + if (answer <= 0) revert InvalidPrice(); + if (updatedAt == 0 || block.timestamp - updatedAt > MAX_ORACLE_AGE) revert StalePrice(); + return (amountIn * uint256(answer) * (BPS - SLIPPAGE_BPS)) / (10 ** (12 + uint256(ORACLE_DECIMALS))) / BPS; + } + + // -------------------------------------------------------------- recovery + + /// @notice Permissionless dead-man sweep: after SWEEP_DELAY of stranding, anyone may + /// move the full EURe balance to the client's fallbackAddress. Deliberately + /// NOT gated on pause flags: recovery must work during incidents. Never + /// targets `destination` (CEX rule — variant doc §6). + function sweepStrandedEure() external nonReentrant { + if (strandedSince == 0) revert NotStranded(); + if (block.timestamp - strandedSince < SWEEP_DELAY) revert DelayNotElapsed(); + uint256 balance = EURE.balanceOf(address(this)); + _transfer(EURE, fallbackAddress, balance); + strandedSince = 0; + emit StrandedEureSwept(msg.sender, balance); + } + + // ------------------------------------------------------- client (fallback) authority + + function setDestination(address destination_) external onlyFallback { + _validateConfigAddress(destination_); + emit DestinationUpdated(destination, destination_); + destination = destination_; + } + + function setFallbackAddress(address fallbackAddress_) external onlyFallback { + _validateConfigAddress(fallbackAddress_); + emit FallbackAddressUpdated(fallbackAddress, fallbackAddress_); + fallbackAddress = fallbackAddress_; + } + + function setClientPaused(bool paused) external onlyFallback { + clientPaused = paused; + emit ClientPausedSet(paused); + } + + /// @notice Client exit hatch: move any token (incl. EURe/USDC/unsolicited) anywhere. + /// Works while paused — guardian pause must never trap client funds. + function sweep(address token, address to) external onlyFallback nonReentrant { + if (to == address(0)) revert ZeroAddress(); + uint256 balance = IERC20(token).balanceOf(address(this)); + _transfer(IERC20(token), to, balance); + emit TokenSwept(token, to, balance); + } + + // ----------------------------------------------------------- guardian authority + + /// @notice Protective-only: blocks swaps (compliance holds, dormancy gate — R05). + /// Cannot move funds, change config, or block fallback paths. + function setGuardianPaused(bool paused) external onlyGuardian { + guardianPaused = paused; + emit GuardianPausedSet(paused); + } + + // ---------------------------------------------------------------- helpers + + function _validateConfigAddress(address account) internal view { + if (account == address(0)) revert ZeroAddress(); + if ( + account == address(EURE) || account == address(EURC) || account == address(USDC) + || account == address(ROUTER) || account == address(this) + ) revert InvalidConfigAddress(); + } + + function _transfer(IERC20 token, address to, uint256 amount) internal { + if (amount == 0) return; + (bool success, bytes memory data) = address(token).call(abi.encodeCall(IERC20.transfer, (to, amount))); + if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } + + function _approve(IERC20 token, address spender, uint256 amount) internal { + (bool success, bytes memory data) = address(token).call(abi.encodeCall(IERC20.approve, (spender, amount))); + if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } +} diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol new file mode 100644 index 000000000..0e12fde06 --- /dev/null +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {VortexForwarder} from "./VortexForwarder.sol"; + +/// @title VortexForwarderFactory +/// @notice Deploys per-client VortexForwarder clones (EIP-1167, CREATE2) and holds the +/// guardian role plus bounded operational parameters shared by all clones +/// (implementation plan §2.2; parameter bounds are registry P6/P7). +contract VortexForwarderFactory { + address public immutable implementation; + + /// @dev Immutable bounds for the operational parameters (R10): the guardian can + /// tune values only inside [floor, ceiling]; the bounds themselves never move. + uint256 public immutable MIN_SWAP_FLOOR; + uint256 public immutable CAP_CEILING; + + address public guardian; + address public pendingGuardian; + mapping(address => bool) public isKeeper; + bool public globalPaused; + + uint256 public minSwapAmount; // registry P6 + uint256 public perSwapCap; // registry P7 + + mapping(address => bool) public isForwarder; + + event ForwarderDeployed( + address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt + ); + event KeeperSet(address indexed keeper, bool enabled); + event GlobalPausedSet(bool paused); + event MinSwapAmountSet(uint256 value); + event PerSwapCapSet(uint256 value); + event GuardianTransferStarted(address indexed current, address indexed pending); + event GuardianTransferred(address indexed previous, address indexed current); + + error NotGuardian(); + error NotPendingGuardian(); + error OutOfBounds(); + error CloneFailed(); + + modifier onlyGuardian() { + if (msg.sender != guardian) revert NotGuardian(); + _; + } + + constructor( + VortexForwarder.ImmutableConfig memory cfg, + uint256 minSwapFloor, + uint256 capCeiling, + uint256 initialMinSwapAmount, + uint256 initialPerSwapCap + ) { + guardian = msg.sender; + implementation = address(new VortexForwarder(cfg)); + MIN_SWAP_FLOOR = minSwapFloor; + CAP_CEILING = capCeiling; + _setMinSwapAmount(initialMinSwapAmount); + _setPerSwapCap(initialPerSwapCap); + } + + // ------------------------------------------------------------- deployment + + /// @notice Deploy and initialize a client forwarder in one transaction. The clone + /// address is deterministic (CREATE2) so it can be communicated/linked + /// reliably; predict it with `predictAddress` before deploying. + function deployForwarder(address destination, address fallbackAddress, uint16 feeBps, bytes32 salt) + external + onlyGuardian + returns (address forwarder) + { + forwarder = _cloneDeterministic(implementation, salt); + VortexForwarder(forwarder).initialize(destination, fallbackAddress, feeBps); + isForwarder[forwarder] = true; + emit ForwarderDeployed(forwarder, destination, fallbackAddress, feeBps, salt); + } + + function predictAddress(bytes32 salt) external view returns (address) { + bytes32 initCodeHash = keccak256(_cloneInitCode(implementation)); + return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash))))); + } + + // ------------------------------------------------------------- governance + + function setKeeper(address keeper, bool enabled) external onlyGuardian { + isKeeper[keeper] = enabled; + emit KeeperSet(keeper, enabled); + } + + function setGlobalPaused(bool paused) external onlyGuardian { + globalPaused = paused; + emit GlobalPausedSet(paused); + } + + function setMinSwapAmount(uint256 value) external onlyGuardian { + _setMinSwapAmount(value); + } + + function setPerSwapCap(uint256 value) external onlyGuardian { + _setPerSwapCap(value); + } + + /// @dev Two-step transfer: guardian is load-bearing for every clone's pause and + /// keeper gating, so a fat-fingered transfer must not be possible. + function transferGuardian(address newGuardian) external onlyGuardian { + pendingGuardian = newGuardian; + emit GuardianTransferStarted(guardian, newGuardian); + } + + function acceptGuardian() external { + if (msg.sender != pendingGuardian) revert NotPendingGuardian(); + emit GuardianTransferred(guardian, msg.sender); + guardian = msg.sender; + pendingGuardian = address(0); + } + + // ---------------------------------------------------------------- helpers + + function _setMinSwapAmount(uint256 value) internal { + if (value < MIN_SWAP_FLOOR || value > perSwapCap && perSwapCap != 0) revert OutOfBounds(); + minSwapAmount = value; + emit MinSwapAmountSet(value); + } + + function _setPerSwapCap(uint256 value) internal { + if (value > CAP_CEILING || value < minSwapAmount) revert OutOfBounds(); + perSwapCap = value; + emit PerSwapCapSet(value); + } + + /// @dev Standard EIP-1167 minimal proxy init code for `target`. + function _cloneInitCode(address target) internal pure returns (bytes memory) { + return abi.encodePacked( + hex"3d602d80600a3d3981f3363d3d373d3d3d363d73", target, hex"5af43d82803e903d91602b57fd5bf3" + ); + } + + function _cloneDeterministic(address target, bytes32 salt) internal returns (address instance) { + bytes memory initCode = _cloneInitCode(target); + // solhint-disable-next-line no-inline-assembly + assembly { + instance := create2(0, add(initCode, 0x20), mload(initCode), salt) + } + if (instance == address(0)) revert CloneFailed(); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol new file mode 100644 index 000000000..f8bfc5821 --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VortexForwarder, IERC20, ISwapRouter02} from "../src/VortexForwarder.sol"; +import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; + +contract MockERC20 { + string public name; + uint8 public decimals; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(string memory name_, uint8 decimals_) { + name = name_; + decimals = decimals_; + } + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + } + + function transfer(address to, uint256 amount) external returns (bool) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } +} + +contract MockOracle { + int256 public answer = 1.14e8; // EUR/USD + uint256 public updatedAt = block.timestamp; + uint8 public constant decimals = 8; + + function set(int256 answer_, uint256 updatedAt_) external { + answer = answer_; + updatedAt = updatedAt_; + } + + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { + return (1, answer, updatedAt, updatedAt, 1); + } +} + +contract MockRouter { + MockERC20 public immutable eure; + MockERC20 public immutable usdc; + uint256 public nextOut; + + constructor(MockERC20 eure_, MockERC20 usdc_) { + eure = eure_; + usdc = usdc_; + } + + function setNextOut(uint256 v) external { + nextOut = v; + } + + function exactInput(ISwapRouter02.ExactInputParams calldata params) external payable returns (uint256) { + eure.transferFrom(msg.sender, address(this), params.amountIn); + require(nextOut >= params.amountOutMinimum, "Too little received"); + usdc.mint(params.recipient, nextOut); + return nextOut; + } +} + +contract VortexForwarderTest is Test { + MockERC20 eure; + MockERC20 eurc; + MockERC20 usdc; + MockOracle oracle; + MockRouter router; + VortexForwarderFactory factory; + VortexForwarder fwd; + + uint256 attestorPk = 0xA11CE; + address attestor; + address feeRecipient = makeAddr("feeRecipient"); + address destination = makeAddr("destination"); + address fallbackAddr = makeAddr("fallbackAddr"); + address keeper = makeAddr("keeper"); + address rando = makeAddr("rando"); + + uint256 constant TRIGGER_DELAY = 24 hours; + uint256 constant SWEEP_DELAY = 60 days; + + function setUp() public { + attestor = vm.addr(attestorPk); + eure = new MockERC20("EURe", 18); + eurc = new MockERC20("EURC", 6); + usdc = new MockERC20("USDC", 6); + oracle = new MockOracle(); + router = new MockRouter(eure, usdc); + + factory = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(router), + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: SWEEP_DELAY, + triggerDelay: TRIGGER_DELAY, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, // MIN_SWAP_FLOOR + 50_000e18, // CAP_CEILING + 25e18, // minSwapAmount + 10_000e18 // perSwapCap + ); + factory.setKeeper(keeper, true); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + } + + // ---------------------------------------------------------------- helpers + + function _attest(address forwarder, bytes32 hash) internal view returns (bytes memory) { + bytes32 bound = keccak256(abi.encodePacked(forwarder, hash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); + return abi.encodePacked(r, s, v); + } + + function _fund(uint256 amount) internal { + eure.mint(address(fwd), amount); + } + + // ---------------------------------------------------------------- EIP-1271 + + function test_linkSignature_valid_bothHashSchemes() public view { + bytes32 h191 = fwd.LINK_HASH_191(); + bytes32 hRaw = fwd.LINK_HASH_RAW(); + assertEq(fwd.isValidSignature(h191, _attest(address(fwd), h191)), bytes4(0x1626ba7e)); + assertEq(fwd.isValidSignature(hRaw, _attest(address(fwd), hRaw)), bytes4(0x1626ba7e)); + } + + function test_linkHash191_matchesEip191OfFixedMessage() public view { + bytes memory msg_ = bytes("I hereby declare that I am the address owner."); + assertEq(msg_.length, 45); + assertEq(fwd.LINK_HASH_191(), keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n45", msg_))); + } + + function test_linkSignature_rejectsForeignHash() public view { + bytes32 evil = keccak256("Send EUR 100000 to DE00ATTACKER at 2026-07-17T00:00Z"); + assertEq(fwd.isValidSignature(evil, _attest(address(fwd), evil)), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsWrongSigner() public { + bytes32 h = fwd.LINK_HASH_191(); + bytes32 bound = keccak256(abi.encodePacked(address(fwd), h)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xBAD, bound); + assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsCrossCloneReplay() public { + VortexForwarder other = + VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(2)))); + bytes32 h = fwd.LINK_HASH_191(); + // Signature bound to `fwd` must not validate on `other`. + assertEq(other.isValidSignature(h, _attest(address(fwd), h)), bytes4(0xffffffff)); + } + + // ---------------------------------------------------------------- init + + function test_initialize_onlyFactory_andOnce() public { + vm.expectRevert(VortexForwarder.NotFactory.selector); + fwd.initialize(rando, rando, 0); + + vm.prank(address(factory)); + vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); + fwd.initialize(rando, rando, 0); + } + + function test_implementation_isBricked() public { + VortexForwarder impl = VortexForwarder(factory.implementation()); + vm.prank(address(factory)); + vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); + impl.initialize(rando, rando, 0); + } + + // ---------------------------------------------------------------- swap + + function test_swapAndForward_happyPath_forwardsToDestination() public { + _fund(1_000e18); + // minOut = 1000 * 1.14 * 0.99 = 1128.6 USDC + router.setNextOut(1_130e6); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(usdc.balanceOf(destination), 1_130e6); + assertEq(eure.balanceOf(address(fwd)), 0); + assertEq(eure.allowance(address(fwd), address(router)), 0); + } + + function test_swapAndForward_enforcesOracleMinOut() public { + _fund(1_000e18); + router.setNextOut(1_100e6); // below 1128.6 -> router-side minOut check fires + vm.prank(keeper); + vm.expectRevert("Too little received"); + fwd.swapAndForward(); + } + + function test_swapAndForward_revertsOnStaleOracle() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + oracle.set(1.14e8, block.timestamp); + skip(27 hours); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.StalePrice.selector); + fwd.swapAndForward(); + } + + function test_swapAndForward_publicOnlyAfterTriggerDelay() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.swapAndForward(); + + fwd.poke(); + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.swapAndForward(); + + skip(TRIGGER_DELAY + 1); + oracle.set(1.14e8, block.timestamp); + vm.prank(rando); + fwd.swapAndForward(); + assertEq(usdc.balanceOf(destination), 1_130e6); + } + + function test_swapAndForward_respectsPerSwapCap() public { + _fund(15_000e18); // cap is 10k + // minOut for 10k at 1.14*0.99 = 11286 USDC + router.setNextOut(11_290e6); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(eure.balanceOf(address(fwd)), 5_000e18); // remainder awaits next execution + } + + function test_swapAndForward_feeSkim() public { + VortexForwarder feeFwd = + VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 50, bytes32(uint256(3)))); + eure.mint(address(feeFwd), 1_000e18); + router.setNextOut(1_130e6); + vm.prank(keeper); + feeFwd.swapAndForward(); + uint256 fee = (1_130e6 * 50) / 10_000; + assertEq(usdc.balanceOf(feeRecipient), fee); + assertEq(usdc.balanceOf(destination), 1_130e6 - fee); + } + + function test_swapAndForward_pausedByGuardianOrClientOrGlobal() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + + fwd.setGuardianPaused(true); // test contract is factory guardian + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swapAndForward(); + fwd.setGuardianPaused(false); + + vm.prank(fallbackAddr); + fwd.setClientPaused(true); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swapAndForward(); + vm.prank(fallbackAddr); + fwd.setClientPaused(false); + + factory.setGlobalPaused(true); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swapAndForward(); + } + + // ---------------------------------------------------------------- recovery + + function test_sweepStrandedEure_afterDelay_toFallbackOnly() public { + _fund(500e18); + fwd.poke(); + + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.sweepStrandedEure(); + + skip(SWEEP_DELAY + 1); + vm.prank(rando); // permissionless + fwd.sweepStrandedEure(); + assertEq(eure.balanceOf(fallbackAddr), 500e18); + assertEq(fwd.strandedSince(), 0); + } + + function test_fallbackSweep_worksWhilePaused() public { + _fund(500e18); + fwd.setGuardianPaused(true); + vm.prank(fallbackAddr); + fwd.sweep(address(eure), fallbackAddr); + assertEq(eure.balanceOf(fallbackAddr), 500e18); + } + + function test_fallbackAuthority_gated() public { + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotFallbackAddress.selector); + fwd.setDestination(rando); + + address newDest = makeAddr("newDest"); + vm.prank(fallbackAddr); + fwd.setDestination(newDest); + assertEq(fwd.destination(), newDest); + } + + function test_guardianPause_gated() public { + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotGuardian.selector); + fwd.setGuardianPaused(true); + } + + // ---------------------------------------------------------------- factory + + function test_predictAddress_matchesDeployment() public { + bytes32 salt = bytes32(uint256(42)); + address predicted = factory.predictAddress(salt); + address deployed = factory.deployForwarder(destination, fallbackAddr, 0, salt); + assertEq(predicted, deployed); + } + + function test_factory_paramBounds() public { + vm.expectRevert(VortexForwarderFactory.OutOfBounds.selector); + factory.setPerSwapCap(60_000e18); // above ceiling + + vm.expectRevert(VortexForwarderFactory.OutOfBounds.selector); + factory.setMinSwapAmount(0.5e18); // below floor + + vm.expectRevert(VortexForwarderFactory.OutOfBounds.selector); + factory.setMinSwapAmount(20_000e18); // above current cap + } + + function test_feeBps_cappedAtMax() public { + vm.expectRevert(VortexForwarder.FeeTooHigh.selector); + factory.deployForwarder(destination, fallbackAddr, 101, bytes32(uint256(9))); + } +} diff --git a/package.json b/package.json index 402d58d3b..99260cf19 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "build:frontend": "bun run --cwd apps/frontend build", "build:sdk": "bun run --cwd packages/sdk build", "build:shared": "bun run --cwd packages/shared build", + "compile:contracts:monerium-forwarder": "bun run --cwd contracts/monerium-forwarder compile", "compile:contracts:relayer": "bun run --cwd contracts/relayer compile", "dev": "bun run --cwd packages/shared dev & bun run --cwd apps/api dev & bun run --cwd apps/frontend dev & wait", "dev:backend": "bun run --cwd apps/api dev", @@ -124,6 +125,7 @@ "serve:frontend": "bun run --cwd apps/frontend preview", "test": "bun run test:shared && bun run test:sdk && bun run test:rebalancer && bun run test:api && bun run test:frontend", "test:api": "cd apps/api && bun test", + "test:contracts:monerium-forwarder": "bun run --cwd contracts/monerium-forwarder test", "test:contracts:relayer": "bun run --cwd contracts/relayer test", "test:coverage": "bun run --cwd packages/shared test:coverage && bun run --cwd packages/sdk test:coverage && bun run --cwd apps/rebalancer test:coverage && bun run --cwd apps/api test:coverage && bun run --cwd apps/frontend test:coverage && bun scripts/coverage-report.ts", "test:coverage:html": "bun scripts/coverage-report.ts --html coverage/index.html && open coverage/index.html", From be917766d93c327e50c52e9985cf64adfcfd71e0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 11:43:06 +0200 Subject: [PATCH 08/59] Document forge-std submodule init in READMEs --- README.md | 8 ++++++++ contracts/monerium-forwarder/README.md | 1 + 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 303ba342e..65da6ab6b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ bun install If you encounter issues with the `bun install` command, you can try upgrading your `bun` version with `bun upgrade`. The installation is confirmed to work in bun v1.3.1. +The Foundry-based contracts in `contracts/monerium-forwarder` use a git submodule (`forge-std`). If you plan to work on those contracts, initialize it once: + +```bash +git submodule update --init +``` + +(Or clone with `git clone --recurse-submodules`.) Everything else in the monorepo works without this step. + ### Running the Projects #### Run All Projects diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index c80f1aeb5..375a8c8d6 100644 --- a/contracts/monerium-forwarder/README.md +++ b/contracts/monerium-forwarder/README.md @@ -12,6 +12,7 @@ conversion policy and client-controlled recovery. do not treat values in code as final. ```bash +git submodule update --init # once per clone: fetches lib/forge-std forge build forge test # unit tests (mocks) ETH_RPC_URL=... forge test # + mainnet fork tests (pending, task 2) From 0d9e9001e6c365b2b057569a9b52c94432ceff73 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 11:49:52 +0200 Subject: [PATCH 09/59] Forwarder tests: mainnet-fork suite, invariant suite, reentrancy + unsolicited-USDC units --- .../test/VortexForwarder.fork.t.sol | 125 ++++++++++++ .../test/VortexForwarder.invariants.t.sol | 182 ++++++++++++++++++ .../test/VortexForwarder.t.sol | 53 +++++ 3 files changed, 360 insertions(+) create mode 100644 contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol create mode 100644 contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol new file mode 100644 index 000000000..c4a7c2918 --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; + +interface IUniswapV3Factory { + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address); +} + +interface IERC20Meta { + function balanceOf(address) external view returns (uint256); + function decimals() external view returns (uint8); +} + +/// Mainnet fork tests against the real tokens, pools, router, and oracle. +/// Skipped when ETH_RPC_URL is not set. Addresses below are build-time pins — +/// re-verify per registry P10/G0 before any deployment. +contract VortexForwarderForkTest is Test { + // EURe V2 (Monerium, verified via CoinGecko + Etherscan 2026-07-10). V1 is deprecated. + address constant EURE_V2 = 0x39b8B6385416f4cA36a20319F70D28621895279D; + address constant EURE_V1_DEPRECATED = 0x3231Cb76718CDeF2155FC47b5286d82e6eDA273f; + address constant EURC = 0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant SWAP_ROUTER_02 = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; + address constant UNIV3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; + // Chainlink EUR/USD proxy — verify against data.chain.link before deploy (registry P8). + address constant CHAINLINK_EUR_USD = 0xb49f677943BC038e9857d61E7d053CaA2C1734C1; + + VortexForwarderFactory factory; + VortexForwarder fwd; + + address attestor = vm.addr(0xA11CE); + address destination = makeAddr("destination"); + address fallbackAddr = makeAddr("fallbackAddr"); + address keeper = makeAddr("keeper"); + + bool forked; + + function setUp() public { + string memory rpc = vm.envOr("ETH_RPC_URL", string("")); + if (bytes(rpc).length == 0) return; // tests will self-skip + vm.createSelectFork(rpc); + forked = true; + + factory = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: EURE_V2, + eurc: EURC, + usdc: USDC, + router: SWAP_ROUTER_02, + oracle: CHAINLINK_EUR_USD, + attestor: attestor, + feeRecipient: makeAddr("feeRecipient"), + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: 60 days, + triggerDelay: 24 hours, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + factory.setKeeper(keeper, true); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + } + + modifier onlyForked() { + vm.skip(!forked); + _; + } + + function test_fork_oracleIsEurUsdWithPlausiblePrice() public onlyForked { + assertEq(fwd.ORACLE_DECIMALS(), 8, "EUR/USD feed should have 8 decimals"); + (, int256 answer,, uint256 updatedAt,) = fwd.ORACLE().latestRoundData(); + // Plausibility band: a wrong feed address fails loudly here. + assertGt(answer, 0.8e8, "EUR/USD implausibly low"); + assertLt(answer, 1.6e8, "EUR/USD implausibly high"); + assertGt(updatedAt, 0); + } + + function test_fork_pinnedPathUsesV2AndPoolsExist() public onlyForked { + assertEq(address(fwd.EURE()), EURE_V2); + assertTrue(address(fwd.EURE()) != EURE_V1_DEPRECATED, "route must never touch deprecated V1 EURe"); + + // Both hops of the pinned path must exist on-chain with the pinned fee tiers. + address hop1 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURE_V2, EURC, fwd.POOL_FEE_EURE_EURC()); + address hop2 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURC, USDC, fwd.POOL_FEE_EURC_USDC()); + assertTrue(hop1 != address(0), "EURe/EURC pool missing at pinned fee tier"); + assertTrue(hop2 != address(0), "EURC/USDC pool missing at pinned fee tier"); + // The V2 pool must actually hold V2 tokens (stale-pool trap check). + assertGt(IERC20Meta(EURE_V2).balanceOf(hop1), 0, "pinned hop1 pool holds no V2 EURe"); + } + + function test_fork_swapAndForward_executesWithinOracleBounds() public onlyForked { + uint256 amountIn = 1_000e18; + deal(EURE_V2, address(fwd), amountIn); // stdStorage balance override + + (, int256 answer,,,) = fwd.ORACLE().latestRoundData(); + uint256 fair = (amountIn * uint256(answer)) / 1e20; // 6-dec USDC at oracle rate + + vm.prank(keeper); + fwd.swapAndForward(); + + uint256 received = IERC20Meta(USDC).balanceOf(destination); + assertGe(received, (fair * 9_900) / 10_000, "below oracle-bounded minOut"); + assertLe(received, (fair * 10_300) / 10_000, "implausibly above oracle rate"); + assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 0, "EURe left behind"); + assertEq(IERC20Meta(USDC).balanceOf(address(fwd)), 0, "USDC left behind"); + } + + function test_fork_perSwapCapLeavesRemainder() public onlyForked { + deal(EURE_V2, address(fwd), 12_000e18); // cap is 10k + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 2_000e18); + assertGt(IERC20Meta(USDC).balanceOf(destination), 0); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol new file mode 100644 index 000000000..91dbab084 --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; +import {MockERC20, MockOracle, MockRouter} from "./VortexForwarder.t.sol"; + +/// Randomized action handler. Ghost variables track every token unit entering the +/// system so the invariants below can assert exit-path exhaustiveness (plan §2.3.1): +/// EURe may sit in the forwarder, be consumed by the router, or reach the client's +/// fallback; USDC may only reach destination + feeRecipient; nothing else, ever. +contract ForwarderHandler is Test { + VortexForwarderFactory public factory; + VortexForwarder public fwd; + MockERC20 public eure; + MockERC20 public usdc; + MockERC20 public eurc; + MockOracle public oracle; + MockRouter public router; + + address public destination = makeAddr("destination"); + address public fallbackAddr = makeAddr("fallbackAddr"); + address public keeper = makeAddr("keeper"); + address public rando = makeAddr("rando"); + address public feeRecipient = makeAddr("feeRecipient"); + + uint256 public ghostEureMinted; + uint256 public ghostUsdcPaidByRouter; + uint256 public fallbackSweepFailures; + uint16 public immutable INITIAL_FEE_BPS = 50; + + constructor() { + eure = new MockERC20("EURe", 18); + eurc = new MockERC20("EURC", 6); + usdc = new MockERC20("USDC", 6); + oracle = new MockOracle(); + router = new MockRouter(eure, usdc); + + factory = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(router), + oracle: address(oracle), + attestor: vm.addr(0xA11CE), + feeRecipient: feeRecipient, + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: 60 days, + triggerDelay: 24 hours, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + factory.setKeeper(keeper, true); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, INITIAL_FEE_BPS, bytes32(uint256(1)))); + } + + // ------------------------------------------------------------- actions + + function fund(uint96 raw) external { + uint256 amount = bound(uint256(raw), 0, 20_000e18); + eure.mint(address(fwd), amount); + ghostEureMinted += amount; + } + + function doPoke() external { + fwd.poke(); + } + + function warp(uint32 raw) external { + vm.warp(block.timestamp + bound(uint256(raw), 1, 90 days)); + } + + /// Router pays a randomized amount around the fair oracle value; underpayment + /// exercises the minOut revert path, overpayment the happy path. + function keeperSwap(uint96 raw) external { + _swapAs(keeper, raw); + } + + function randoSwap(uint96 raw) external { + _swapAs(rando, raw); + } + + function _swapAs(address caller, uint96 raw) internal { + oracle.set(1.14e8, block.timestamp); + uint256 balance = eure.balanceOf(address(fwd)); + uint256 amountIn = balance > 10_000e18 ? 10_000e18 : balance; + uint256 fair = (amountIn * 1.14e8) / 1e20; + // 95%..105% of fair value; below 99% the swap must revert on minOut. + uint256 payout = bound(uint256(raw), (fair * 95) / 100, (fair * 105) / 100); + router.setNextOut(payout); + + uint256 routerUsdcBefore = usdc.totalMinted(); + vm.prank(caller); + try fwd.swapAndForward() { + ghostUsdcPaidByRouter += usdc.totalMinted() - routerUsdcBefore; + } catch {} + } + + function sweepStranded() external { + try fwd.sweepStrandedEure() {} catch {} + } + + function guardianPause(bool paused) external { + fwd.setGuardianPaused(paused); // handler deployed the factory -> handler is guardian + } + + function clientPause(bool paused) external { + vm.prank(fallbackAddr); + fwd.setClientPaused(paused); + } + + /// The client exit hatch must NEVER fail, including while paused (plan §2.3.4). + function clientSweepEure() external { + vm.prank(fallbackAddr); + try fwd.sweep(address(eure), fallbackAddr) {} + catch { + fallbackSweepFailures++; + } + } + + function randoTriesPrivilegedCalls(uint8 selector) external { + vm.startPrank(rando); + if (selector % 4 == 0) try fwd.setDestination(rando) {} catch {} + if (selector % 4 == 1) try fwd.setGuardianPaused(true) {} catch {} + if (selector % 4 == 2) try fwd.setFallbackAddress(rando) {} catch {} + if (selector % 4 == 3) try fwd.sweep(address(eure), rando) {} catch {} + vm.stopPrank(); + } +} + +contract VortexForwarderInvariantTest is Test { + ForwarderHandler handler; + + function setUp() public { + handler = new ForwarderHandler(); + targetContract(address(handler)); + } + + /// Exit-path exhaustiveness for EURe: every unit ever minted into the forwarder is + /// either still there, consumed by the router (swap), or at the client's fallback. + function invariant_eureConservation() public view { + uint256 accounted = handler.eure().balanceOf(address(handler.fwd())) + + handler.eure().balanceOf(address(handler.router())) + handler.eure().balanceOf(handler.fallbackAddr()); + assertEq(accounted, handler.ghostEureMinted(), "EURe leaked to an unexpected address"); + } + + /// Exit-path exhaustiveness for USDC: everything the router ever paid ends up + /// split between destination and feeRecipient; the forwarder retains nothing. + function invariant_usdcOnlyReachesDestinationAndFee() public view { + uint256 accounted = + handler.usdc().balanceOf(handler.destination()) + handler.usdc().balanceOf(handler.feeRecipient()); + assertEq(accounted, handler.ghostUsdcPaidByRouter(), "USDC leaked to an unexpected address"); + assertEq(handler.usdc().balanceOf(address(handler.fwd())), 0, "forwarder retained USDC"); + } + + /// feeBps is immutable post-init; rando privileged-call attempts must never mutate config. + function invariant_configIntegrity() public view { + assertEq(handler.fwd().feeBps(), handler.INITIAL_FEE_BPS()); + assertEq(handler.fwd().destination(), handler.destination()); + assertEq(handler.fwd().fallbackAddress(), handler.fallbackAddr()); + } + + /// Guardian/global pause must never block the client's exit hatch. + function invariant_fallbackSweepNeverBlocked() public view { + assertEq(handler.fallbackSweepFailures(), 0, "client exit hatch was blocked"); + } + + /// The stranding marker never points into the future. + function invariant_strandedSinceNotInFuture() public view { + assertLe(handler.fwd().strandedSince(), block.timestamp); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index f8bfc5821..c1444cfab 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -16,8 +16,11 @@ contract MockERC20 { decimals = decimals_; } + uint256 public totalMinted; + function mint(address to, uint256 amount) external { balanceOf[to] += amount; + totalMinted += amount; } function transfer(address to, uint256 amount) external returns (bool) { @@ -76,6 +79,14 @@ contract MockRouter { } } +/// Malicious router that tries to re-enter swapAndForward during the swap. +contract MockReentrantRouter { + function exactInput(ISwapRouter02.ExactInputParams calldata) external payable returns (uint256) { + VortexForwarder(msg.sender).swapAndForward(); // must revert via reentrancy guard + return 0; + } +} + contract VortexForwarderTest is Test { MockERC20 eure; MockERC20 eurc; @@ -292,6 +303,48 @@ contract VortexForwarderTest is Test { fwd.swapAndForward(); } + function test_unsolicitedUsdc_forwardedWithNextSwap() public { + usdc.mint(address(fwd), 500e6); // unsolicited direct transfer (R09) + _fund(1_000e18); + router.setNextOut(1_130e6); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(usdc.balanceOf(destination), 1_130e6 + 500e6); + } + + function test_reentrantRouter_blockedByGuard() public { + MockReentrantRouter evil = new MockReentrantRouter(); + VortexForwarderFactory f2 = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(evil), + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: SWEEP_DELAY, + triggerDelay: TRIGGER_DELAY, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + f2.setKeeper(keeper, true); + VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(7)))); + eure.mint(address(fwd2), 1_000e18); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Reentrancy.selector); + fwd2.swapAndForward(); + } + // ---------------------------------------------------------------- recovery function test_sweepStrandedEure_afterDelay_toFallbackOnly() public { From 9b8094e06f12744611c74564dff4fdfa81168535 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 11:58:15 +0200 Subject: [PATCH 10/59] Monerium B2B backend: persistent account/deposit/execution models + webhook inbox table --- .../051-monerium-b2b-onramp-tables.ts | 104 ++++++++++++++ apps/api/src/models/index.ts | 10 ++ apps/api/src/models/moneriumAccount.model.ts | 127 ++++++++++++++++ .../moneriumConversionExecution.model.ts | 129 +++++++++++++++++ .../src/models/moneriumFiatDeposit.model.ts | 136 ++++++++++++++++++ 5 files changed, 506 insertions(+) create mode 100644 apps/api/src/database/migrations/051-monerium-b2b-onramp-tables.ts create mode 100644 apps/api/src/models/moneriumAccount.model.ts create mode 100644 apps/api/src/models/moneriumConversionExecution.model.ts create mode 100644 apps/api/src/models/moneriumFiatDeposit.model.ts diff --git a/apps/api/src/database/migrations/051-monerium-b2b-onramp-tables.ts b/apps/api/src/database/migrations/051-monerium-b2b-onramp-tables.ts new file mode 100644 index 000000000..3a7f6d81d --- /dev/null +++ b/apps/api/src/database/migrations/051-monerium-b2b-onramp-tables.ts @@ -0,0 +1,104 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// B2B zero-touch onramp persistence (docs/prd/monerium-b2b-implementation-plan.md §3). +// Deliberately separate from ramp_states: a Monerium IBAN account is permanent and +// repeatedly funded, not a one-shot ramp. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("monerium_accounts", { + config_version: { allowNull: false, defaultValue: 1, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + destination: { allowNull: false, type: DataTypes.STRING(42) }, + dormant_since: { allowNull: true, type: DataTypes.DATE }, + fallback_address: { allowNull: false, type: DataTypes.STRING(42) }, + fee_bps: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + forwarder_address: { allowNull: false, type: DataTypes.STRING(42), unique: true }, + iban: { allowNull: true, type: DataTypes.STRING(42) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + profile_id: { allowNull: false, type: DataTypes.STRING(64), unique: true }, + status: { + allowNull: false, + defaultValue: "onboarding", + type: DataTypes.ENUM("onboarding", "active", "suspended", "closed") + }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + await queryInterface.addIndex("monerium_accounts", ["status"]); + + await queryInterface.createTable("monerium_fiat_deposits", { + account_id: { + allowNull: false, + references: { key: "id", model: "monerium_accounts" }, + type: DataTypes.UUID + }, + allocated_execution_id: { allowNull: true, type: DataTypes.UUID }, + amount_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + block_hash: { allowNull: true, type: DataTypes.STRING(66) }, + chain_id: { allowNull: true, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + currency: { allowNull: false, defaultValue: "eur", type: DataTypes.STRING(8) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + log_index: { allowNull: true, type: DataTypes.INTEGER }, + monerium_order_id: { allowNull: false, type: DataTypes.STRING(64), unique: true }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.ENUM("pending", "minted", "held", "returned") + }, + tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + await queryInterface.addIndex("monerium_fiat_deposits", ["account_id", "status"]); + // On-chain identity: one deposit per mint log (partial unique — mint fields are null + // until the Transfer is observed). + await queryInterface.sequelize.query( + "CREATE UNIQUE INDEX monerium_fiat_deposits_mint_log ON monerium_fiat_deposits (chain_id, tx_hash, log_index) WHERE tx_hash IS NOT NULL" + ); + + await queryInterface.createTable("monerium_conversion_executions", { + account_id: { + allowNull: false, + references: { key: "id", model: "monerium_accounts" }, + type: DataTypes.UUID + }, + block_number: { allowNull: true, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + destination: { allowNull: false, type: DataTypes.STRING(42) }, + error: { allowNull: true, type: DataTypes.TEXT }, + eure_in_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + fee_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.ENUM("pending", "confirmed", "failed") + }, + tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + usdc_gross_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + usdc_net_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) } + }); + await queryInterface.addIndex("monerium_conversion_executions", ["account_id", "status"]); + + // Durable webhook inbox: persist-before-200 with payload, dedup by delivery id (R06). + await queryInterface.createTable("monerium_webhook_events", { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + event_id: { allowNull: false, type: DataTypes.STRING(128), unique: true }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + payload: { allowNull: false, type: DataTypes.JSONB }, + processed_at: { allowNull: true, type: DataTypes.DATE } + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("monerium_webhook_events"); + await queryInterface.dropTable("monerium_conversion_executions"); + await queryInterface.dropTable("monerium_fiat_deposits"); + await queryInterface.dropTable("monerium_accounts"); + for (const enumName of [ + "enum_monerium_accounts_status", + "enum_monerium_fiat_deposits_status", + "enum_monerium_conversion_executions_status" + ]) { + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "${enumName}"`); + } +} diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index bd152a569..b3b96eb1b 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -5,6 +5,9 @@ import ApiKey from "./apiKey.model"; import CustomerEntity from "./customerEntity.model"; import KycCase from "./kycCase.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; +import MoneriumAccount from "./moneriumAccount.model"; +import MoneriumConversionExecution from "./moneriumConversionExecution.model"; +import MoneriumFiatDeposit from "./moneriumFiatDeposit.model"; import Notification from "./notification.model"; import NotificationPreference from "./notificationPreference.model"; import Partner from "./partner.model"; @@ -22,6 +25,10 @@ import User from "./user.model"; import Webhook from "./webhook.model"; // Define associations +MoneriumAccount.hasMany(MoneriumFiatDeposit, { as: "fiatDeposits", foreignKey: "accountId" }); +MoneriumFiatDeposit.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); +MoneriumAccount.hasMany(MoneriumConversionExecution, { as: "conversionExecutions", foreignKey: "accountId" }); +MoneriumConversionExecution.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); RampState.belongsTo(QuoteTicket, { as: "quote", foreignKey: "quoteId" }); QuoteTicket.hasOne(RampState, { as: "rampState", foreignKey: "quoteId" }); QuoteTicket.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); @@ -100,6 +107,9 @@ const models = { CustomerEntity, KycCase, MaintenanceSchedule, + MoneriumAccount, + MoneriumConversionExecution, + MoneriumFiatDeposit, Notification, NotificationPreference, Partner, diff --git a/apps/api/src/models/moneriumAccount.model.ts b/apps/api/src/models/moneriumAccount.model.ts new file mode 100644 index 000000000..681436957 --- /dev/null +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -0,0 +1,127 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum MoneriumAccountStatus { + Onboarding = "onboarding", + Active = "active", + Suspended = "suspended", + Closed = "closed" +} + +// Persistent B2B onramp account (docs/prd/monerium-b2b-implementation-plan.md §3): +// one row per client = one Monerium profile + IBAN + deployed forwarder. Long-lived, +// repeatedly funded — deliberately NOT a RampState. +export interface MoneriumAccountAttributes { + id: string; + profileId: string; + iban: string | null; + forwarderAddress: string; + destination: string; + fallbackAddress: string; + feeBps: number; + configVersion: number; + status: MoneriumAccountStatus; + dormantSince: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumAccountCreationAttributes = Optional< + MoneriumAccountAttributes, + "id" | "iban" | "configVersion" | "status" | "dormantSince" | "createdAt" | "updatedAt" +>; + +class MoneriumAccount + extends Model + implements MoneriumAccountAttributes +{ + declare id: string; + declare profileId: string; + declare iban: string | null; + declare forwarderAddress: string; + declare destination: string; + declare fallbackAddress: string; + declare feeBps: number; + declare configVersion: number; + declare status: MoneriumAccountStatus; + declare dormantSince: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumAccount.init( + { + configVersion: { + allowNull: false, + defaultValue: 1, + field: "config_version", + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + destination: { + allowNull: false, + type: DataTypes.STRING(42) + }, + dormantSince: { + allowNull: true, + field: "dormant_since", + type: DataTypes.DATE + }, + fallbackAddress: { + allowNull: false, + field: "fallback_address", + type: DataTypes.STRING(42) + }, + feeBps: { + allowNull: false, + defaultValue: 0, + field: "fee_bps", + type: DataTypes.INTEGER + }, + forwarderAddress: { + allowNull: false, + field: "forwarder_address", + type: DataTypes.STRING(42), + unique: true + }, + iban: { + allowNull: true, + type: DataTypes.STRING(42) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + profileId: { + allowNull: false, + field: "profile_id", + type: DataTypes.STRING(64), + unique: true + }, + status: { + allowNull: false, + defaultValue: MoneriumAccountStatus.Onboarding, + type: DataTypes.ENUM(...Object.values(MoneriumAccountStatus)) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [{ fields: ["status"] }], + modelName: "MoneriumAccount", + sequelize, + tableName: "monerium_accounts" + } +); + +export default MoneriumAccount; diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts new file mode 100644 index 000000000..6e88dbc34 --- /dev/null +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -0,0 +1,129 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum MoneriumConversionExecutionStatus { + Pending = "pending", + Confirmed = "confirmed", + Failed = "failed" +} + +// One row per swapAndForward execution (or intentional batch). Allocation to deposits +// is snapshot-based (plan §3, R04): included deposits are those with mint block <= +// execution block not yet allocated; pro-rata by amount, remainder to largest. +export interface MoneriumConversionExecutionAttributes { + id: string; + accountId: string; + eureInRaw: string; // 18-decimal base units + usdcGrossRaw: string | null; // 6-decimal base units + feeRaw: string | null; + usdcNetRaw: string | null; + destination: string; + txHash: string | null; + blockNumber: number | null; + status: MoneriumConversionExecutionStatus; + error: string | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumConversionExecutionCreationAttributes = Optional< + MoneriumConversionExecutionAttributes, + "id" | "usdcGrossRaw" | "feeRaw" | "usdcNetRaw" | "txHash" | "blockNumber" | "status" | "error" | "createdAt" | "updatedAt" +>; + +class MoneriumConversionExecution + extends Model + implements MoneriumConversionExecutionAttributes +{ + declare id: string; + declare accountId: string; + declare eureInRaw: string; + declare usdcGrossRaw: string | null; + declare feeRaw: string | null; + declare usdcNetRaw: string | null; + declare destination: string; + declare txHash: string | null; + declare blockNumber: number | null; + declare status: MoneriumConversionExecutionStatus; + declare error: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumConversionExecution.init( + { + accountId: { + allowNull: false, + field: "account_id", + type: DataTypes.UUID + }, + blockNumber: { + allowNull: true, + field: "block_number", + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + destination: { + allowNull: false, + type: DataTypes.STRING(42) + }, + error: { + allowNull: true, + type: DataTypes.TEXT + }, + eureInRaw: { + allowNull: false, + field: "eure_in_raw", + type: DataTypes.DECIMAL(38, 0) + }, + feeRaw: { + allowNull: true, + field: "fee_raw", + type: DataTypes.DECIMAL(38, 0) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + status: { + allowNull: false, + defaultValue: MoneriumConversionExecutionStatus.Pending, + type: DataTypes.ENUM(...Object.values(MoneriumConversionExecutionStatus)) + }, + txHash: { + allowNull: true, + field: "tx_hash", + type: DataTypes.STRING(66) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + usdcGrossRaw: { + allowNull: true, + field: "usdc_gross_raw", + type: DataTypes.DECIMAL(38, 0) + }, + usdcNetRaw: { + allowNull: true, + field: "usdc_net_raw", + type: DataTypes.DECIMAL(38, 0) + } + }, + { + indexes: [{ fields: ["account_id", "status"] }], + modelName: "MoneriumConversionExecution", + sequelize, + tableName: "monerium_conversion_executions" + } +); + +export default MoneriumConversionExecution; diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts new file mode 100644 index 000000000..9bb4cc205 --- /dev/null +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -0,0 +1,136 @@ +import { DataTypes, Model, Op, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum MoneriumFiatDepositStatus { + Pending = "pending", + Minted = "minted", + Held = "held", + Returned = "returned" +} + +// One row per Monerium issue order (SEPA deposit → EURe mint). Identity/idempotency: +// monerium_order_id for accounting, (chain_id, tx_hash, log_index) for the on-chain +// mint. Status transitions are forward-only (plan §3, R06/R13). +export interface MoneriumFiatDepositAttributes { + id: string; + accountId: string; + moneriumOrderId: string; + amountRaw: string; // EURe base units (18 decimals), stringified + currency: string; + status: MoneriumFiatDepositStatus; + chainId: number | null; + txHash: string | null; + logIndex: number | null; + blockHash: string | null; + allocatedExecutionId: string | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumFiatDepositCreationAttributes = Optional< + MoneriumFiatDepositAttributes, + "id" | "status" | "chainId" | "txHash" | "logIndex" | "blockHash" | "allocatedExecutionId" | "createdAt" | "updatedAt" +>; + +class MoneriumFiatDeposit + extends Model + implements MoneriumFiatDepositAttributes +{ + declare id: string; + declare accountId: string; + declare moneriumOrderId: string; + declare amountRaw: string; + declare currency: string; + declare status: MoneriumFiatDepositStatus; + declare chainId: number | null; + declare txHash: string | null; + declare logIndex: number | null; + declare blockHash: string | null; + declare allocatedExecutionId: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumFiatDeposit.init( + { + accountId: { + allowNull: false, + field: "account_id", + type: DataTypes.UUID + }, + allocatedExecutionId: { + allowNull: true, + field: "allocated_execution_id", + type: DataTypes.UUID + }, + amountRaw: { + allowNull: false, + field: "amount_raw", + type: DataTypes.DECIMAL(38, 0) + }, + blockHash: { + allowNull: true, + field: "block_hash", + type: DataTypes.STRING(66) + }, + chainId: { + allowNull: true, + field: "chain_id", + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + currency: { + allowNull: false, + defaultValue: "eur", + type: DataTypes.STRING(8) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + logIndex: { + allowNull: true, + field: "log_index", + type: DataTypes.INTEGER + }, + moneriumOrderId: { + allowNull: false, + field: "monerium_order_id", + type: DataTypes.STRING(64), + unique: true + }, + status: { + allowNull: false, + defaultValue: MoneriumFiatDepositStatus.Pending, + type: DataTypes.ENUM(...Object.values(MoneriumFiatDepositStatus)) + }, + txHash: { + allowNull: true, + field: "tx_hash", + type: DataTypes.STRING(66) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { fields: ["account_id", "status"] }, + { fields: ["chain_id", "tx_hash", "log_index"], unique: true, where: { tx_hash: { [Op.ne]: null } } } + ], + modelName: "MoneriumFiatDeposit", + sequelize, + tableName: "monerium_fiat_deposits" + } +); + +export default MoneriumFiatDeposit; From eff320f68e2324c0d14068319930fb8a3efa1c26 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 12:06:44 +0200 Subject: [PATCH 11/59] Fix review F1: arm stranding marker against immutable floor; add code-review r1 report --- .../src/VortexForwarder.sol | 7 +- .../test/VortexForwarder.t.sol | 17 ++ docs/prd/monerium-b2b-code-review-r1.md | 157 ++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 docs/prd/monerium-b2b-code-review-r1.md diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index dfe545e87..7c08a4ea2 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -35,6 +35,7 @@ interface IVortexForwarderFactory { function globalPaused() external view returns (bool); function minSwapAmount() external view returns (uint256); function perSwapCap() external view returns (uint256); + function MIN_SWAP_FLOOR() external view returns (uint256); } /// @title VortexForwarder @@ -263,8 +264,12 @@ contract VortexForwarder { /// threshold (start time for TRIGGER_DELAY / SWEEP_DELAY), and clears the /// marker if the balance dropped back below it. function poke() external { + // Armed against the IMMUTABLE floor, not the guardian-tunable minSwapAmount: + // otherwise the guardian could raise minSwapAmount above a client's balance and + // a poke() would clear the marker, permanently disabling the un-pausable + // dead-man sweep (review r1, finding F1 — breach of plan invariant §2.3.5). uint256 balance = EURE.balanceOf(address(this)); - if (balance >= FACTORY.minSwapAmount()) { + if (balance >= FACTORY.MIN_SWAP_FLOOR()) { if (strandedSince == 0) { strandedSince = uint64(block.timestamp); emit Poked(strandedSince); diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index c1444cfab..0c803a203 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -361,6 +361,23 @@ contract VortexForwarderTest is Test { assertEq(fwd.strandedSince(), 0); } + /// Review r1 F1 regression: raising the tunable minSwapAmount above a stranded + /// balance must NOT let a poke() clear the marker — the dead-man sweep is armed + /// against the immutable MIN_SWAP_FLOOR and must survive any guardian action. + function test_guardianCannotDisarmDeadManSweep_byRaisingMinSwap() public { + _fund(500e18); + fwd.poke(); + assertGt(fwd.strandedSince(), 0); + + factory.setMinSwapAmount(1_000e18); // guardian raises threshold above balance + fwd.poke(); // anyone can poke; marker must survive + assertGt(fwd.strandedSince(), 0, "guardian disarmed the dead-man sweep"); + + skip(SWEEP_DELAY + 1); + fwd.sweepStrandedEure(); + assertEq(eure.balanceOf(fallbackAddr), 500e18); + } + function test_fallbackSweep_worksWhilePaused() public { _fund(500e18); fwd.setGuardianPaused(true); diff --git a/docs/prd/monerium-b2b-code-review-r1.md b/docs/prd/monerium-b2b-code-review-r1.md new file mode 100644 index 000000000..5f333af91 --- /dev/null +++ b/docs/prd/monerium-b2b-code-review-r1.md @@ -0,0 +1,157 @@ +# Monerium B2B Forwarder — Adversarial Code Review R1 + +**Reviewer:** adversarial security review (automated) +**Date:** 2026-07-17 +**Scope:** `contracts/monerium-forwarder/` (VortexForwarder + Factory + tests) against the B2B variant spec, implementation plan (invariants §2.3, dispositions §5), and deferred-decisions registry. +**Build/test:** `forge build` clean; `forge test --no-match-contract Fork` = 28 pass / 0 fail. Two PoC tests written during review (guardian stranding-reset, permissionless min-out execution) both passed, then removed. + +## Verdict + +The core non-custody property **holds on-chain**: there is no path for Vortex (attestor/guardian/keeper/deployer) to redirect converted USDC away from the client's `destination` or to a Vortex address, and `isValidSignature` cannot validate a Monerium redeem order. **No P0 found.** The strongest issue is a P1 liveness/griefing escalation: the guardian can indefinitely defeat the permissionless recovery backstop that the design deliberately kept guardian-proof. Several P2 notes and concrete test gaps follow. + +## Findings table + +| # | Sev | Title | Location | +|---|-----|-------|----------| +| F1 | **P1** | Guardian can indefinitely freeze the permissionless recovery paths via `minSwapAmount` raise + `poke()` (violates invariant §2.3.5; defeats un-pausable dead-man sweep) | `VortexForwarder.sol:265-276`, `293`, `328`; `VortexForwarderFactory.sol:96-98,120-124` | +| F2 | P2 | No `chainid`/domain separator in the EIP-1271 bound → cross-chain replay of the link attestation if the forwarder is ever deployed at the same address on another chain (EURe is multichain) | `VortexForwarder.sol:254` | +| F3 | P2 | Oracle staleness check omits round-completeness (`answeredInRound`/`roundId`) | `VortexForwarder.sol:337-339` | +| F4 | P2 | Permissionless `swapAndForward` is MEV-exposed (no private orderflow); executes at up to `SLIPPAGE_BPS` worse, sandwich-extractable | `VortexForwarder.sol:283-311` | +| F5 | P2 | Spec/code drift: variant §3.3 shows `abi.encode`, code uses `abi.encodePacked`; attestor signs the raw `bound` digest. Off-chain signer must match exactly or links silently fail | `VortexForwarder.sol:254` vs variant §3.3 | +| F6 | P2 | `swapAndForward` clears `strandedSince` even when a `perSwapCap` remainder ≥ `minSwapAmount` stays; permissionless path must re-poke + re-wait `TRIGGER_DELAY` per cap-chunk | `VortexForwarder.sol:328` | +| F7 | P2 | `RECOVERY_HASH` mechanism (disabled) only preserves non-custody **iff** Monerium's recovery message is parameterless — a hard constraint on how registry T1 may be resolved | `VortexForwarder.sol:237` | +| F8 | P2 | EURe compliance-transfer-revert behaviour unmodeled/undocumented: if Monerium freezes the forwarder's EURe, both `sweepStrandedEure` and client `sweep` revert (funds stuck) | `VortexForwarder.sol:349-356,379-384` | +| F9 | P2 (nit) | `_setMinSwapAmount` relies on unparenthesised `&&`/`||` precedence | `VortexForwarderFactory.sol:121` | +| F10 | P2 (nit) | Invariant §2.3.3 says "at most the two whitelisted hashes"; code accepts up to three hash constants (191, RAW, RECOVERY) | `VortexForwarder.sol:236-237` vs plan §2.3.3 | +| — | — | Test-suite gaps (enumerated in §"Test gaps") | — | + +--- + +## F1 (P1) — Guardian defeats the permissionless recovery backstop + +**Verified (PoC passed).** + +The dead-man sweep (`sweepStrandedEure`) and the permissionless `swapAndForward` are the design's guardian-proof liveness backstops. `sweepStrandedEure` is *deliberately not pause-gated* (`VortexForwarder.sol:345-347` comment: "recovery must work during incidents"), and implementation-plan invariant §2.3.5 states `strandedSince` is "monotonic per stranding episode; reset only by swap success/balance drop." Both guarantees are bypassable by the guardian. + +`poke()` (`:265-276`) arms/clears the marker against the **mutable, global** `FACTORY.minSwapAmount()`: + +```solidity +if (balance >= FACTORY.minSwapAmount()) { if (strandedSince == 0) strandedSince = ...; } +else if (strandedSince != 0) { strandedSince = 0; ... } // "balance drop" branch +``` + +The guardian can synthesise the "balance drop" by **raising the threshold above an existing balance** (`setMinSwapAmount`, `Factory.sol:96`, bounded only by `[MIN_SWAP_FLOOR, perSwapCap]` — floor is 1e18, so any real balance can be undercut). Sequence: + +1. Client has 100 EURe stranded; `strandedSince` armed at t0. Day 59 of the 60-day dead-man window. +2. Guardian `setMinSwapAmount(200e18)` (in bounds). +3. Anyone `poke()` → `balance(100) < min(200)` → `strandedSince = 0`. The 59 days are erased. +4. While the guardian holds `min > balance`, `poke()` can never re-arm, so `sweepStrandedEure` reverts `NotStranded` **forever**, and `swapAndForward` reverts `BelowMinimum` (`:293`) — no conversion either. + +Net: the guardian unilaterally re-creates the exact "stranded forever, no permissionless rescue" terminal state the dead-man sweep was built to eliminate. This is **strictly more power than pause** (pause leaves `sweepStrandedEure` callable). It is a liveness/griefing escalation, not theft — the client's own `fallbackAddress` can still `sweep()` — but it voids a stated security property for any client relying on the zero-touch/permissionless path, and because `minSwapAmount` is **global**, one raise freezes every client below the threshold at once. + +**Fix:** decouple the stranding marker from the guardian-tunable operational `minSwapAmount`. Options, cleanest first: +- Arm/clear `poke()` against the **immutable `MIN_SWAP_FLOOR`** instead of `FACTORY.minSwapAmount()`. The marker then only tracks "a non-trivial balance is parked," which the guardian cannot move. +- Or snapshot the arming threshold in storage when `strandedSince` is first set and clear only if balance falls below *that* snapshot. +- Or only clear on `balance == 0` / an actual observed decrease (track last-seen balance). + +Whichever is chosen, add an invariant/fuzz action that raises `minSwapAmount` mid-stranding and asserts `strandedSince` and the SWEEP_DELAY deadline are preserved. + +--- + +## F2 (P2) — Cross-chain replay of the link attestation + +`isValidSignature` binds only to `address(this)` (`:254`): `bound = keccak256(abi.encodePacked(address(this), hash))`. No `block.chainid`. The design doc claims "no cross-contract replay," but cross-**chain** replay is uncovered. Monerium EURe is deployed on multiple chains (Ethereum, Gnosis, Polygon, Arbitrum, …). Clone addresses are CREATE2-deterministic in the factory address + salt + init code; if the factory lands at the same address on two chains (common when teams want consistent addresses) and the same salt is used, the clone address collides, and **one attestor signature validates the Monerium link on both chains**. An attestation minted to link the mainnet forwarder would also validate a link of the identical Gnosis-side address (possibly to a different profile). + +**Status:** the fork test pins mainnet only; whether multichain deployment is intended is **unverified-hypothesis**. If it ever is, this rises to P1. It is cheap to close now. + +**Fix:** `bound = keccak256(abi.encodePacked(block.chainid, address(this), hash))` and mirror it in the off-chain attestor signer. + +--- + +## F3 (P2) — Oracle staleness omits round-completeness + +`_minOut` (`:337-339`) checks `answer <= 0` (handles negative and zero — good) and `updatedAt` age, but not `answeredInRound >= roundId` nor `roundId != 0`. On a Chainlink feed that carries a stale answer forward in a stuck round, `updatedAt` can still look fresh. `minOut` is the sole on-chain price protection, so a wrong-but-recent round weakens it within the `SLIPPAGE_BPS` band. Defense-in-depth. + +**Fix:** read `roundId`/`answeredInRound` from `latestRoundData` and require `answeredInRound >= roundId` and `roundId != 0`. + +--- + +## F4 (P2) — Permissionless swap path is MEV-exposed + +The keeper is planned to submit `swapAndForward` via private orderflow (plan §3). The permissionless branch (`:286-290`, anyone after `TRIGGER_DELAY`) is not, and executes with `amountOutMinimum = minOut` = worst allowed (1%). A searcher can call it in the public mempool and sandwich to the `SLIPPAGE_BPS` floor. A rando can also `poke()` immediately after any deposit to start the 24h clock (arming is intended/permissionless), then extract ≤1% once the keeper is ≥24h down. Within the documented "worst case within slippage bound," but it is a per-swap value leak specific to the fallback path and should be documented (and bounded by keeping `SLIPPAGE_BPS` tight / keeper promptness). + +**PoC confirmed:** a `rando` call after `TRIGGER_DELAY` with the router paying exactly `minOut` forwards the worst-allowed amount to `destination`. + +--- + +## F5 (P2) — Spec/code encoding drift + raw-digest signing + +Variant §3.3 pseudocode: `keccak256(abi.encode(address(this), LINK_HASH))`. Code (`:254`): `abi.encodePacked`. Packed encoding of `(address, bytes32)` is unambiguous (both fixed-length), so this is **safe**, but the two documents disagree, and the off-chain attestor MUST match the code exactly. Additionally, the contract `ecrecover`s over `bound` directly — the attestor signs the **raw 32-byte digest**, not an EIP-191 `personal_sign` of it (the test uses `vm.sign(pk, bound)` accordingly). A signer that uses `personal_sign` will produce non-validating links. + +**Fix:** reconcile the spec to `abi.encodePacked`, and document "attestor signs the raw `bound` digest (no EIP-191 prefix)". + +--- + +## F6 (P2) — `strandedSince` reset discards `perSwapCap` remainder + +`swapAndForward` unconditionally sets `strandedSince = 0` on success (`:328`). When `amountIn` is capped by `perSwapCap` and a remainder ≥ `minSwapAmount` stays, the marker is nonetheless cleared. The leftover then needs a fresh `poke()` + a full `TRIGGER_DELAY` before the permissionless path can move the next chunk, so a balance above the cap drains one cap-chunk per (poke + 24h) cycle if the keeper is absent. Liveness slowness, not a loss. + +**Fix (optional):** after the swap, if the remaining EURe balance ≥ `minSwapAmount`, leave `strandedSince` untouched (or reset to `block.timestamp`) so the remainder stays enrolled in the permissionless schedule. + +--- + +## F7 (P2) — `RECOVERY_HASH` non-custody constraint (registry T1 guardrail) + +`RECOVERY_HASH` is `bytes32(0)` (disabled) in all current configs, so **no live issue**. But the whole non-custody argument for a whitelisted hash depends on the message being **fixed/parameterless** — the same reason the link hash is safe and a redeem order is not. If T1 reveals that Monerium's recovery message contains variable fields (amount, IBAN, bank account), a single compile-time `RECOVERY_HASH` is either useless (hash varies per recovery) or unsafe if broadened to a scheme-match. This is not a finding against current code; it is a **hard constraint on resolving T1**: only enable `RECOVERY_HASH` if the recovery message is parameterless, otherwise the constrained-1271 safety property does not carry over. Worth stating explicitly in the registry T1 row. + +--- + +## F8 (P2) — EURe transfer-restriction assumption undocumented/untested + +EURe is a regulated e-money token with a compliance controller that can restrict transfers. The design handles the USDC (Circle) blacklist (atomic revert keeps funds as EURe). It does **not** document the case where the forwarder's *own* address (or `fallbackAddress`) is EURe-frozen: then `sweepStrandedEure` (`:353`) and client `sweep` (`:382`) both revert `TransferFailed` and funds are genuinely stuck. The mocks model no EURe transfer restriction, so this is neither documented nor tested. Out of contract scope to fix, but the "EURe always transferable for the forwarder/fallback" assumption should be stated (and covered in ops/terms). + +--- + +## F9 / F10 (P2 nits) + +- **F9:** `_setMinSwapAmount` (`Factory.sol:121`) — `value < MIN_SWAP_FLOOR || value > perSwapCap && perSwapCap != 0` is correct only because `&&` binds tighter than `||` (and the construction ordering sets cap after min while cap==0). Add parentheses: `value < MIN_SWAP_FLOOR || (perSwapCap != 0 && value > perSwapCap)`. +- **F10:** Invariant §2.3.3 wording says "at most the two whitelisted hashes"; the code accepts up to three constants (`LINK_HASH_191`, `LINK_HASH_RAW`, `RECOVERY_HASH`) = two *messages*, three *hashes*. Align the wording. + +--- + +## Spec-vs-code invariant check (plan §2.3 + §5) + +| Invariant / disposition | Status | +|---|---| +| §2.3.1 assets leave only via enumerated paths | **Holds.** Exit points: router pull (approved `amountIn`, `Overspend`-checked, reset to 0), USDC fee→`FEE_RECIPIENT` (≤`feeBps`, on swap delta only), USDC→`destination`, EURe→`fallbackAddress` (delayed), fallback `sweep`. Nothing else. | +| §2.3.2 no delegatecall/selfdestruct, CALL-only, value==0, reentrancy-guarded, safe-ERC20 | **Holds.** `nonReentrant` on all fund-movers; low-level calls carry no value; safe-transfer return-data handling present. | +| §2.3.3 1271 validates only whitelisted hashes, none authorize Vortex asset movement | **Holds** (wording nit F10). Redeem orders cannot validate. | +| §2.3.4 guardian delay-only, fallback client-only, non-upgradeable | **Mostly holds; F1 breaches the spirit** — guardian gains an unbounded freeze over permissionless recovery. Deploy-time `destination` trust is the documented S0 residual (guardian sets `destination` at `deployForwarder`; only fallback can change it after — contract correctly blocks post-deploy guardian changes). | +| §2.3.5 `strandedSince` not manipulable to skip/extend delays | **Breached — see F1.** Guardian can reset via a synthetic "balance drop." | +| R03 strandedSince marker | Implemented; F1 caveat. | +| R05 pause protective-only, never traps fallback | Holds — `sweep`/`sweepStrandedEure`/`setDestination` ungated by pause; invariant test covers fallback-sweep-while-paused. | +| R09 unsolicited tokens | Holds — fallback `sweep(token,to)`; unsolicited USDC forwarded to `destination` (fee applied to swap delta only, not to unsolicited USDC — correct). | + +**Factory/clone lifecycle:** `deployForwarder` clones + initializes atomically and is `onlyGuardian` (no initialize front-run — verified). `initialize` is `msg.sender == FACTORY` gated and one-shot; implementation self-bricks in its constructor (test-covered). CREATE2 address cannot be squatted by third parties (deployer = factory). `predictAddress` matches deployment (test-covered). Two-step guardian transfer is sound; `transferGuardian(0)` just cancels a pending transfer. Clones read `FACTORY.guardian()` dynamically, so a transfer re-points every clone with no per-clone migration. `implementation` is immutable (no swap). All good. + +## Test gaps (item 6) + +Named, missing tests (each maps to a finding or an untested branch): + +1. **Guardian raises `minSwapAmount` mid-stranding** — the exact F1 gap; no test toggles the threshold against an armed marker. +2. **Cross-chain replay** — untestable until a chainid is in the bound (F2); add once fixed. +3. **Oracle negative/zero answer** — `InvalidPrice` (`:338`) is never triggered; tests only exercise the stale (`updatedAt`) path. +4. **`RECOVERY_HASH` enabled branch** — every config uses `recoveryHash: bytes32(0)`, so the `isRecovery` path (`:237`) and its "recovery hash only, nothing else" property are entirely untested. +5. **Signature malleability / v** — the high-s check (`:251`) and `v ∉ {27,28}` (`:252`) rejections are untested; only wrong-signer and foreign-hash are covered. +6. **`signature.length != 65`** rejection (`:239`) untested. +7. **`guardianPaused` does NOT block `sweepStrandedEure`** — the "recovery during incident" property is asserted only for the fallback `sweep`, not the permissionless dead-man path. +8. **EURe transfer reverts** (compliance freeze) on the sweep/exit paths (F8) — mocks never fail a transfer. +9. **Fee rounding to zero** for dust swaps (`fee = usdcReceived*feeBps/BPS` floors to 0). +10. **`setFallbackAddress`** authority/gating (only `setDestination` is covered for fallback authority). +11. **Perpetual-remainder liveness** (F6): balance > `perSwapCap`, keeper absent — assert how many (poke + `TRIGGER_DELAY`) cycles are needed. + +## Notes that are NOT findings + +- Deploy-time `destination` correctness is the documented S0 provisioning trust (variant §4, plan R01); the contract cannot verify it and relies on the off-chain manifest + client confirmation. Correctly restricted post-deploy. +- Reentrancy: EURe/USDC are not ERC-777; all fund-movers are `nonReentrant`; a hooked token could reenter only ungated no-op functions (`poke`) with no harmful effect. The router-reentrancy guard is test-covered. +- Registry placeholders (fees, timings, T1–T5) are known-open and not reported as findings. From 5b4f2287391e0b6f6d4053e4f0efda4ecb2ec28e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 12:29:35 +0200 Subject: [PATCH 12/59] Monerium B2B backend: whitelabel client, attestor signer, HMAC webhook inbox, deposit processor --- .../controllers/monerium-b2b.controller.ts | 51 +++++ apps/api/src/api/routes/v1/index.ts | 7 + .../src/api/routes/v1/monerium-b2b.route.ts | 9 + .../services/monerium-b2b/attestor.test.ts | 74 ++++++ .../src/api/services/monerium-b2b/attestor.ts | 81 +++++++ .../monerium-b2b/deposit-processor.test.ts | 86 +++++++ .../monerium-b2b/deposit-processor.ts | 169 ++++++++++++++ .../api/services/monerium-b2b/webhook.test.ts | 182 +++++++++++++++ .../src/api/services/monerium-b2b/webhook.ts | 56 +++++ .../monerium-b2b/whitelabel-client.ts | 212 ++++++++++++++++++ apps/api/src/config/express.ts | 13 +- apps/api/src/config/vars.ts | 18 ++ apps/api/src/models/index.ts | 2 + .../src/models/moneriumWebhookEvent.model.ts | 66 ++++++ .../05-integrations/monerium-b2b.md | 54 +++++ .../security-spec/05-integrations/monerium.md | 2 + docs/security-spec/README.md | 1 + 17 files changed, 1082 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/api/controllers/monerium-b2b.controller.ts create mode 100644 apps/api/src/api/routes/v1/monerium-b2b.route.ts create mode 100644 apps/api/src/api/services/monerium-b2b/attestor.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/attestor.ts create mode 100644 apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/deposit-processor.ts create mode 100644 apps/api/src/api/services/monerium-b2b/webhook.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/webhook.ts create mode 100644 apps/api/src/api/services/monerium-b2b/whitelabel-client.ts create mode 100644 apps/api/src/models/moneriumWebhookEvent.model.ts create mode 100644 docs/security-spec/05-integrations/monerium-b2b.md diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts new file mode 100644 index 000000000..ed39f2a5f --- /dev/null +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -0,0 +1,51 @@ +import { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import { config } from "../../config/vars"; +import { APIError } from "../errors/api-error"; +import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { + deriveEventId, + MONERIUM_SIGNATURE_HEADER, + recordWebhookEvent, + verifyWebhookSignature +} from "../services/monerium-b2b/webhook"; + +/** + * POST /v1/monerium-b2b/webhook — durable-inbox webhook receiver (plan §3, R06). + * Order of operations is load-bearing: HMAC over the RAW bytes first, then persist the + * delivery (dedup on event id), and only then 200. Processing happens asynchronously + * after the response — Monerium retries are absorbed by the inbox dedup. + */ +export const handleWebhook = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const secret = config.moneriumB2b.webhookSecret; + if (!secret) { + throw new APIError({ message: "Monerium B2B webhook secret is not configured", status: httpStatus.SERVICE_UNAVAILABLE }); + } + + // Raw bytes captured by the body-parser verify hook in config/express.ts. + const rawBody = (req as Request & { rawBody?: Buffer }).rawBody; + if (!rawBody || !verifyWebhookSignature(rawBody, req.header(MONERIUM_SIGNATURE_HEADER), secret)) { + throw new APIError({ message: "Invalid webhook signature", status: httpStatus.UNAUTHORIZED }); + } + + let payload: unknown; + try { + payload = JSON.parse(rawBody.toString("utf8")); + } catch { + throw new APIError({ message: "Webhook payload is not valid JSON", status: httpStatus.BAD_REQUEST }); + } + + await recordWebhookEvent(deriveEventId(rawBody, payload), payload); + res.status(httpStatus.OK).json({ received: true }); + + setImmediate(() => { + processMoneriumWebhookInbox().catch(error => { + logger.error("monerium-b2b: async webhook inbox processing failed:", error); + }); + }); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 91f21bc09..99c7c4954 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -16,6 +16,7 @@ import fiatRoutes from "./fiat.route"; import maintenanceRoutes from "./maintenance.route"; import metricsRoutes from "./metrics.route"; import moneriumRoutes from "./monerium.route"; +import moneriumB2bRoutes from "./monerium-b2b.route"; import mykoboRoutes from "./mykobo.route"; import notificationsRoutes from "./notifications.route"; import onboardingRoutes from "./onboarding.route"; @@ -161,6 +162,12 @@ router.use("/mykobo", mykoboRoutes); */ router.use("/monerium", moneriumRoutes); +/** + * Monerium B2B whitelabel onramp. + * POST /v1/monerium-b2b/webhook — HMAC-authenticated durable-inbox webhook receiver. + */ +router.use("/monerium-b2b", moneriumB2bRoutes); + /** * POST v1/webhook * DELETE v1/webhook diff --git a/apps/api/src/api/routes/v1/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/monerium-b2b.route.ts new file mode 100644 index 000000000..8427f5a56 --- /dev/null +++ b/apps/api/src/api/routes/v1/monerium-b2b.route.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import * as moneriumB2bController from "../../controllers/monerium-b2b.controller"; + +const router = Router(); + +// Authenticated by HMAC signature over the raw body (no session/API-key auth). +router.post("/webhook", moneriumB2bController.handleWebhook); + +export default router; diff --git a/apps/api/src/api/services/monerium-b2b/attestor.test.ts b/apps/api/src/api/services/monerium-b2b/attestor.test.ts new file mode 100644 index 000000000..204be25bc --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/attestor.test.ts @@ -0,0 +1,74 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Address, encodePacked, hashMessage, Hex, hexToBigInt, hexToNumber, keccak256, recoverAddress, slice, stringToBytes } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { config } from "../../../config/vars"; +import { attestationBoundHash, attestorAddress, LINK_MESSAGE, linkMessageHash, signLinkAttestation } from "./attestor"; + +// Well-known test key (Foundry/Anvil account #0) — never a real attestor key. +const TEST_KEY: Hex = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const FORWARDER: Address = "0x1111111111111111111111111111111111111111"; +// Malleability bound from VortexForwarder.isValidSignature (secp256k1 n/2). +const HALF_ORDER = hexToBigInt("0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0"); + +let originalKey: string | undefined; + +beforeAll(() => { + originalKey = config.moneriumB2b.attestorPrivateKey; + config.moneriumB2b.attestorPrivateKey = TEST_KEY; +}); + +afterAll(() => { + config.moneriumB2b.attestorPrivateKey = originalKey; +}); + +describe("monerium-b2b attestor", () => { + it("derives LINK_HASH_191 and LINK_HASH_RAW exactly as the forwarder immutables", () => { + // The Solidity constant hardcodes "\x19Ethereum Signed Message:\n45" — LINK_MESSAGE must be 45 bytes. + expect(Buffer.byteLength(LINK_MESSAGE, "utf8")).toBe(45); + expect(linkMessageHash("raw")).toBe(keccak256(stringToBytes(LINK_MESSAGE))); + expect(linkMessageHash("eip191")).toBe(keccak256(stringToBytes(`\x19Ethereum Signed Message:\n45${LINK_MESSAGE}`))); + // Independent derivation via viem's EIP-191 implementation. + expect(linkMessageHash()).toBe(hashMessage(LINK_MESSAGE)); + }); + + it("binds the signature to the forwarder address exactly like isValidSignature", async () => { + const attestation = await signLinkAttestation(FORWARDER); + // bound = keccak256(abi.encodePacked(address(this), hash)) — the contract-side recomputation. + const expectedBound = keccak256(encodePacked(["address", "bytes32"], [FORWARDER, hashMessage(LINK_MESSAGE)])); + expect(attestation.boundHash).toBe(expectedBound); + expect(attestation.linkHash).toBe(hashMessage(LINK_MESSAGE)); + expect(attestation.message).toBe(LINK_MESSAGE); + // ecrecover(bound, v, r, s) must yield the ATTESTOR. + const signer = await recoverAddress({ hash: expectedBound, signature: attestation.signature }); + expect(signer).toBe(privateKeyToAccount(TEST_KEY).address); + expect(signer).toBe(attestorAddress()); + }); + + it("emits a 65-byte low-s signature with v in 27/28 for both hash variants", async () => { + for (const variant of ["eip191", "raw"] as const) { + const { boundHash, linkHash, signature } = await signLinkAttestation(FORWARDER, variant); + expect(signature.length).toBe(2 + 65 * 2); + const v = hexToNumber(slice(signature, 64, 65)); + expect([27, 28]).toContain(v); + const s = hexToBigInt(slice(signature, 32, 64)); + expect(s <= HALF_ORDER).toBe(true); + expect(boundHash).toBe(attestationBoundHash(FORWARDER, linkHash)); + expect(await recoverAddress({ hash: boundHash, signature })).toBe(privateKeyToAccount(TEST_KEY).address); + } + }); + + it("does not verify against a different forwarder address", async () => { + const { signature } = await signLinkAttestation(FORWARDER); + const otherBound = attestationBoundHash("0x2222222222222222222222222222222222222222", linkMessageHash()); + expect(await recoverAddress({ hash: otherBound, signature })).not.toBe(privateKeyToAccount(TEST_KEY).address); + }); + + it("refuses to sign when the attestor key is not configured", async () => { + config.moneriumB2b.attestorPrivateKey = undefined; + try { + await expect(signLinkAttestation(FORWARDER)).rejects.toThrow("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY"); + } finally { + config.moneriumB2b.attestorPrivateKey = TEST_KEY; + } + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/attestor.ts b/apps/api/src/api/services/monerium-b2b/attestor.ts new file mode 100644 index 000000000..3b15ffe08 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/attestor.ts @@ -0,0 +1,81 @@ +import { Address, encodePacked, Hex, keccak256, serializeSignature, stringToBytes } from "viem"; +import { privateKeyToAccount, sign } from "viem/accounts"; +import { config } from "../../../config/vars"; + +/** + * Attestor signature construction for VortexForwarder address linking. + * + * Mirrors contracts/monerium-forwarder/src/VortexForwarder.sol `isValidSignature`: + * the forwarder returns the EIP-1271 magic value iff the presented hash is one of the + * two whitelisted link hashes (EIP-191 personal-message hash or raw keccak of + * LINK_MESSAGE) and the 65-byte (r,s,v; v in 27/28; low-s) signature recovers the + * ATTESTOR over `keccak256(abi.encodePacked(address(this), hash))`. + * + * The attestor key authorizes NOTHING beyond this fixed link statement — it can never + * move funds (security-spec/05-integrations/monerium-b2b.md). + */ + +export const LINK_MESSAGE = "I hereby declare that I am the address owner."; + +export type LinkHashVariant = "eip191" | "raw"; + +export interface LinkAttestation { + boundHash: Hex; + linkHash: Hex; + message: string; + signature: Hex; +} + +/** LINK_HASH_191 / LINK_HASH_RAW from the forwarder immutables. */ +export function linkMessageHash(variant: LinkHashVariant = "eip191"): Hex { + if (variant === "raw") { + return keccak256(stringToBytes(LINK_MESSAGE)); + } + // hashMessage would work too; spelled out to match the Solidity constant byte for byte + // (LINK_MESSAGE is 45 bytes, hence the fixed "\x19Ethereum Signed Message:\n45"). + return keccak256(stringToBytes(`\x19Ethereum Signed Message:\n45${LINK_MESSAGE}`)); +} + +/** `bound = keccak256(abi.encodePacked(forwarderAddress, hash))` — the digest the attestor signs. */ +export function attestationBoundHash(forwarderAddress: Address, hash: Hex): Hex { + return keccak256(encodePacked(["address", "bytes32"], [forwarderAddress, hash])); +} + +function attestorPrivateKey(): Hex { + const key = config.moneriumB2b.attestorPrivateKey; + if (!key) { + // Never include key material in errors or logs. + throw new Error("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY is not configured"); + } + return key as Hex; +} + +export function attestorAddress(): Address { + return privateKeyToAccount(attestorPrivateKey()).address; +} + +/** + * Builds the attestor signature submitted with Monerium's POST /addresses link call. + * Signs the bound digest directly (no extra EIP-191 prefix — the contract ecrecovers + * the bound hash as-is). viem's signer emits canonical low-s signatures, so the + * forwarder's malleability check passes. + * + * Defaults to the EIP-191 variant (personal_sign is what Monerium documents for the + * link message). TODO(sandbox/T4): confirm during the G0 whitelabel spike which hash + * Monerium's EIP-1271 validation presents and pin the variant. + */ +export async function signLinkAttestation( + forwarderAddress: Address, + variant: LinkHashVariant = "eip191" +): Promise { + const linkHash = linkMessageHash(variant); + const boundHash = attestationBoundHash(forwarderAddress, linkHash); + const signature = await sign({ hash: boundHash, privateKey: attestorPrivateKey() }); + return { + boundHash, + linkHash, + message: LINK_MESSAGE, + // 65-byte r ‖ s ‖ v serialization with v in 27/28, as isValidSignature expects. + signature: serializeSignature(signature) + }; +} diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts new file mode 100644 index 000000000..305f0b476 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { isForwardTransition, mapOrderStateToDepositStatus, parseOrderEvent } from "./deposit-processor"; + +const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; + +describe("forward-only deposit status transitions", () => { + it("allows pending to progress to minted, held, or returned", () => { + expect(isForwardTransition(Pending, Minted)).toBe(true); + expect(isForwardTransition(Pending, Held)).toBe(true); + expect(isForwardTransition(Pending, Returned)).toBe(true); + }); + + it("allows a hold to resolve to minted or returned but never back to pending", () => { + expect(isForwardTransition(Held, Minted)).toBe(true); + expect(isForwardTransition(Held, Returned)).toBe(true); + expect(isForwardTransition(Held, Pending)).toBe(false); + }); + + it("treats minted and returned as terminal", () => { + for (const to of [Pending, Held, Returned]) { + expect(isForwardTransition(Minted, to)).toBe(false); + } + for (const to of [Pending, Held, Minted]) { + expect(isForwardTransition(Returned, to)).toBe(false); + } + }); + + it("never allows a self-transition write", () => { + for (const status of [Pending, Held, Minted, Returned]) { + expect(isForwardTransition(status, status)).toBe(false); + } + }); +}); + +describe("mapOrderStateToDepositStatus", () => { + it("maps documented Monerium order states", () => { + expect(mapOrderStateToDepositStatus("placed")).toBe(Pending); + expect(mapOrderStateToDepositStatus("pending")).toBe(Pending); + expect(mapOrderStateToDepositStatus("processed")).toBe(Minted); + expect(mapOrderStateToDepositStatus("rejected")).toBe(Returned); + expect(mapOrderStateToDepositStatus("held")).toBe(Held); + }); + + it("normalizes case and whitespace, and returns null for unknown states", () => { + expect(mapOrderStateToDepositStatus(" Processed ")).toBe(Minted); + expect(mapOrderStateToDepositStatus("something-new")).toBeNull(); + expect(mapOrderStateToDepositStatus("")).toBeNull(); + }); +}); + +describe("parseOrderEvent", () => { + const validPayload = { + data: { + address: "0x1111111111111111111111111111111111111111", + amount: "100.5", + currency: "eur", + id: "order-1", + kind: "issue", + meta: { txHash: "0xabc" }, + state: "processed" + }, + timestamp: "2026-07-17T00:00:00Z", + type: "order.updated" + }; + + it("extracts the issue-order fields", () => { + expect(parseOrderEvent(validPayload)).toEqual({ + amount: "100.5", + currency: "eur", + forwarderAddress: "0x1111111111111111111111111111111111111111", + orderId: "order-1", + state: "processed", + txHash: "0xabc" + }); + }); + + it("ignores redeem orders, non-order events, and malformed payloads", () => { + expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, kind: "redeem" } })).toBeNull(); + expect(parseOrderEvent({ ...validPayload, type: "profile.updated" })).toBeNull(); + expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, id: undefined } })).toBeNull(); + expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, amount: 100.5 } })).toBeNull(); + expect(parseOrderEvent(null)).toBeNull(); + expect(parseOrderEvent("junk")).toBeNull(); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts new file mode 100644 index 000000000..68bf4b687 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -0,0 +1,169 @@ +import { parseUnits } from "viem"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; + +/** + * Asynchronous processor for the durable webhook inbox (plan §3): upserts + * MoneriumFiatDeposit rows by monerium_order_id with forward-only status transitions, + * serialized per forwarder address via a Postgres transaction-scoped advisory lock. + */ + +const EURE_DECIMALS = 18; + +// Forward-only lattice (plan §3): pending → minted/held/returned; a compliance hold can +// still resolve to minted or returned; minted/returned are terminal. +const FORWARD_TRANSITIONS: Record = { + [MoneriumFiatDepositStatus.Pending]: [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Held, + MoneriumFiatDepositStatus.Returned + ], + [MoneriumFiatDepositStatus.Held]: [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Returned], + [MoneriumFiatDepositStatus.Minted]: [], + [MoneriumFiatDepositStatus.Returned]: [] +}; + +export function isForwardTransition(from: MoneriumFiatDepositStatus, to: MoneriumFiatDepositStatus): boolean { + return FORWARD_TRANSITIONS[from].includes(to); +} + +/** + * Maps a Monerium issue-order state to a deposit status, or null for states we do not + * (yet) recognize. TODO(sandbox): pin the exact upstream state vocabulary — "processed" + * and "rejected" are documented; the compliance-hold value is a sandbox-verification item. + */ +export function mapOrderStateToDepositStatus(state: string): MoneriumFiatDepositStatus | null { + switch (state.trim().toLowerCase()) { + case "placed": + case "pending": + return MoneriumFiatDepositStatus.Pending; + case "processed": + return MoneriumFiatDepositStatus.Minted; + case "held": + case "on_hold": + return MoneriumFiatDepositStatus.Held; + case "rejected": + case "returned": + return MoneriumFiatDepositStatus.Returned; + default: + return null; + } +} + +interface ParsedOrderEvent { + orderId: string; + forwarderAddress: string; + amount: string; + currency: string; + state: string; + txHash: string | null; +} + +/** + * Extracts the issue-order fields this processor acts on from a delivery payload + * (documented shape: { type, timestamp, data }). Returns null for deliveries that are + * not EURe issue orders — those are acked and marked processed without a deposit write. + */ +export function parseOrderEvent(payload: unknown): ParsedOrderEvent | null { + const envelope = payload as { data?: unknown; type?: unknown } | null; + if (!envelope || typeof envelope !== "object") return null; + if (typeof envelope.type === "string" && !envelope.type.startsWith("order")) return null; + const data = (envelope.data ?? envelope) as Record; + if (typeof data.kind === "string" && data.kind !== "issue") return null; + if (typeof data.id !== "string" || typeof data.address !== "string" || typeof data.amount !== "string") return null; + const state = typeof data.state === "string" ? data.state : ""; + const meta = (data.meta ?? {}) as Record; + return { + amount: data.amount, + currency: typeof data.currency === "string" ? data.currency : "eur", + forwarderAddress: data.address, + orderId: data.id, + state, + txHash: typeof meta.txHash === "string" ? meta.txHash : null + }; +} + +async function processInboxRow(row: MoneriumWebhookEvent): Promise { + const event = parseOrderEvent(row.payload); + if (!event) { + await row.update({ processedAt: new Date() }); + return; + } + + const forwarderKey = event.forwarderAddress.toLowerCase(); + await sequelize.transaction(async transaction => { + // Per-forwarder serialization (plan §3): the advisory lock is transaction-scoped, so + // concurrent processors (multiple instances, webhook-triggered + scheduled runs) + // apply events for one account strictly one at a time. + await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { + replacements: { key: `monerium-b2b:${forwarderKey}` }, + transaction + }); + + const account = await MoneriumAccount.findOne({ + transaction, + where: sequelize.where(sequelize.fn("lower", sequelize.col("forwarder_address")), forwarderKey) + }); + if (!account) { + logger.warn(`monerium-b2b: webhook order ${event.orderId} references unknown forwarder address, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + + const targetStatus = mapOrderStateToDepositStatus(event.state); + const existing = await MoneriumFiatDeposit.findOne({ transaction, where: { moneriumOrderId: event.orderId } }); + if (!existing) { + await MoneriumFiatDeposit.create( + { + accountId: account.id, + amountRaw: parseUnits(event.amount, EURE_DECIMALS).toString(), + currency: event.currency, + moneriumOrderId: event.orderId, + status: targetStatus ?? MoneriumFiatDepositStatus.Pending, + txHash: event.txHash + }, + { transaction } + ); + } else if (targetStatus && targetStatus !== existing.status) { + if (isForwardTransition(existing.status, targetStatus)) { + await existing.update( + { status: targetStatus, ...(event.txHash && !existing.txHash ? { txHash: event.txHash } : {}) }, + { + transaction + } + ); + } else { + logger.warn( + `monerium-b2b: ignoring backward status transition ${existing.status} -> ${targetStatus} for order ${event.orderId}` + ); + } + } + + await row.update({ processedAt: new Date() }, { transaction }); + }); +} + +/** + * Processes all unprocessed inbox rows oldest-first. A row that fails stays + * unprocessed and is retried on the next run; rows we recognize but choose to skip are + * marked processed so they cannot poison the loop. + */ +export async function processMoneriumWebhookInbox(): Promise { + const rows = await MoneriumWebhookEvent.findAll({ + order: [["created_at", "ASC"]], + where: { processedAt: null } + }); + let processed = 0; + for (const row of rows) { + try { + await processInboxRow(row); + processed += 1; + } catch (error) { + logger.error(`monerium-b2b: failed to process webhook inbox row ${row.eventId}:`, error); + } + } + return processed; +} diff --git a/apps/api/src/api/services/monerium-b2b/webhook.test.ts b/apps/api/src/api/services/monerium-b2b/webhook.test.ts new file mode 100644 index 000000000..90748d80b --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/webhook.test.ts @@ -0,0 +1,182 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import crypto from "crypto"; +// Value copies taken before the mock.module calls below; restored in afterAll because bun +// module mocks are process-wide and would poison later test files. +import * as webhookEventNamespace from "../../../models/moneriumWebhookEvent.model"; +import * as depositProcessorNamespace from "./deposit-processor"; + +const webhookEventReal = { ...webhookEventNamespace }; +const depositProcessorReal = { ...depositProcessorNamespace }; + +const callOrder: string[] = []; +const bulkCreate = mock(async (_rows: unknown, _options: unknown) => { + callOrder.push("insert"); + return []; +}); +const processInbox = mock(async () => 0); + +mock.module("../../../models/moneriumWebhookEvent.model", () => ({ + default: { bulkCreate } +})); +mock.module("./deposit-processor", () => ({ + ...depositProcessorReal, + processMoneriumWebhookInbox: processInbox +})); + +let webhook: typeof import("./webhook"); +let controller: typeof import("../../controllers/monerium-b2b.controller"); +let config: typeof import("../../../config/vars").config; + +const SECRET = "test-webhook-secret"; + +function sign(rawBody: Buffer, secret: string, encoding: "base64" | "hex" = "hex"): string { + return crypto.createHmac("sha256", secret).update(rawBody).digest(encoding); +} + +function mockRequest(rawBody: Buffer | undefined, signature: string | undefined): never { + return { + header: (name: string) => (name.toLowerCase() === "webhook-signature" ? signature : undefined), + rawBody + } as never; +} + +function mockResponse(): { json: ReturnType; status: ReturnType } { + const res = { + json: mock((_value: unknown) => { + callOrder.push("respond"); + return res; + }), + status: mock((_code: number) => res) + }; + return res; +} + +async function flushSetImmediate(): Promise { + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); +} + +beforeAll(async () => { + webhook = await import("./webhook"); + controller = await import("../../controllers/monerium-b2b.controller"); + ({ config } = await import("../../../config/vars")); +}); + +beforeEach(() => { + config.moneriumB2b.webhookSecret = SECRET; + bulkCreate.mockClear(); + processInbox.mockClear(); + callOrder.length = 0; +}); + +afterAll(() => { + mock.module("../../../models/moneriumWebhookEvent.model", () => ({ ...webhookEventReal })); + mock.module("./deposit-processor", () => ({ ...depositProcessorReal })); + mock.restore(); +}); + +describe("verifyWebhookSignature", () => { + const body = Buffer.from(JSON.stringify({ data: { id: "order-1" }, type: "order.updated" }), "utf8"); + + it("accepts a correct HMAC-SHA256 in hex or base64 encoding", () => { + expect(webhook.verifyWebhookSignature(body, sign(body, SECRET, "hex"), SECRET)).toBe(true); + expect(webhook.verifyWebhookSignature(body, sign(body, SECRET, "base64"), SECRET)).toBe(true); + }); + + it("rejects a wrong secret, tampered bytes, missing header, and empty secret", () => { + expect(webhook.verifyWebhookSignature(body, sign(body, "other-secret"), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(Buffer.concat([body, Buffer.from(" ")]), sign(body, SECRET), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, undefined, SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, "", SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, sign(body, SECRET), "")).toBe(false); + expect(webhook.verifyWebhookSignature(body, "not-a-mac", SECRET)).toBe(false); + }); +}); + +describe("deriveEventId", () => { + it("uses a top-level payload id when present", () => { + expect(webhook.deriveEventId(Buffer.from("{}"), { id: "evt-1" })).toBe("evt-1"); + }); + + it("falls back to a digest of the raw bytes, stable across redeliveries", () => { + const raw = Buffer.from('{"type":"order.updated"}'); + const first = webhook.deriveEventId(raw, { type: "order.updated" }); + expect(first).toStartWith("sha256:"); + expect(webhook.deriveEventId(Buffer.from(raw), { type: "order.updated" })).toBe(first); + expect(webhook.deriveEventId(Buffer.from('{"type":"order.created"}'), { type: "order.created" })).not.toBe(first); + }); +}); + +describe("recordWebhookEvent", () => { + it("inserts with on-conflict-do-nothing dedup semantics", async () => { + await webhook.recordWebhookEvent("evt-1", { type: "order.updated" }); + expect(bulkCreate).toHaveBeenCalledTimes(1); + const [rows, options] = bulkCreate.mock.calls[0] as [unknown, unknown]; + expect(rows).toEqual([{ eventId: "evt-1", payload: { type: "order.updated" } }]); + expect(options).toEqual({ ignoreDuplicates: true }); + }); +}); + +describe("POST /v1/monerium-b2b/webhook controller", () => { + const payload = { data: { id: "order-1" }, timestamp: "2026-07-17T00:00:00Z", type: "order.updated" }; + const rawBody = Buffer.from(JSON.stringify(payload), "utf8"); + + it("persists the delivery durably before responding 200 and processes async", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(bulkCreate).toHaveBeenCalledTimes(1); + // Durable insert strictly precedes the 200 (R06). + expect(callOrder).toEqual(["insert", "respond"]); + + await flushSetImmediate(); + expect(processInbox).toHaveBeenCalledTimes(1); + }); + + it("rejects an invalid signature with 401 and never touches the inbox", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, "wrong-secret")), res as never, next as never); + + expect(next).toHaveBeenCalledTimes(1); + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 401 }); + expect(bulkCreate).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects when the raw body was not captured", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(undefined, sign(rawBody, SECRET)), res as never, next as never); + + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 401 }); + expect(bulkCreate).not.toHaveBeenCalled(); + }); + + it("responds 503 when the webhook secret is not configured", async () => { + config.moneriumB2b.webhookSecret = ""; + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 503 }); + expect(bulkCreate).not.toHaveBeenCalled(); + }); + + it("acks a redelivery with 200 (insert is a dedup no-op)", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenNthCalledWith(2, 200); + // Same event id both times — the unique index makes the second insert a no-op. + const firstRows = bulkCreate.mock.calls[0]?.[0] as Array<{ eventId: string }>; + const secondRows = bulkCreate.mock.calls[1]?.[0] as Array<{ eventId: string }>; + expect(firstRows[0].eventId).toBe(secondRows[0].eventId); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/webhook.ts b/apps/api/src/api/services/monerium-b2b/webhook.ts new file mode 100644 index 000000000..b600f451f --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/webhook.ts @@ -0,0 +1,56 @@ +import crypto from "crypto"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; + +/** + * Monerium B2B webhook authentication + durable inbox (plan §3, R06). + * + * Monerium docs: each notification carries a `webhook-signature` header containing the + * HMAC-SHA256 of the minified JSON payload under the shared secret. We verify over the + * RAW request bytes exactly as delivered (never a re-serialization) with a + * constant-time compare. + * + * TODO(sandbox): the docs do not state the digest encoding — hex and base64 are both + * accepted here until the G0 sandbox spike pins it (both encode the same secret MAC, + * so accepting either does not widen the trust surface). + */ + +export const MONERIUM_SIGNATURE_HEADER = "webhook-signature"; + +function constantTimeEquals(a: Buffer, b: Buffer): boolean { + if (a.length !== b.length) { + // Compare against self to keep timing independent of the mismatch position. + crypto.timingSafeEqual(a, a); + return false; + } + return crypto.timingSafeEqual(a, b); +} + +export function verifyWebhookSignature(rawBody: Buffer, signatureHeader: string | undefined, secret: string): boolean { + if (!secret || !signatureHeader) return false; + const mac = crypto.createHmac("sha256", secret).update(rawBody).digest(); + const provided = Buffer.from(signatureHeader.trim(), "utf8"); + const hexMatch = constantTimeEquals(provided, Buffer.from(mac.toString("hex"), "utf8")); + const base64Match = constantTimeEquals(provided, Buffer.from(mac.toString("base64"), "utf8")); + return hexMatch || base64Match; +} + +/** + * Dedup identity for a delivery. Monerium's documented payload (`type`, `timestamp`, + * `data`) carries no delivery id, so redeliveries are identified by the digest of the + * raw bytes; a top-level `id` is honored if the payload ever grows one. + * TODO(sandbox): confirm whether deliveries carry an id field or header. + */ +export function deriveEventId(rawBody: Buffer, payload: unknown): string { + const id = (payload as { id?: unknown } | null)?.id; + if (typeof id === "string" && id.length > 0 && id.length <= 128) return id; + return `sha256:${crypto.createHash("sha256").update(rawBody).digest("hex")}`; +} + +/** + * Durably persists a delivery BEFORE the webhook responds 200. `ignoreDuplicates` + * compiles to `ON CONFLICT DO NOTHING` on the unique event_id — a redelivery is a + * silent no-op, and the caller still acks with 200. + */ +export async function recordWebhookEvent(eventId: string, payload: unknown): Promise { + await MoneriumWebhookEvent.bulkCreate([{ eventId, payload }], { ignoreDuplicates: true }); +} diff --git a/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts b/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts new file mode 100644 index 000000000..9a9074d37 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts @@ -0,0 +1,212 @@ +import httpStatus from "http-status"; +import { config } from "../../../config/vars"; +import { APIError } from "../../errors/api-error"; +import { LINK_MESSAGE } from "./attestor"; + +/** + * Thin Monerium whitelabel API client (client-credentials flow, sandbox-first). + * Endpoints per docs.monerium.com/api: POST /auth/token, POST /profiles, + * POST /addresses, POST /ibans, GET /ibans, GET /orders/{orderId}. + * Only the endpoints the B2B onramp needs — no speculative surface. + */ + +const FETCH_TIMEOUT_MS = 10_000; +const TOKEN_EXPIRY_SKEW_MS = 30_000; +const API_V2_ACCEPT = "application/vnd.monerium.api-v2+json"; + +export type MoneriumProfileKind = "personal" | "corporate"; + +export interface WhitelabelProfile { + id: string; + kind: MoneriumProfileKind; + state: string; +} + +export interface WhitelabelIban { + iban: string; + bic?: string; + address: string; + chain: string; +} + +export interface WhitelabelOrder { + id: string; + state: string; + kind?: string; + amount?: string; + currency?: string; + address?: string; +} + +interface CachedToken { + accessToken: string; + expiresAt: number; +} + +let cachedToken: CachedToken | null = null; +let tokenRequest: Promise | null = null; + +function upstreamError(_internalMessage: string): APIError { + return new APIError({ message: "Monerium request failed", status: httpStatus.BAD_GATEWAY }); +} + +async function fetchToken(): Promise { + const { apiUrl, clientId, clientSecret } = config.moneriumB2b; + let response: Response; + try { + response = await fetch(`${apiUrl}/auth/token`, { + body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: "client_credentials" }), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }); + } catch { + throw upstreamError("Monerium token request timed out or failed"); + } + if (!response.ok) { + throw upstreamError(`Monerium token endpoint returned HTTP ${response.status}`); + } + const token = (await response.json().catch(() => null)) as { access_token?: unknown; expires_in?: unknown } | null; + if (!token || typeof token.access_token !== "string" || typeof token.expires_in !== "number" || token.expires_in <= 0) { + throw upstreamError("Monerium returned an invalid token response"); + } + return { accessToken: token.access_token, expiresAt: Date.now() + token.expires_in * 1000 }; +} + +async function getAccessToken(forceRefresh = false): Promise { + if (!config.moneriumB2b.clientId || !config.moneriumB2b.clientSecret) { + throw new APIError({ + message: "Monerium B2B client credentials are not configured", + status: httpStatus.SERVICE_UNAVAILABLE + }); + } + if (!forceRefresh && cachedToken && cachedToken.expiresAt - TOKEN_EXPIRY_SKEW_MS > Date.now()) { + return cachedToken.accessToken; + } + if (!tokenRequest) { + tokenRequest = fetchToken() + .then(token => { + cachedToken = token; + return token; + }) + .finally(() => { + tokenRequest = null; + }); + } + return (await tokenRequest).accessToken; +} + +async function request(path: string, init: { body?: unknown; method: "GET" | "POST" }): Promise { + const doFetch = async (accessToken: string): Promise => { + try { + return await fetch(`${config.moneriumB2b.apiUrl}${path}`, { + body: init.body === undefined ? undefined : JSON.stringify(init.body), + headers: { + Accept: API_V2_ACCEPT, + Authorization: `Bearer ${accessToken}`, + ...(init.body === undefined ? {} : { "Content-Type": "application/json" }) + }, + method: init.method, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }); + } catch { + throw upstreamError("Monerium request timed out or failed"); + } + }; + + let response = await doFetch(await getAccessToken()); + if (response.status === 401) { + // Client-credentials tokens are not refreshable — a 401 means expired/revoked; mint a new one once. + response = await doFetch(await getAccessToken(true)); + } + if (!response.ok) { + throw upstreamError(`Monerium returned HTTP ${response.status} for ${init.method} ${path}`); + } + if (response.status === 204) return null; + try { + return await response.json(); + } catch { + throw upstreamError("Monerium returned invalid JSON"); + } +} + +/** POST /profiles — partner-created profile (whitelabel). */ +export async function createProfile(kind: MoneriumProfileKind): Promise { + const profile = (await request("/profiles", { body: { kind }, method: "POST" })) as Record; + if (!profile || typeof profile.id !== "string" || profile.kind !== kind || typeof profile.state !== "string") { + throw upstreamError("Monerium returned an invalid profile"); + } + return { id: profile.id, kind, state: profile.state }; +} + +/** + * Corporate KYB submission for a whitelabel profile. + * + * Deliberately a stub: the KYB mechanism under whitelabel (Monerium-run verification vs + * KYC-reliance) is pending the MSA negotiation — deferred-decisions registry item T3. + * Do not build a speculative payload shape against it. + */ +export async function submitKybData(_profileId: string, _data: unknown): Promise { + throw new APIError({ + message: "Monerium B2B KYB submission is not implemented (pending registry item T3)", + status: httpStatus.NOT_IMPLEMENTED + }); +} + +/** + * POST /addresses — links a forwarder address to a profile using the attestor's + * EIP-1271-verifiable signature over the fixed link message (see ./attestor.ts). + */ +export async function linkAddress(profileId: string, address: string, chain: string, signature: string): Promise { + return request("/addresses", { + body: { address, chain, message: LINK_MESSAGE, profile: profileId, signature }, + method: "POST" + }); +} + +/** POST /ibans — requests IBAN issuance for a linked address. */ +export async function requestIban(address: string, chain: string): Promise { + return request("/ibans", { body: { address, chain }, method: "POST" }); +} + +/** GET /ibans — returns the IBAN issued for an address, or null if none yet. */ +export async function getIbanForAddress(address: string): Promise { + const response = (await request("/ibans", { method: "GET" })) as { ibans?: unknown } | unknown[] | null; + const entries = Array.isArray(response) ? response : Array.isArray(response?.ibans) ? response.ibans : []; + for (const entry of entries as Record[]) { + if ( + typeof entry?.iban === "string" && + typeof entry.address === "string" && + entry.address.toLowerCase() === address.toLowerCase() + ) { + return { + address: entry.address, + bic: typeof entry.bic === "string" ? entry.bic : undefined, + chain: typeof entry.chain === "string" ? entry.chain : "", + iban: entry.iban + }; + } + } + return null; +} + +/** GET /orders/{orderId} */ +export async function getOrder(orderId: string): Promise { + const order = (await request(`/orders/${encodeURIComponent(orderId)}`, { method: "GET" })) as Record; + if (!order || typeof order.id !== "string" || typeof order.state !== "string") { + throw upstreamError("Monerium returned an invalid order"); + } + return { + address: typeof order.address === "string" ? order.address : undefined, + amount: typeof order.amount === "string" ? order.amount : undefined, + currency: typeof order.currency === "string" ? order.currency : undefined, + id: order.id, + kind: typeof order.kind === "string" ? order.kind : undefined, + state: order.state + }; +} + +export function resetMoneriumB2bClientForTests(): void { + cachedToken = null; + tokenRequest = null; +} diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index ac15fcb1a..e1b94875f 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -78,7 +78,18 @@ app.use(requestContext); app.use(morgan(logs)); // parse body params and attach them to req.body -app.use(bodyParser.json({ limit: REQUEST_BODY_LIMIT })); +app.use( + bodyParser.json({ + limit: REQUEST_BODY_LIMIT, + // The Monerium B2B webhook HMAC is computed over the RAW request bytes; capture + // them before JSON parsing for that route only (monerium-b2b.controller). + verify: (req, _res, buf) => { + if (req.url?.startsWith("/v1/monerium-b2b/webhook")) { + (req as typeof req & { rawBody?: Buffer }).rawBody = buf; + } + } + }) +); app.use(bodyParser.urlencoded({ extended: true, limit: REQUEST_BODY_LIMIT })); // gzip compression diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 5b3886069..43b161ed1 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -167,6 +167,15 @@ interface Config { clientId: string; redirectUri: string; }; + // B2B whitelabel onramp integration (docs/prd/monerium-b2b-implementation-plan.md §3). + // Separate credential set from the legacy consumer OAuth integration above. + moneriumB2b: { + apiUrl: string; + attestorPrivateKey: string | undefined; + clientId: string; + clientSecret: string; + webhookSecret: string; + }; subscanApiKey: string | undefined; vortexFeePenPercentage: number; @@ -232,6 +241,15 @@ export const config: Config = { clientId: process.env.MONERIUM_CLIENT_ID || "", redirectUri: process.env.MONERIUM_REDIRECT_URI || "http://localhost:5174/monerium/callback" }, + moneriumB2b: { + // Sandbox by default: the B2B build is developed against api.monerium.dev until the + // MSA is signed (locked scope decision 2026-07-17). + apiUrl: process.env.MONERIUM_B2B_API_URL || "https://api.monerium.dev", + attestorPrivateKey: process.env.MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, + clientId: process.env.MONERIUM_B2B_CLIENT_ID || "", + clientSecret: process.env.MONERIUM_B2B_CLIENT_SECRET || "", + webhookSecret: process.env.MONERIUM_B2B_WEBHOOK_SECRET || "" + }, mykobo: { feeFallback: readMykoboFeeFallback() }, diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index b3b96eb1b..7f2cebbce 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -8,6 +8,7 @@ import MaintenanceSchedule from "./maintenanceSchedule.model"; import MoneriumAccount from "./moneriumAccount.model"; import MoneriumConversionExecution from "./moneriumConversionExecution.model"; import MoneriumFiatDeposit from "./moneriumFiatDeposit.model"; +import MoneriumWebhookEvent from "./moneriumWebhookEvent.model"; import Notification from "./notification.model"; import NotificationPreference from "./notificationPreference.model"; import Partner from "./partner.model"; @@ -110,6 +111,7 @@ const models = { MoneriumAccount, MoneriumConversionExecution, MoneriumFiatDeposit, + MoneriumWebhookEvent, Notification, NotificationPreference, Partner, diff --git a/apps/api/src/models/moneriumWebhookEvent.model.ts b/apps/api/src/models/moneriumWebhookEvent.model.ts new file mode 100644 index 000000000..fa1cc2574 --- /dev/null +++ b/apps/api/src/models/moneriumWebhookEvent.model.ts @@ -0,0 +1,66 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// Durable inbox for Monerium B2B webhook deliveries (plan §3, R06): rows are inserted +// (dedup on event_id, on conflict do nothing) BEFORE the webhook returns 200 and +// processed asynchronously afterwards. Table created by migration 051. +export interface MoneriumWebhookEventAttributes { + id: string; + eventId: string; + payload: unknown; + processedAt: Date | null; + createdAt: Date; +} + +type MoneriumWebhookEventCreationAttributes = Optional; + +class MoneriumWebhookEvent + extends Model + implements MoneriumWebhookEventAttributes +{ + declare id: string; + declare eventId: string; + declare payload: unknown; + declare processedAt: Date | null; + declare createdAt: Date; +} + +MoneriumWebhookEvent.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + eventId: { + allowNull: false, + field: "event_id", + type: DataTypes.STRING(128), + unique: true + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + payload: { + allowNull: false, + type: DataTypes.JSONB + }, + processedAt: { + allowNull: true, + field: "processed_at", + type: DataTypes.DATE + } + }, + { + modelName: "MoneriumWebhookEvent", + sequelize, + tableName: "monerium_webhook_events", + // The inbox table has no updated_at column (append + processed_at flip only). + updatedAt: false + } +); + +export default MoneriumWebhookEvent; diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md new file mode 100644 index 000000000..7630f2fc3 --- /dev/null +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -0,0 +1,54 @@ +# Monerium B2B Whitelabel Onramp + +## What This Does + +The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.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, and the deposit processor. It is deliberately NOT part of the one-shot ramp state machine — accounts are persistent and repeatedly funded. + +**Provider type:** on-ramp (EUR → USDC) +**Fiat currencies:** EUR +**Chains involved:** Ethereum (forwarder contracts, EURe/USDC) +**Modules:** `monerium-b2b/whitelabel-client.ts`, `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`) +**API auth method:** OAuth client credentials (`MONERIUM_B2B_CLIENT_ID`/`MONERIUM_B2B_CLIENT_SECRET`) against `MONERIUM_B2B_API_URL` (sandbox `api.monerium.dev` by default); inbound webhooks authenticated by HMAC-SHA256 (`MONERIUM_B2B_WEBHOOK_SECRET`) + +## Security Invariants + +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(forwarderAddress, linkHash))` where `linkHash` is one of the two fixed hashes of `"I hereby declare that I am the address owner."` (EIP-191 personal hash or raw keccak). `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 over raw bytes, constant-time** — the `webhook-signature` header is verified as HMAC-SHA256 of the RAW request bytes (captured by a body-parser `verify` hook scoped to this route, never a re-serialization of parsed JSON) using `crypto.timingSafeEqual`, with a self-compare on length mismatch so timing does not leak the mismatch position. Unverified requests are rejected 401 before any database write. +5. **Durable persist before 200 (R06)** — every verified delivery is inserted into `monerium_webhook_events` before the 200 response is sent. Processing happens strictly after the response; a crash between insert and processing loses nothing because the inbox row survives. +6. **Delivery dedup is enforced by the database** — inserts use `ON CONFLICT DO NOTHING` on the unique `event_id` (payload id when present, else sha256 of the raw bytes), so Monerium retries and duplicate deliveries can never double-create or double-apply a deposit event. +7. **Deposit status transitions are forward-only** — `pending → {minted, held, returned}`, `held → {minted, returned}`; `minted` and `returned` are terminal. Out-of-order or replayed webhook events can never regress a deposit status; regressive transitions are logged and ignored. Guarded by `isForwardTransition` (unit-tested). +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 is the Monerium order id** — `monerium_order_id` is unique; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are stored as 18-decimal base-unit strings converted from the provider decimal, never floats. +10. **Client credentials are env-only and requests are bounded** — whitelabel API credentials come from env, all calls carry an explicit timeout, HTTPS base URLs only, and upstream failures surface as generic 502s without echoing provider response bodies. +11. **KYB submission is a guarded stub** — `submitKybData` throws 501 until the whitelabel KYB mechanism is contractually settled (deferred-decisions registry T3); no speculative identity-data path exists. + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| **Webhook spoofing** | Attacker posts fabricated order events to `/v1/monerium-b2b/webhook` to invent or advance deposits | HMAC-SHA256 over raw bytes with constant-time compare; 401 before any persistence; 503 (no acceptance) if the secret is unconfigured | +| **Webhook replay / duplicate delivery** | A captured valid delivery is replayed to double-count a deposit | Durable inbox dedup on unique `event_id` (`ON CONFLICT DO NOTHING`); forward-only transitions make a replayed older state a no-op | +| **Out-of-order events regress state** | A delayed `pending` event arrives after `minted` | Forward-only transition lattice; regressions logged and dropped | +| **Attestor key leak** | Attacker obtains `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` | Blast radius is bounded by design: the key can only produce link attestations for the fixed message, never move funds (contract-side invariant); rotate key + re-deploy forwarders with new ATTESTOR immutable | +| **Attestor signing oracle abuse** | Backend is tricked into signing an arbitrary hash with the attestor key | `signLinkAttestation` derives the hash internally from the fixed LINK_MESSAGE and the forwarder address parameter; no caller-supplied hash is ever signed | +| **Concurrent processors corrupt attribution** | Two instances process events for one account simultaneously | Transaction-scoped Postgres advisory lock per forwarder address serializes all per-account writes | +| **Lost webhook between receipt and processing** | Process crashes after 200 but before the deposit write | Insert-before-200 durable inbox; unprocessed rows are retried on the next run | +| **Poison inbox row blocks processing** | A malformed payload throws forever | Non-order/unrecognized payloads are marked processed and skipped; genuine failures are logged per-row and do not block other rows | +| **API credential compromise** | Whitelabel client id/secret leak | Env-only storage; sandbox credentials are segregated from production (`MONERIUM_B2B_*` set is distinct from legacy `MONERIUM_*`); rotate at Monerium | +| **Provider unavailability** | Monerium API down | Client calls have explicit timeouts and surface 502; webhook inbox is unaffected (processing is local) | + +## Audit Checklist + +- [ ] `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY`, `MONERIUM_B2B_CLIENT_SECRET`, `MONERIUM_B2B_WEBHOOK_SECRET` loaded from env only; grep confirms no logging of their values +- [ ] Attestor signs only the bound link hash (`attestor.ts` has no arbitrary-hash signing entry point) +- [ ] `attestor.test.ts` pins the signature layout against `VortexForwarder.isValidSignature` (65 bytes, v in 27/28, low-s, bound to forwarder address) +- [ ] Webhook HMAC verified over raw captured bytes (`config/express.ts` verify hook), constant-time compare +- [ ] Inbox insert (`ON CONFLICT DO NOTHING` on `event_id`) happens before the 200 response in `monerium-b2b.controller.ts` +- [ ] Forward-only transition guard covers all four statuses; regressive events are dropped, not applied +- [ ] All deposit writes run under `pg_advisory_xact_lock` keyed by lower-cased forwarder address +- [ ] `monerium_order_id` unique constraint present; mint-log partial unique index present (migration 051) +- [ ] `submitKybData` still returns 501 unless registry item T3 has been resolved and this spec updated +- [ ] HTTPS enforced for provider base URLs; timeouts configured on every provider call +- [ ] Sandbox-verification TODOs resolved before production: exact `webhook-signature` digest encoding, delivery id field, upstream order-state vocabulary, EIP-191 vs raw link-hash variant (registry T4) diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 082349751..47e97e646 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -1,5 +1,7 @@ # Monerium Integration +> **Superseded for the B2B onramp:** the whitelabel/attestor/webhook integration is specified in [monerium-b2b.md](./monerium-b2b.md); this file covers only the legacy consumer OAuth onboarding flow. + ## What This Does The backend provides authenticated Monerium OAuth authorization-code endpoints for individual KYC and business KYB. It generates OAuth state and PKCE material server-side, exchanges codes directly with Monerium, keeps access and rotating refresh tokens only in backend memory, reads the authenticated Monerium context and API-v2 profile, and mirrors only normalized verification metadata into `provider_customers` and `kyc_cases`. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 62c24b176..12ab879f9 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -39,6 +39,7 @@ This directory contains the security specification for the Vortex cross-border p | BRLA | `05-integrations/brla.md` | BRLA anchor for BRL on/off-ramp | | Mykobo | `05-integrations/mykobo.md` | Mykobo EUR on/off-ramp on Base (currently registration-gated) | | Monerium | `05-integrations/monerium.md` | Server-side OAuth KYC/KYB and verification status mirroring | +| Monerium B2B | `05-integrations/monerium-b2b.md` | Whitelabel onramp: attestor address linking, HMAC webhook + durable inbox, forward-only deposits | | Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | | Binance | `05-integrations/binance.md` | Binance USDT spot price used as the primary USD<>BRL rate source | | FastForex | `05-integrations/fastforex.md` | Fiat forex price provider used by quote/conversion math | From 8142d0ead156dd35ff4ccd27d1cef4c8746830a2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 12:48:38 +0200 Subject: [PATCH 13/59] Monerium B2B keeper: mint watcher, conversion executor with R04 allocation, dormancy gate, cron worker --- .../src/api/services/monerium-b2b/chain.ts | 218 ++++++++++ .../monerium-b2b/conversion-executor.test.ts | 90 ++++ .../monerium-b2b/conversion-executor.ts | 392 ++++++++++++++++++ .../monerium-b2b/deposit-processor.ts | 29 +- .../services/monerium-b2b/dormancy.test.ts | 58 +++ .../src/api/services/monerium-b2b/dormancy.ts | 92 ++++ .../monerium-b2b/mint-watcher.test.ts | 91 ++++ .../api/services/monerium-b2b/mint-watcher.ts | 213 ++++++++++ .../src/api/workers/monerium-b2b.worker.ts | 123 ++++++ apps/api/src/config/vars.ts | 12 + .../052-monerium-keeper-chain-state.ts | 25 ++ apps/api/src/index.ts | 2 + apps/api/src/models/index.ts | 2 + .../src/models/moneriumChainCursor.model.ts | 58 +++ .../src/models/moneriumFiatDeposit.model.ts | 20 +- .../05-integrations/monerium-b2b.md | 16 +- 16 files changed, 1430 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/api/services/monerium-b2b/chain.ts create mode 100644 apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/conversion-executor.ts create mode 100644 apps/api/src/api/services/monerium-b2b/dormancy.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/dormancy.ts create mode 100644 apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/mint-watcher.ts create mode 100644 apps/api/src/api/workers/monerium-b2b.worker.ts create mode 100644 apps/api/src/database/migrations/052-monerium-keeper-chain-state.ts create mode 100644 apps/api/src/models/moneriumChainCursor.model.ts diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts new file mode 100644 index 000000000..6a9c789bd --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -0,0 +1,218 @@ +import { + Account, + Address, + createPublicClient, + createWalletClient, + Hex, + http, + PublicClient, + parseAbiItem, + Transport, + WalletClient +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; + +/** + * viem clients + minimal hand-written ABI surface for the B2B keeper + * (docs/prd/monerium-b2b-implementation-plan.md §3, "Keeper"). + * + * Key separation is an invariant (security-spec/05-integrations/monerium-b2b.md): + * keeper key (swap submission) != guardian key (protective pause) != attestor key + * (address linking). Reads go through the public RPC; keeper/guardian WRITES go + * through a separate submission transport for private orderflow. + */ + +/** Suggested private-orderflow endpoint for mainnet (MONERIUM_B2B_PRIVATE_RPC_URL). */ +export const DEFAULT_PRIVATE_RPC_URL = "https://rpc.flashbots.net"; + +/** + * Client notification confirmation depth in blocks — registry P9 + * (docs/prd/monerium-onramp-deferred-decisions.md). Not consumed by the keeper itself + * (execution finality is handled via receipt + reorg-safe deposit identity); reserved + * for the notification job (plan §3, "Notifications"). + */ +export const NOTIFY_CONFIRMATION_DEPTH = 32; + +// ------------------------------------------------------------------ ABI surface + +// Hand-written minimal ABIs (no codegen) mirroring +// contracts/monerium-forwarder/src/VortexForwarder.sol + VortexForwarderFactory.sol. + +export const eureTransferEvent = parseAbiItem("event Transfer(address indexed from, address indexed to, uint256 value)"); + +export const erc20Abi = [ + { + inputs: [{ name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +export const forwarderAbi = [ + { inputs: [], name: "poke", outputs: [], stateMutability: "nonpayable", type: "function" }, + { inputs: [], name: "swapAndForward", outputs: [], stateMutability: "nonpayable", type: "function" }, + { + inputs: [{ name: "paused", type: "bool" }], + name: "setGuardianPaused", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, + { inputs: [], name: "strandedSince", outputs: [{ name: "", type: "uint64" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "guardianPaused", outputs: [{ name: "", type: "bool" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "EURE", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "FACTORY", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { + anonymous: false, + inputs: [{ indexed: false, name: "strandedSince", type: "uint64" }], + name: "Poked", + type: "event" + }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "caller", type: "address" }, + { indexed: false, name: "eureIn", type: "uint256" }, + { indexed: false, name: "usdcOut", type: "uint256" }, + { indexed: false, name: "fee", type: "uint256" }, + { indexed: false, name: "forwarded", type: "uint256" } + ], + name: "SwapExecuted", + type: "event" + }, + { + anonymous: false, + inputs: [{ indexed: false, name: "paused", type: "bool" }], + name: "GuardianPausedSet", + type: "event" + } +] as const; + +export const factoryAbi = [ + { inputs: [], name: "minSwapAmount", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "perSwapCap", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "MIN_SWAP_FLOOR", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" } +] as const; + +// ------------------------------------------------------------------ clients + +export type KeeperWalletClient = WalletClient; + +let publicClientCache: PublicClient | null = null; +let keeperClientCache: KeeperWalletClient | null = null; +let guardianClientCache: KeeperWalletClient | null = null; +let privateRpcWarned = false; + +export function isKeeperChainConfigured(): boolean { + return Boolean(config.moneriumB2b.rpcUrl && config.moneriumB2b.keeperPrivateKey); +} + +/** Read/receipt client on the public RPC (MONERIUM_B2B_RPC_URL). */ +export function getPublicClient(): PublicClient { + if (!publicClientCache) { + const { rpcUrl } = config.moneriumB2b; + if (!rpcUrl) { + throw new Error("MONERIUM_B2B_RPC_URL is not configured"); + } + publicClientCache = createPublicClient({ transport: http(rpcUrl) }); + } + return publicClientCache; +} + +/** + * Submission endpoint for keeper/guardian transactions. Prefers the dedicated private + * orderflow RPC; falls back to the public RPC with a warning when unset (fine on + * sandbox/testnet where DEFAULT_PRIVATE_RPC_URL, a mainnet endpoint, does not apply). + */ +function submissionRpcUrl(): string { + const { privateRpcUrl, rpcUrl } = config.moneriumB2b; + if (privateRpcUrl) { + return privateRpcUrl; + } + if (!rpcUrl) { + throw new Error("MONERIUM_B2B_RPC_URL is not configured"); + } + if (!privateRpcWarned) { + privateRpcWarned = true; + logger.warn( + "monerium-b2b: MONERIUM_B2B_PRIVATE_RPC_URL is not set — keeper transactions will be submitted via the public RPC " + + `without private orderflow protection. Set it (e.g. ${DEFAULT_PRIVATE_RPC_URL}) for mainnet.` + ); + } + return rpcUrl; +} + +/** Keeper wallet client (MONERIUM_B2B_KEEPER_PRIVATE_KEY) on the submission transport. */ +export function getKeeperWalletClient(): KeeperWalletClient { + if (!keeperClientCache) { + const key = config.moneriumB2b.keeperPrivateKey; + if (!key) { + // Never include key material in errors or logs. + throw new Error("MONERIUM_B2B_KEEPER_PRIVATE_KEY is not configured"); + } + keeperClientCache = createWalletClient({ + account: privateKeyToAccount(key as Hex), + transport: http(submissionRpcUrl()) + }); + } + return keeperClientCache; +} + +/** + * Guardian wallet client (MONERIUM_B2B_GUARDIAN_PRIVATE_KEY) for the dormancy-gate + * pause. Returns null when the key is unset — the dormancy gate then runs in log-only + * mode. The guardian key is deliberately separate from the keeper key: it can only + * pause (protective-only invariant, plan §2.2), never move funds. + */ +export function getGuardianWalletClient(): KeeperWalletClient | null { + if (!config.moneriumB2b.guardianPrivateKey) { + return null; + } + if (!guardianClientCache) { + guardianClientCache = createWalletClient({ + account: privateKeyToAccount(config.moneriumB2b.guardianPrivateKey as Hex), + transport: http(submissionRpcUrl()) + }); + } + return guardianClientCache; +} + +// ------------------------------------------------------------------ cached chain lookups + +let chainIdCache: number | null = null; + +export async function getChainId(): Promise { + if (chainIdCache === null) { + chainIdCache = await getPublicClient().getChainId(); + } + return chainIdCache; +} + +interface ForwarderImmutables { + eure: Address; + factory: Address; +} + +// EURE/FACTORY are implementation-level immutables shared by every clone, so one +// lookup per forwarder address is enough for the process lifetime. +const forwarderImmutablesCache = new Map(); + +export async function getForwarderImmutables(forwarderAddress: Address): Promise { + const key = forwarderAddress.toLowerCase(); + const cached = forwarderImmutablesCache.get(key); + if (cached) { + return cached; + } + const client = getPublicClient(); + const [eure, factory] = await Promise.all([ + client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName: "EURE" }), + client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName: "FACTORY" }) + ]); + const immutables = { eure, factory }; + forwarderImmutablesCache.set(key, immutables); + return immutables; +} diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts new file mode 100644 index 000000000..55c3b211f --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "bun:test"; +import { AllocatableDeposit, allocateUsdcProRata, selectDepositsForExecution } from "./conversion-executor"; + +// R04 attribution (docs/prd/monerium-b2b-implementation-plan.md §3): pro-rata by +// amount_raw against eureInRaw, floor division, remainder to the largest deposit. +// No chain or database involved — pure math. + +const EUR = 10n ** 18n; +const USDC = 10n ** 6n; + +function deposit(id: string, amountRaw: bigint): AllocatableDeposit { + return { amountRaw, id }; +} + +describe("selectDepositsForExecution", () => { + it("selects all deposits when they fit within eureInRaw", () => { + const deposits = [deposit("a", 100n * EUR), deposit("b", 50n * EUR)]; + expect(selectDepositsForExecution(deposits, 150n * EUR)).toEqual(deposits); + }); + + it("stops before a deposit that would exceed the per-swap cap cut", () => { + const deposits = [deposit("a", 50n * EUR), deposit("b", 30n * EUR)]; + // eureIn = 60: the 30-EUR deposit would push cumulative to 80 — it waits for the + // next execution instead of being over-attributed to this one. + expect(selectDepositsForExecution(deposits, 60n * EUR)).toEqual([deposits[0]]); + }); + + it("selects nothing when even the oldest deposit exceeds eureInRaw", () => { + expect(selectDepositsForExecution([deposit("a", 100n * EUR)], 60n * EUR)).toEqual([]); + }); + + it("handles an exact fit and an empty list", () => { + const deposits = [deposit("a", 25n * EUR), deposit("b", 75n * EUR)]; + expect(selectDepositsForExecution(deposits, 100n * EUR)).toEqual(deposits); + expect(selectDepositsForExecution([], 100n * EUR)).toEqual([]); + }); +}); + +describe("allocateUsdcProRata", () => { + it("gives a single deposit covering the full eureIn the entire net USDC", () => { + const shares = allocateUsdcProRata([deposit("a", 100n * EUR)], 100n * EUR, 108n * USDC); + expect(shares.get("a")).toBe(108n * USDC); + }); + + it("splits proportionally when amounts divide evenly", () => { + const shares = allocateUsdcProRata([deposit("a", 75n * EUR), deposit("b", 25n * EUR)], 100n * EUR, 100n * USDC); + expect(shares.get("a")).toBe(75n * USDC); + expect(shares.get("b")).toBe(25n * USDC); + }); + + it("floors each share and gives the division remainder to the largest deposit", () => { + // 100 USDC over three equal thirds: floor gives 33.333333 each, 1 raw unit of dust + // remains and goes to the largest (tie -> earliest). + const shares = allocateUsdcProRata( + [deposit("a", 1n * EUR), deposit("b", 1n * EUR), deposit("c", 1n * EUR)], + 3n * EUR, + 100n * USDC + ); + expect(shares.get("a")).toBe(33333334n); + expect(shares.get("b")).toBe(33333333n); + expect(shares.get("c")).toBe(33333333n); + expect([...shares.values()].reduce((sum, share) => sum + share, 0n)).toBe(100n * USDC); + }); + + it("gives the remainder to the largest deposit, not the first", () => { + const shares = allocateUsdcProRata([deposit("small", 1n * EUR), deposit("big", 2n * EUR)], 3n * EUR, 100n * USDC); + expect(shares.get("small")).toBe(33333333n); + expect(shares.get("big")).toBe(66666667n); + }); + + it("handles a dust deposit whose floor share is zero", () => { + // 1 raw-unit deposit against 100 EUR in: floor share is 0; the sum invariant holds + // because the remainder lands on the large deposit. + const shares = allocateUsdcProRata([deposit("dust", 1n), deposit("big", 100n * EUR - 1n)], 100n * EUR, 100n * USDC); + expect(shares.get("dust")).toBe(0n); + expect(shares.get("big")).toBe(100n * USDC); + }); + + it("conserves the total exactly whenever the selection covers eureInRaw", () => { + const deposits = [deposit("a", 7n * EUR), deposit("b", 13n * EUR), deposit("c", 17n * EUR)]; + const usdcNet = 39_876_543n; + const shares = allocateUsdcProRata(deposits, 37n * EUR, usdcNet); + expect([...shares.values()].reduce((sum, share) => sum + share, 0n)).toBe(usdcNet); + }); + + it("returns an empty allocation for an empty selection or non-positive eureIn", () => { + expect(allocateUsdcProRata([], 100n * EUR, 100n * USDC).size).toBe(0); + expect(allocateUsdcProRata([deposit("a", 1n * EUR)], 0n, 100n * USDC).size).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts new file mode 100644 index 000000000..90a9e2a68 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -0,0 +1,392 @@ +import { Op, Transaction } from "sequelize"; +import { Address, parseEventLogs, TransactionReceipt } from "viem"; +import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { erc20Abi, factoryAbi, forwarderAbi, getForwarderImmutables, getKeeperWalletClient, getPublicClient } from "./chain"; +import { withForwarderLock } from "./deposit-processor"; + +/** + * Per-account conversion executor (plan §3, "Keeper" + "Attribution (R04)"): + * balance >= minSwapAmount -> poke() (stranding marker, R03) + swapAndForward() via the + * private submission transport, with an execution record created and committed BEFORE + * anything is sent, then snapshot-based deposit attribution on confirmation. + * + * Serialization: every database mutation runs inside the per-forwarder advisory lock + * (withForwarderLock). The chain send/wait itself deliberately happens OUTSIDE a lock — + * holding a transaction open across RPC waits would pin a connection for minutes, and + * crash-safety requires the pending execution row to be durably COMMITTED before the + * transaction is broadcast (a row inside an open transaction would roll back on crash). + * Double-send is instead prevented by the "any pending execution -> skip" check, which + * runs under the lock. + */ + +/** Retry backoff for failed executions: base * 2^attempts, capped. Kept deliberately minimal. */ +const RETRY_BASE_MS = 60_000; +const RETRY_MAX_MS = 60 * 60_000; + +/** A pending execution with a tx hash but no receipt after this long is declared failed. */ +const PENDING_TX_STALE_MS = 15 * 60_000; + +/** How long one cycle waits for the swap receipt before deferring to the next cycle. */ +const RECEIPT_TIMEOUT_MS = 3 * 60_000; + +// ------------------------------------------------------------------ R04 allocation math + +export interface AllocatableDeposit { + id: string; + amountRaw: bigint; +} + +/** + * Snapshot selection honoring the per-swap cap: deposits are taken oldest-mint-first + * until the next one would push the cumulative amount past eureInRaw (a cap-cut deposit + * stays unallocated and joins the next execution). Callers pass only unallocated minted + * deposits with mint block <= execution block (R04). + */ +export function selectDepositsForExecution(deposits: AllocatableDeposit[], eureInRaw: bigint): AllocatableDeposit[] { + const selected: AllocatableDeposit[] = []; + let cumulative = 0n; + for (const deposit of deposits) { + if (cumulative + deposit.amountRaw > eureInRaw) { + break; + } + cumulative += deposit.amountRaw; + selected.push(deposit); + } + return selected; +} + +/** + * R04 pro-rata attribution of the execution's net USDC: each deposit gets + * floor(usdcNetRaw * amountRaw / eureInRaw); the remainder (floor dust, plus any value + * from inflows not represented in the selection) goes to the largest deposit (ties: the + * earliest). Sum of shares always equals usdcNetRaw for a non-empty selection. + */ +export function allocateUsdcProRata( + deposits: AllocatableDeposit[], + eureInRaw: bigint, + usdcNetRaw: bigint +): Map { + const shares = new Map(); + if (deposits.length === 0 || eureInRaw <= 0n) { + return shares; + } + let allocated = 0n; + let largest = deposits[0]; + for (const deposit of deposits) { + const share = (usdcNetRaw * deposit.amountRaw) / eureInRaw; + shares.set(deposit.id, share); + allocated += share; + if (deposit.amountRaw > largest.amountRaw) { + largest = deposit; + } + } + const remainder = usdcNetRaw - allocated; + if (remainder > 0n) { + shares.set(largest.id, (shares.get(largest.id) as bigint) + remainder); + } + return shares; +} + +// ------------------------------------------------------------------ finalization + attribution + +function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 500); +} + +async function allocateDeposits(execution: MoneriumConversionExecution, transaction: Transaction): Promise { + if (execution.blockNumber === null) { + return; + } + // R04 snapshot: unallocated minted deposits with mint block <= execution block, + // oldest mint first. Unattributed inflow rows participate: their EURe was part of the + // swapped balance, and linking them marks the inflow as consumed by this execution. + const deposits = await MoneriumFiatDeposit.findAll({ + order: [ + ["block_number", "ASC"], + ["log_index", "ASC"] + ], + transaction, + where: { + accountId: execution.accountId, + allocatedExecutionId: null, + blockNumber: { [Op.lte]: execution.blockNumber }, + status: MoneriumFiatDepositStatus.Minted + } + }); + const eureInRaw = BigInt(execution.eureInRaw); + const selected = selectDepositsForExecution( + deposits.map(deposit => ({ amountRaw: BigInt(deposit.amountRaw), id: deposit.id })), + eureInRaw + ); + if (selected.length === 0) { + return; + } + const shares = allocateUsdcProRata(selected, eureInRaw, BigInt(execution.usdcNetRaw ?? "0")); + const selectedIds = selected.map(deposit => deposit.id); + await MoneriumFiatDeposit.update( + { allocatedExecutionId: execution.id }, + { transaction, where: { id: { [Op.in]: selectedIds } } } + ); + logger.info( + `monerium-b2b: execution ${execution.id} allocated ${selectedIds.length} deposit(s): ` + + [...shares.entries()].map(([id, share]) => `${id}=${share.toString()}`).join(", ") + ); +} + +/** Applies a mined receipt to a pending execution: confirmed + event amounts + R04 allocation, or failed on revert. */ +async function finalizeExecution( + execution: MoneriumConversionExecution, + receipt: TransactionReceipt, + forwarderAddress: string, + transaction: Transaction +): Promise { + if (receipt.status !== "success") { + await execution.update( + { + blockNumber: Number(receipt.blockNumber), + error: "swapAndForward reverted", + status: MoneriumConversionExecutionStatus.Failed + }, + { transaction } + ); + return; + } + const swapEvents = parseEventLogs({ abi: forwarderAbi, eventName: "SwapExecuted", logs: receipt.logs }).filter( + log => log.address.toLowerCase() === forwarderAddress.toLowerCase() + ); + if (swapEvents.length === 0) { + // A successful swapAndForward always emits SwapExecuted; treat absence as failure. + await execution.update( + { + blockNumber: Number(receipt.blockNumber), + error: "receipt succeeded but no SwapExecuted event was emitted by the forwarder", + status: MoneriumConversionExecutionStatus.Failed + }, + { transaction } + ); + return; + } + const { eureIn, usdcOut, fee, forwarded } = swapEvents[0].args; + await execution.update( + { + blockNumber: Number(receipt.blockNumber), + error: null, + // The event's amountIn is authoritative (min(balance, cap) at execution time). + eureInRaw: eureIn.toString(), + feeRaw: fee.toString(), + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: receipt.transactionHash, + usdcGrossRaw: usdcOut.toString(), + usdcNetRaw: forwarded.toString() + }, + { transaction } + ); + await allocateDeposits(execution, transaction); +} + +// ------------------------------------------------------------------ pending resolution + backoff + +type PreparationResult = { kind: "proceed"; attempt: number } | { kind: "skip"; reason: string }; + +/** + * Under the forwarder lock: resolve leftover pending executions (crash/timeout + * recovery), then decide whether a new execution may start (retry backoff). + */ +async function prepareExecutionSlot(account: MoneriumAccount, transaction: Transaction): Promise { + const pendings = await MoneriumConversionExecution.findAll({ + order: [["created_at", "ASC"]], + transaction, + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Pending } + }); + for (const pending of pendings) { + if (!pending.txHash) { + // Execution-before-send record with no hash: the process died between commit and + // broadcast, so nothing is in flight — safe to fail and retry. + await pending.update( + { error: "crashed before the transaction was sent", status: MoneriumConversionExecutionStatus.Failed }, + { transaction } + ); + continue; + } + const receipt = await getPublicClient() + .getTransactionReceipt({ hash: pending.txHash as Address }) + .catch(() => null); + if (receipt) { + await finalizeExecution(pending, receipt, account.forwarderAddress, transaction); + } else if (Date.now() - pending.updatedAt.getTime() > PENDING_TX_STALE_MS) { + await pending.update( + { error: "timed out waiting for a receipt", status: MoneriumConversionExecutionStatus.Failed }, + { transaction } + ); + } else { + return { kind: "skip", reason: `execution ${pending.id} still awaiting receipt ${pending.txHash}` }; + } + } + + // Backoff over consecutive failures since the last confirmed execution. + const lastConfirmed = await MoneriumConversionExecution.findOne({ + order: [["created_at", "DESC"]], + transaction, + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Confirmed } + }); + const failedSince: MoneriumConversionExecution[] = await MoneriumConversionExecution.findAll({ + order: [["created_at", "DESC"]], + transaction, + where: { + accountId: account.id, + status: MoneriumConversionExecutionStatus.Failed, + ...(lastConfirmed ? { createdAt: { [Op.gt]: lastConfirmed.createdAt } } : {}) + } + }); + if (failedSince.length > 0) { + const backoffMs = Math.min(RETRY_BASE_MS * 2 ** (failedSince.length - 1), RETRY_MAX_MS); + const nextAttemptAt = failedSince[0].updatedAt.getTime() + backoffMs; + if (Date.now() < nextAttemptAt) { + return { kind: "skip", reason: `retry backoff until ${new Date(nextAttemptAt).toISOString()}` }; + } + } + return { attempt: failedSince.length + 1, kind: "proceed" }; +} + +// ------------------------------------------------------------------ executor + +/** + * Runs one conversion cycle for an account. Safe to call for accounts with nothing to + * do (cheap chain reads, then returns). + */ +export async function runConversionExecutor(accountId: string): Promise { + const account = await MoneriumAccount.findByPk(accountId); + if (!account) { + return; + } + if (account.status === MoneriumAccountStatus.Suspended || account.status === MoneriumAccountStatus.Closed) { + return; + } + if (account.dormantSince) { + // Guardian-paused for dormancy — swapAndForward would revert Paused(). Unpausing is + // manual after partner re-confirmation (registry B5). + return; + } + + const client = getPublicClient(); + const forwarder = account.forwarderAddress as Address; + const { eure, factory } = await getForwarderImmutables(forwarder); + const [balance, strandedSince, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) + ]); + + // R03: arm the stranding marker whenever funds cross the immutable floor, even below + // the (guardian-tunable) minSwapAmount — the dead-man timers must start regardless of + // whether a swap is currently possible. + const pokeNeeded = strandedSince === 0n && balance >= minSwapFloor; + + if (balance < minSwapAmount) { + if (pokeNeeded) { + await sendPoke(forwarder); + } + return; + } + + const preparation = await withForwarderLock(account.forwarderAddress, transaction => + prepareExecutionSlot(account, transaction) + ); + if (preparation.kind === "skip") { + logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${preparation.reason}`); + return; + } + + // Execution-before-send record (plan §3): committed before any broadcast so a crash + // leaves an auditable pending row, never an untracked on-chain swap. + const execution = await withForwarderLock(account.forwarderAddress, transaction => + MoneriumConversionExecution.create( + { + accountId: account.id, + destination: account.destination, + eureInRaw: (balance > perSwapCap ? perSwapCap : balance).toString() + }, + { transaction } + ) + ); + + try { + const keeper = getKeeperWalletClient(); + // Explicit nonces: poke + swap are sent back-to-back through the private transport, + // which may not expose a coherent pending pool for nonce derivation. + let nonce = await client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }); + + if (pokeNeeded) { + await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); + await keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "poke", + nonce: nonce++ + }); + } + + await client.simulateContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + functionName: "swapAndForward" + }); + const txHash = await keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "swapAndForward", + nonce + }); + await execution.update({ txHash }); + + const receipt = await client.waitForTransactionReceipt({ hash: txHash, timeout: RECEIPT_TIMEOUT_MS }); + await withForwarderLock(account.forwarderAddress, transaction => + finalizeExecution(execution, receipt, account.forwarderAddress, transaction) + ); + } catch (error) { + if (execution.txHash) { + // The transaction is (or may be) in flight; leave the row pending — the next + // cycle resolves it via receipt lookup or declares it stale. + logger.warn(`monerium-b2b: execution ${execution.id} awaiting receipt after error: ${errorText(error)}`); + return; + } + await execution.update({ + error: `attempt ${preparation.attempt}: ${errorText(error)}`, + status: MoneriumConversionExecutionStatus.Failed + }); + logger.error(`monerium-b2b: conversion for account ${account.id} failed (attempt ${preparation.attempt}):`, error); + } +} + +/** Standalone stranding-marker poke for balances between the floor and minSwapAmount. */ +async function sendPoke(forwarder: Address): Promise { + try { + const client = getPublicClient(); + const keeper = getKeeperWalletClient(); + await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); + const hash = await keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "poke" + }); + logger.info(`monerium-b2b: poked forwarder ${forwarder} (${hash})`); + } catch (error) { + // Best-effort: poke is also permissionless on-chain, so a missed poke only delays + // the stranding timers until the next cycle. + logger.warn(`monerium-b2b: poke for forwarder ${forwarder} failed: ${errorText(error)}`); + } +} diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts index 68bf4b687..db8a20f75 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -1,3 +1,4 @@ +import { Transaction } from "sequelize"; import { parseUnits } from "viem"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; @@ -13,6 +14,24 @@ import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; const EURE_DECIMALS = 18; +/** + * Runs `fn` inside a transaction holding the per-forwarder advisory lock (plan §3): + * the lock is transaction-scoped, so concurrent processors (multiple instances, + * webhook-triggered + scheduled runs, mint watcher, conversion executor) apply writes + * for one account strictly one at a time. Shared serialization point for the whole + * monerium-b2b module. + */ +export async function withForwarderLock(forwarderAddress: string, fn: (transaction: Transaction) => Promise): Promise { + const forwarderKey = forwarderAddress.toLowerCase(); + return sequelize.transaction(async transaction => { + await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { + replacements: { key: `monerium-b2b:${forwarderKey}` }, + transaction + }); + return fn(transaction); + }); +} + // Forward-only lattice (plan §3): pending → minted/held/returned; a compliance hold can // still resolve to minted or returned; minted/returned are terminal. const FORWARD_TRANSITIONS: Record = { @@ -94,15 +113,7 @@ async function processInboxRow(row: MoneriumWebhookEvent): Promise { } const forwarderKey = event.forwarderAddress.toLowerCase(); - await sequelize.transaction(async transaction => { - // Per-forwarder serialization (plan §3): the advisory lock is transaction-scoped, so - // concurrent processors (multiple instances, webhook-triggered + scheduled runs) - // apply events for one account strictly one at a time. - await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { - replacements: { key: `monerium-b2b:${forwarderKey}` }, - transaction - }); - + await withForwarderLock(forwarderKey, async transaction => { const account = await MoneriumAccount.findOne({ transaction, where: sequelize.where(sequelize.fn("lower", sequelize.col("forwarder_address")), forwarderKey) diff --git a/apps/api/src/api/services/monerium-b2b/dormancy.test.ts b/apps/api/src/api/services/monerium-b2b/dormancy.test.ts new file mode 100644 index 000000000..f5f6a07bf --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/dormancy.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import { DORMANCY_WINDOW_MS, isDormancyCandidate } from "./dormancy"; + +// Dormancy selection (plan §3, R05; window = registry P5, 60 days). Pure predicate — +// runDormancyGate applies it per active account with its latest confirmed execution. + +const NOW = new Date("2026-07-17T12:00:00Z"); + +function daysAgo(days: number): Date { + return new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000); +} + +function account(overrides: Partial[0]> = {}) { + return { + createdAt: daysAgo(365), + dormantSince: null, + status: MoneriumAccountStatus.Active, + ...overrides + }; +} + +describe("isDormancyCandidate", () => { + it("uses a 60-day window (registry P5)", () => { + expect(DORMANCY_WINDOW_MS).toBe(60 * 24 * 60 * 60 * 1000); + }); + + it("flags an active account whose last confirmed conversion is older than the window", () => { + expect(isDormancyCandidate(account(), daysAgo(61), NOW)).toBe(true); + }); + + it("does not flag an account with a recent confirmed conversion", () => { + expect(isDormancyCandidate(account(), daysAgo(59), NOW)).toBe(false); + }); + + it("treats exactly-at-the-window as dormant (inclusive boundary)", () => { + expect(isDormancyCandidate(account(), daysAgo(60), NOW)).toBe(true); + }); + + it("anchors never-converted accounts on their creation date", () => { + expect(isDormancyCandidate(account({ createdAt: daysAgo(61) }), null, NOW)).toBe(true); + expect(isDormancyCandidate(account({ createdAt: daysAgo(10) }), null, NOW)).toBe(false); + }); + + it("never re-flags an account already marked dormant", () => { + expect(isDormancyCandidate(account({ dormantSince: daysAgo(5) }), daysAgo(90), NOW)).toBe(false); + }); + + it("only applies to active accounts (status itself is not changed by the gate)", () => { + for (const status of [ + MoneriumAccountStatus.Onboarding, + MoneriumAccountStatus.Suspended, + MoneriumAccountStatus.Closed + ]) { + expect(isDormancyCandidate(account({ status }), daysAgo(90), NOW)).toBe(false); + } + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/dormancy.ts b/apps/api/src/api/services/monerium-b2b/dormancy.ts new file mode 100644 index 000000000..9d2a0ec26 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/dormancy.ts @@ -0,0 +1,92 @@ +import { Address } from "viem"; +import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import { forwarderAbi, getGuardianWalletClient, getPublicClient } from "./chain"; + +/** + * Dormancy gate (plan §3, R05): accounts with no successful forward for the dormancy + * window get a protective per-clone guardian pause. The pause can never move funds or + * block the client's fallback paths (contract invariant, plan §2.2); account status + * 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 (deferred-decisions registry B5). + */ + +/** Dormancy pause window — registry P5 (docs/prd/monerium-onramp-deferred-decisions.md). */ +export const DORMANCY_WINDOW_MS = 60 * 24 * 60 * 60 * 1000; + +export interface DormancyAccountFields { + createdAt: Date; + dormantSince: Date | null; + status: MoneriumAccountStatus; +} + +/** + * An account is a dormancy candidate iff it is active, not already flagged, and its + * last confirmed conversion (or, for never-converted accounts, its creation) is at + * least the dormancy window in the past. + */ +export function isDormancyCandidate( + account: DormancyAccountFields, + lastConfirmedAt: Date | null, + now: Date = new Date() +): boolean { + if (account.status !== MoneriumAccountStatus.Active || account.dormantSince !== null) { + return false; + } + const anchor = lastConfirmedAt ?? account.createdAt; + return now.getTime() - anchor.getTime() >= DORMANCY_WINDOW_MS; +} + +async function pauseDormantAccount(account: MoneriumAccount, now: Date): Promise { + const guardian = getGuardianWalletClient(); + if (!guardian) { + // Log-only mode (MONERIUM_B2B_GUARDIAN_PRIVATE_KEY unset): record the detection so + // it is not re-alerted every cycle, but state clearly that no on-chain pause exists. + logger.warn( + `monerium-b2b: account ${account.id} (forwarder ${account.forwarderAddress}) is dormant; ` + + "guardian key not configured — NOT pausing on-chain (log-only mode)" + ); + await account.update({ dormantSince: now }); + return; + } + + const client = getPublicClient(); + const forwarder = account.forwarderAddress as Address; + const { request } = await client.simulateContract({ + abi: forwarderAbi, + account: guardian.account, + address: forwarder, + args: [true], + functionName: "setGuardianPaused" + }); + const hash = await guardian.writeContract({ ...request, chain: null }); + await client.waitForTransactionReceipt({ hash }); + await account.update({ dormantSince: now }); + logger.info(`monerium-b2b: dormancy pause set for account ${account.id} (forwarder ${forwarder}, tx ${hash})`); +} + +/** Runs one dormancy-gate pass over all active, not-yet-flagged accounts. */ +export async function runDormancyGate(now: Date = new Date()): Promise { + const accounts = await MoneriumAccount.findAll({ + where: { dormantSince: null, status: MoneriumAccountStatus.Active } + }); + for (const account of accounts) { + try { + const lastConfirmed = await MoneriumConversionExecution.findOne({ + order: [["created_at", "DESC"]], + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Confirmed } + }); + if (isDormancyCandidate(account, lastConfirmed?.createdAt ?? null, now)) { + await pauseDormantAccount(account, now); + } + } catch (error) { + logger.error(`monerium-b2b: dormancy gate failed for account ${account.id}:`, error); + } + } +} diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts new file mode 100644 index 000000000..ca14d9764 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { + MatchableDeposit, + matchMintLogToDeposit, + syntheticUnattributedOrderId, + UNATTRIBUTED_ORDER_PREFIX +} from "./mint-watcher"; + +// Mint-log -> deposit matching (plan §3, "mint detection"). Pure decision logic; the +// database write path shares the advisory-locked transaction with the webhook processor. + +const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; + +const TX_A = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +function candidate(overrides: Partial & { id: string }): MatchableDeposit { + return { + amountRaw: (100n * 10n ** 18n).toString(), + logIndex: null, + status: Pending, + txHash: null, + ...overrides + } as MatchableDeposit; +} + +describe("matchMintLogToDeposit", () => { + it("matches by tx hash when the webhook already recorded the mint hash (case-insensitive)", () => { + const deposits = [ + candidate({ id: "other" }), + candidate({ id: "hash-match", status: Minted, txHash: TX_A.toLowerCase() }) + ]; + const match = matchMintLogToDeposit({ txHash: TX_A, valueRaw: 5n }, deposits); + expect(match?.id).toBe("hash-match"); + }); + + it("matches the oldest pending deposit with the exact mint amount", () => { + const amount = 250n * 10n ** 18n; + const deposits = [ + candidate({ amountRaw: (100n * 10n ** 18n).toString(), id: "wrong-amount" }), + candidate({ amountRaw: amount.toString(), id: "older" }), + candidate({ amountRaw: amount.toString(), id: "newer" }) + ]; + const match = matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits); + expect(match?.id).toBe("older"); + }); + + it("returns null when nothing matches (unattributed fallback)", () => { + const deposits = [candidate({ amountRaw: "100", id: "a" })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: 999n }, deposits)).toBeNull(); + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: 999n }, [])).toBeNull(); + }); + + it("never matches deposits that already carry mint fields", () => { + const amount = 100n * 10n ** 18n; + const deposits = [candidate({ amountRaw: amount.toString(), id: "already-recorded", logIndex: 3 })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); + }); + + it("never matches held or returned orders (they have not minted)", () => { + const amount = 100n * 10n ** 18n; + const deposits = [ + candidate({ amountRaw: amount.toString(), id: "held", status: Held }), + candidate({ amountRaw: amount.toString(), id: "returned", status: Returned }) + ]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); + }); + + it("does not amount-match a minted deposit without a hash (hash is required once minted)", () => { + // A webhook-minted order without meta.txHash cannot be safely claimed by amount + // alone once it is already minted — only pending orders amount-match. + const amount = 100n * 10n ** 18n; + const deposits = [candidate({ amountRaw: amount.toString(), id: "minted-no-hash", status: Minted })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); + }); +}); + +describe("syntheticUnattributedOrderId", () => { + it("is deterministic, flagged, and fits the 64-char order-id column", () => { + const id = syntheticUnattributedOrderId(1, TX_A, 7); + expect(id).toBe(syntheticUnattributedOrderId(1, TX_A.toLowerCase(), 7)); + expect(id.startsWith(UNATTRIBUTED_ORDER_PREFIX)).toBe(true); + expect(id.length).toBeLessThanOrEqual(64); + }); + + it("differs per chain, transaction, and log index", () => { + const base = syntheticUnattributedOrderId(1, TX_A, 7); + expect(syntheticUnattributedOrderId(2, TX_A, 7)).not.toBe(base); + expect(syntheticUnattributedOrderId(1, TX_A, 8)).not.toBe(base); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts new file mode 100644 index 000000000..8f24aa26d --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts @@ -0,0 +1,213 @@ +import crypto from "crypto"; +import { Op } from "sequelize"; +import { Address } from "viem"; +import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumChainCursor from "../../../models/moneriumChainCursor.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { eureTransferEvent, getChainId, getForwarderImmutables, getPublicClient } from "./chain"; +import { withForwarderLock } from "./deposit-processor"; + +/** + * Poll-based EURe Transfer watcher (plan §3, "Keeper: mint detection"): scans a + * persisted-cursor block range for Transfers TO known forwarder addresses (from any + * sender), matches each mint to its pending Monerium order, and flags non-Monerium + * inflows as unattributed (R09: flagged, not treated as a customer deposit claim). + */ + +/** Upper bound on blocks scanned per cycle, so getLogs stays bounded after downtime. */ +const MAX_BLOCK_RANGE = 2000n; + +/** Order-id prefix marking a deposit row created from a mint log with no matching Monerium order. */ +export const UNATTRIBUTED_ORDER_PREFIX = "unattr:"; + +/** + * Deterministic synthetic order id for an unattributed mint (monerium_order_id is NOT + * NULL UNIQUE, max 64 chars — a raw tx hash would not fit alongside a prefix). + */ +export function syntheticUnattributedOrderId(chainId: number, txHash: string, logIndex: number): string { + const digest = crypto.createHash("sha256").update(`${chainId}:${txHash.toLowerCase()}:${logIndex}`).digest("hex"); + return `${UNATTRIBUTED_ORDER_PREFIX}${digest.slice(0, 56)}`; +} + +export interface MintLogFields { + txHash: string; + valueRaw: bigint; +} + +export type MatchableDeposit = Pick; + +/** + * Picks the deposit row a mint log belongs to, among the account's deposits that are + * still missing their mint fields (logIndex null). Precedence: + * 1. tx-hash match — the webhook may have already recorded the mint hash (order meta); + * 2. amount match on a pending order — oldest first (caller passes createdAt order). + * Held/returned orders never minted, so they are not candidates. Returns null when + * nothing matches (unattributed inflow). + */ +export function matchMintLogToDeposit(log: MintLogFields, candidates: MatchableDeposit[]): MatchableDeposit | null { + const open = candidates.filter( + deposit => + deposit.logIndex === null && + (deposit.status === MoneriumFiatDepositStatus.Pending || deposit.status === MoneriumFiatDepositStatus.Minted) + ); + const byHash = open.find(deposit => deposit.txHash !== null && deposit.txHash.toLowerCase() === log.txHash.toLowerCase()); + if (byHash) { + return byHash; + } + return ( + open.find( + deposit => + deposit.txHash === null && + deposit.status === MoneriumFiatDepositStatus.Pending && + BigInt(deposit.amountRaw) === log.valueRaw + ) ?? null + ); +} + +interface ObservedMint { + blockHash: string; + blockNumber: number; + logIndex: number; + to: Address; + txHash: string; + valueRaw: bigint; +} + +async function recordMint( + mint: ObservedMint, + chainId: number, + accountsByForwarder: Map +): Promise { + const account = accountsByForwarder.get(mint.to.toLowerCase()); + if (!account) { + // getLogs was filtered to known forwarders, so this only happens on a race with + // account archival; nothing to record against. + return null; + } + + return withForwarderLock(account.forwarderAddress, async transaction => { + // Idempotency: the (chain_id, tx_hash, log_index) partial unique index is the + // on-chain identity; a re-scan after a crash must not double-record. + const alreadyRecorded = await MoneriumFiatDeposit.findOne({ + transaction, + where: { chainId, logIndex: mint.logIndex, txHash: mint.txHash } + }); + if (alreadyRecorded) { + return null; + } + + const candidates = await MoneriumFiatDeposit.findAll({ + order: [["created_at", "ASC"]], + transaction, + where: { accountId: account.id, logIndex: null } + }); + const match = matchMintLogToDeposit({ txHash: mint.txHash, valueRaw: mint.valueRaw }, candidates); + + if (match) { + const deposit = candidates.find(row => row.id === match.id) as MoneriumFiatDeposit; + await deposit.update( + { + blockHash: mint.blockHash, + blockNumber: mint.blockNumber, + chainId, + logIndex: mint.logIndex, + // Forward-only: pending -> minted; a webhook-minted row just gains chain fields. + ...(deposit.status === MoneriumFiatDepositStatus.Pending ? { status: MoneriumFiatDepositStatus.Minted } : {}), + txHash: mint.txHash + }, + { transaction } + ); + } else { + // R09-adjacent: EURe arrived without a matching Monerium order (direct transfer, + // or the order webhook has not landed yet). Record it flagged as unattributed so + // the balance stays accounted for; it is never presented as a customer deposit. + logger.warn( + `monerium-b2b: unattributed EURe mint ${mint.txHash}#${mint.logIndex} of ${mint.valueRaw.toString()} raw to forwarder ${account.forwarderAddress}` + ); + await MoneriumFiatDeposit.create( + { + accountId: account.id, + amountRaw: mint.valueRaw.toString(), + blockHash: mint.blockHash, + blockNumber: mint.blockNumber, + chainId, + currency: "eur", + logIndex: mint.logIndex, + moneriumOrderId: syntheticUnattributedOrderId(chainId, mint.txHash, mint.logIndex), + status: MoneriumFiatDepositStatus.Minted, + txHash: mint.txHash + }, + { transaction } + ); + } + return account.id; + }); +} + +/** + * Runs one watcher cycle. Returns the ids of accounts that received new mints, so the + * worker can enqueue conversion for them immediately. + */ +export async function runMintWatcher(): Promise { + const accounts = await MoneriumAccount.findAll({ + where: { status: { [Op.ne]: MoneriumAccountStatus.Closed } } + }); + if (accounts.length === 0) { + return []; + } + const accountsByForwarder = new Map(accounts.map(account => [account.forwarderAddress.toLowerCase(), account])); + + const client = getPublicClient(); + const chainId = await getChainId(); + const { eure } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const latest = await client.getBlockNumber(); + + const cursorName = `eure-mints:${chainId}`; + const cursor = await MoneriumChainCursor.findByPk(cursorName); + if (!cursor) { + // Bootstrap: start watching from the current head. Historic mints are covered by + // webhook-recorded orders; back-filling their chain fields is a manual operation. + await MoneriumChainCursor.create({ lastBlock: latest.toString(), name: cursorName }); + return []; + } + + const fromBlock = BigInt(cursor.lastBlock) + 1n; + if (fromBlock > latest) { + return []; + } + const toBlock = latest - fromBlock + 1n > MAX_BLOCK_RANGE ? fromBlock + MAX_BLOCK_RANGE - 1n : latest; + + const logs = await client.getLogs({ + address: eure, + args: { to: accounts.map(account => account.forwarderAddress as Address) }, + event: eureTransferEvent, + fromBlock, + toBlock + }); + + const touchedAccounts = new Set(); + for (const log of logs) { + if (log.blockHash === null || log.blockNumber === null || log.transactionHash === null || log.logIndex === null) { + continue; // pending log — will be picked up once mined (cursor only advances over mined ranges) + } + const accountId = await recordMint( + { + blockHash: log.blockHash, + blockNumber: Number(log.blockNumber), + logIndex: log.logIndex, + to: log.args.to as Address, + txHash: log.transactionHash, + valueRaw: log.args.value as bigint + }, + chainId, + accountsByForwarder + ); + if (accountId) { + touchedAccounts.add(accountId); + } + } + + await cursor.update({ lastBlock: toBlock.toString() }); + return [...touchedAccounts]; +} diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts new file mode 100644 index 000000000..7c095c2a4 --- /dev/null +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -0,0 +1,123 @@ +import { CronJob } from "cron"; +import { Op } from "sequelize"; +import { Address } from "viem"; +import logger from "../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../models/moneriumAccount.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; +import { erc20Abi, getForwarderImmutables, getPublicClient, isKeeperChainConfigured } from "../services/monerium-b2b/chain"; +import { runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; +import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { runDormancyGate } from "../services/monerium-b2b/dormancy"; +import { runMintWatcher } from "../services/monerium-b2b/mint-watcher"; + +const DEFAULT_CRON_TIME = "* * * * *"; // every minute + +/** + * Keeper loop for the Monerium B2B onramp (plan §3): webhook inbox -> mint watcher -> + * per-account conversion executor -> dormancy gate (R05). Chain steps are skipped + * (inbox processing still runs) until MONERIUM_B2B_RPC_URL and + * MONERIUM_B2B_KEEPER_PRIVATE_KEY are configured. + */ +class MoneriumB2bWorker { + private job: CronJob; + private running = false; + private chainConfigWarned = false; + + constructor(cronTime = DEFAULT_CRON_TIME) { + this.job = new CronJob(cronTime, this.cycle.bind(this), null, false, undefined, null, true); + } + + public start(): void { + logger.info("Starting Monerium B2B keeper worker"); + this.job.start(); + } + + public stop(): void { + logger.info("Stopping Monerium B2B keeper worker"); + this.job.stop(); + } + + private async cycle(): Promise { + if (this.running) { + return; // previous cycle (e.g. waiting on a receipt) still in progress + } + this.running = true; + try { + await processMoneriumWebhookInbox(); + + if (!isKeeperChainConfigured()) { + if (!this.chainConfigWarned) { + this.chainConfigWarned = true; + logger.warn( + "monerium-b2b: MONERIUM_B2B_RPC_URL / MONERIUM_B2B_KEEPER_PRIVATE_KEY not configured — keeper chain steps disabled" + ); + } + return; + } + + const mintedAccountIds = await runMintWatcher(); + const candidateIds = await this.conversionCandidates(mintedAccountIds); + for (const accountId of candidateIds) { + try { + await runConversionExecutor(accountId); + } catch (error) { + logger.error(`monerium-b2b: conversion executor failed for account ${accountId}:`, error); + } + } + + await runDormancyGate(); + } catch (error) { + logger.error("Error during Monerium B2B keeper cycle:", error); + } finally { + this.running = false; + } + } + + /** + * Accounts worth running the executor for: fresh mints from this cycle, accounts with + * minted-but-unallocated deposits, and accounts whose forwarder holds a nonzero EURe + * balance (covers inflows the watcher has not indexed yet). + */ + private async conversionCandidates(mintedAccountIds: string[]): Promise { + const candidates = new Set(mintedAccountIds); + + const unallocated = await MoneriumFiatDeposit.findAll({ + attributes: ["accountId"], + group: ["account_id"], + where: { allocatedExecutionId: null, status: MoneriumFiatDepositStatus.Minted } + }); + for (const row of unallocated) { + candidates.add(row.accountId); + } + + const balanceCheckAccounts = await MoneriumAccount.findAll({ + where: { + dormantSince: null, + // Sequelize renders an empty NOT IN as NOT IN (NULL), which matches nothing. + ...(candidates.size > 0 ? { id: { [Op.notIn]: [...candidates] } } : {}), + status: { [Op.in]: [MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active] } + } + }); + for (const account of balanceCheckAccounts) { + try { + const forwarder = account.forwarderAddress as Address; + const { eure } = await getForwarderImmutables(forwarder); + const balance = await getPublicClient().readContract({ + abi: erc20Abi, + address: eure, + args: [forwarder], + functionName: "balanceOf" + }); + if (balance > 0n) { + candidates.add(account.id); + } + } catch (error) { + logger.warn(`monerium-b2b: balance check failed for account ${account.id}:`, error); + } + } + + return [...candidates]; + } +} + +export default MoneriumB2bWorker; diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 43b161ed1..803061161 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -174,6 +174,10 @@ interface Config { attestorPrivateKey: string | undefined; clientId: string; clientSecret: string; + guardianPrivateKey: string | undefined; + keeperPrivateKey: string | undefined; + privateRpcUrl: string | undefined; + rpcUrl: string | undefined; webhookSecret: string; }; subscanApiKey: string | undefined; @@ -248,6 +252,14 @@ export const config: Config = { attestorPrivateKey: process.env.MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, clientId: process.env.MONERIUM_B2B_CLIENT_ID || "", clientSecret: process.env.MONERIUM_B2B_CLIENT_SECRET || "", + // Dormancy-gate pause key (guardian on the factory/forwarders). Distinct from the + // keeper and attestor keys by design; unset = log-only mode for the dormancy gate. + guardianPrivateKey: process.env.MONERIUM_B2B_GUARDIAN_PRIVATE_KEY, + keeperPrivateKey: process.env.MONERIUM_B2B_KEEPER_PRIVATE_KEY, + // Private-orderflow submission endpoint (e.g. https://rpc.flashbots.net); when unset + // the keeper falls back to the public RPC and logs a warning (see chain.ts). + privateRpcUrl: process.env.MONERIUM_B2B_PRIVATE_RPC_URL, + rpcUrl: process.env.MONERIUM_B2B_RPC_URL, webhookSecret: process.env.MONERIUM_B2B_WEBHOOK_SECRET || "" }, mykobo: { diff --git a/apps/api/src/database/migrations/052-monerium-keeper-chain-state.ts b/apps/api/src/database/migrations/052-monerium-keeper-chain-state.ts new file mode 100644 index 000000000..e96c025e9 --- /dev/null +++ b/apps/api/src/database/migrations/052-monerium-keeper-chain-state.ts @@ -0,0 +1,25 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Keeper chain state for the B2B onramp (docs/prd/monerium-b2b-implementation-plan.md §3): +// - monerium_chain_cursors: persisted getLogs cursors for the poll-based EURe mint +// watcher, keyed by watcher name (one row per watcher+chain). +// - monerium_fiat_deposits.block_number: the mint block, required by the R04 attribution +// rule ("mint block <= execution block"); blockHash alone cannot be compared. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("monerium_chain_cursors", { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + last_block: { allowNull: false, type: DataTypes.BIGINT }, + name: { primaryKey: true, type: DataTypes.STRING(64) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + + await queryInterface.addColumn("monerium_fiat_deposits", "block_number", { + allowNull: true, + type: DataTypes.INTEGER + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_fiat_deposits", "block_number"); + await queryInterface.dropTable("monerium_chain_cursors"); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3d9c73e53..dbf02be9e 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,6 +14,7 @@ import registerPhaseHandlers from "./api/services/phases/register-handlers"; import { priceFeedService } from "./api/services/priceFeed.service"; import ApiClientEventsRetentionWorker from "./api/workers/api-client-events-retention.worker"; import CleanupWorker from "./api/workers/cleanup.worker"; +import MoneriumB2bWorker from "./api/workers/monerium-b2b.worker"; import RampRecoveryWorker from "./api/workers/ramp-recovery.worker"; import UnhandledPaymentWorker from "./api/workers/unhandled-payment.worker"; @@ -66,6 +67,7 @@ const initializeApp = async () => { new ApiClientEventsRetentionWorker().start(); new RampRecoveryWorker().start(); new UnhandledPaymentWorker().start(); + new MoneriumB2bWorker().start(); // Start AlfredPay limits refresh loop (daily; falls back to hardcoded if stale) AlfredpayLimitsService.getInstance().start(); diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 7f2cebbce..38ae9c7e7 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -6,6 +6,7 @@ import CustomerEntity from "./customerEntity.model"; import KycCase from "./kycCase.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; import MoneriumAccount from "./moneriumAccount.model"; +import MoneriumChainCursor from "./moneriumChainCursor.model"; import MoneriumConversionExecution from "./moneriumConversionExecution.model"; import MoneriumFiatDeposit from "./moneriumFiatDeposit.model"; import MoneriumWebhookEvent from "./moneriumWebhookEvent.model"; @@ -109,6 +110,7 @@ const models = { KycCase, MaintenanceSchedule, MoneriumAccount, + MoneriumChainCursor, MoneriumConversionExecution, MoneriumFiatDeposit, MoneriumWebhookEvent, diff --git a/apps/api/src/models/moneriumChainCursor.model.ts b/apps/api/src/models/moneriumChainCursor.model.ts new file mode 100644 index 000000000..42d224fa5 --- /dev/null +++ b/apps/api/src/models/moneriumChainCursor.model.ts @@ -0,0 +1,58 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// Persisted block cursor for the poll-based mint watcher (one row per watcher name, +// e.g. "eure-mints:1"). lastBlock is the highest block already scanned; the next run +// resumes at lastBlock + 1 so a crash between getLogs and processing re-scans rather +// than skips (deposit writes are idempotent via the mint-log unique index). +export interface MoneriumChainCursorAttributes { + name: string; + lastBlock: string; // BIGINT, stringified + createdAt: Date; + updatedAt: Date; +} + +type MoneriumChainCursorCreationAttributes = Optional; + +class MoneriumChainCursor + extends Model + implements MoneriumChainCursorAttributes +{ + declare name: string; + declare lastBlock: string; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumChainCursor.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + lastBlock: { + allowNull: false, + field: "last_block", + type: DataTypes.BIGINT + }, + name: { + primaryKey: true, + type: DataTypes.STRING(64) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + modelName: "MoneriumChainCursor", + sequelize, + tableName: "monerium_chain_cursors" + } +); + +export default MoneriumChainCursor; diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts index 9bb4cc205..7721fc608 100644 --- a/apps/api/src/models/moneriumFiatDeposit.model.ts +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -22,6 +22,7 @@ export interface MoneriumFiatDepositAttributes { txHash: string | null; logIndex: number | null; blockHash: string | null; + blockNumber: number | null; allocatedExecutionId: string | null; createdAt: Date; updatedAt: Date; @@ -29,7 +30,16 @@ export interface MoneriumFiatDepositAttributes { type MoneriumFiatDepositCreationAttributes = Optional< MoneriumFiatDepositAttributes, - "id" | "status" | "chainId" | "txHash" | "logIndex" | "blockHash" | "allocatedExecutionId" | "createdAt" | "updatedAt" + | "id" + | "status" + | "chainId" + | "txHash" + | "logIndex" + | "blockHash" + | "blockNumber" + | "allocatedExecutionId" + | "createdAt" + | "updatedAt" >; class MoneriumFiatDeposit @@ -46,6 +56,7 @@ class MoneriumFiatDeposit declare txHash: string | null; declare logIndex: number | null; declare blockHash: string | null; + declare blockNumber: number | null; declare allocatedExecutionId: string | null; declare createdAt: Date; declare updatedAt: Date; @@ -73,6 +84,13 @@ MoneriumFiatDeposit.init( field: "block_hash", type: DataTypes.STRING(66) }, + // Mint block, set by the mint watcher; the R04 attribution rule compares it to the + // execution block (docs/prd/monerium-b2b-implementation-plan.md §3). + blockNumber: { + allowNull: true, + field: "block_number", + type: DataTypes.INTEGER + }, chainId: { allowNull: true, field: "chain_id", diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 7630f2fc3..a81574871 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -7,7 +7,7 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e **Provider type:** on-ramp (EUR → USDC) **Fiat currencies:** EUR **Chains involved:** Ethereum (forwarder contracts, EURe/USDC) -**Modules:** `monerium-b2b/whitelabel-client.ts`, `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`) +**Modules:** `monerium-b2b/whitelabel-client.ts`, `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts` **API auth method:** OAuth client credentials (`MONERIUM_B2B_CLIENT_ID`/`MONERIUM_B2B_CLIENT_SECRET`) against `MONERIUM_B2B_API_URL` (sandbox `api.monerium.dev` by default); inbound webhooks authenticated by HMAC-SHA256 (`MONERIUM_B2B_WEBHOOK_SECRET`) ## Security Invariants @@ -24,6 +24,17 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e 10. **Client credentials are env-only and requests are bounded** — whitelabel API credentials come from env, all calls carry an explicit timeout, HTTPS base URLs only, and upstream failures surface as generic 502s without echoing provider response bodies. 11. **KYB submission is a guarded stub** — `submitKybData` throws 501 until the whitelabel KYB mechanism is contractually settled (deferred-decisions registry T3); no speculative identity-data path exists. +## Keeper + +The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox → mint watcher → per-account conversion executor → dormancy gate) holds signing keys and submits transactions; its invariants: + +1. **Three-way key separation** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`/`swapAndForward()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can move funds: `swapAndForward` only executes the contract-constrained oracle-checked swap to the client's own `destination`; `setGuardianPaused` is protective-only by contract invariant; the attestor signs the fixed link statement. All three are env-only and never logged. +2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. +3. **Execution record before send** — a `monerium_conversion_executions` row (status `pending`, `eureInRaw = min(balance, perSwapCap)`, snapshot destination) is durably committed BEFORE any transaction is broadcast, and the tx hash is recorded immediately after send. A crash therefore always leaves an auditable pending row, never an untracked on-chain swap; leftover pendings are resolved next cycle via receipt lookup (finalize) or declared failed (no hash: never sent; stale hash: timed out). +4. **Advisory-lock serialization** — all keeper database mutations (mint recording, execution slot check/creation, finalization, R04 allocation) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. +5. **Attribution is snapshot-based and idempotent (R04)** — on confirmation, unallocated minted deposits with mint block ≤ execution block are selected oldest-first up to `eureInRaw`, USDC attribution is pro-rata by `amount_raw` against `eureInRaw` with floor division and remainder to the largest deposit (unit-tested), and `allocated_execution_id` links them. Mint identity is the `(chain_id, tx_hash, log_index)` partial unique index, so watcher re-scans after a crash cannot double-record; non-Monerium EURe inflows become `unattr:`-prefixed deposit rows (R09) and are never presented as customer deposits. +6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). + ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | @@ -52,3 +63,6 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e - [ ] `submitKybData` still returns 501 unless registry item T3 has been resolved and this spec updated - [ ] HTTPS enforced for provider base URLs; timeouts configured on every provider call - [ ] Sandbox-verification TODOs resolved before production: exact `webhook-signature` digest encoding, delivery id field, upstream order-state vocabulary, EIP-191 vs raw link-hash variant (registry T4) +- [ ] Keeper, guardian, and attestor private keys are three distinct keys in production; none logged +- [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) +- [ ] Conversion execution rows are created before broadcast and every terminal row has status confirmed/failed with a cause; R04 allocation math covered by `conversion-executor.test.ts` From 8d1b42b2d105ef8d1ed473f3291901d13cc4143f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 16:48:55 +0200 Subject: [PATCH 14/59] G0 spike results: EUR/USD weekend gaps up to 48h, block-pinned liquidity baseline --- docs/prd/monerium-onramp-deferred-decisions.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md index 18a77cf37..a5eb666fa 100644 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -26,7 +26,7 @@ | P5 | Dormancy pause window (no successful forward → pause pending re-confirmation) | 60 days | Operational (backend-enforced via per-account pause), not immutable | | P6 | `minSwapAmount` floor / operational value | €25; must also be ≥ CEX min deposit per client | Floor immutable, operational value adjustable within bounds | | P7 | `perSwapCap` operational value + immutable ceiling | €10k / €50k | Availability parameter, not safety (minOut is safety) | -| P8 | `MAX_ORACLE_AGE` | 26 h | Pending G0 weekend-behavior check (T2) | +| P8 | `MAX_ORACLE_AGE` | 26 h → **recommend 52 h** | T2 answered (2026-07-17): feed updates through weekends but sparsely — observed gaps 32.6 h / 36.1 h / **48.0 h**. 26 h would revert most weekends; 52 h covers observed max + margin, weekend EUR/USD moves sit well inside the 100 bps slippage bound | | P9 | Notification confirmation depth | 32 blocks | Backend only | | P10 | Uniswap router pin (SwapRouter w/ deadline vs SwapRouter02 w/o) + EURC hop fee-tier re-verification | SwapRouter02 | G0 spike output | @@ -35,7 +35,8 @@ | # | Item | Owner | Status | |---|---|---|---| | T1 | **Monerium recovery-burn mechanism for contract addresses**: exact message/hash their recovery flow validates via EIP-1271, so the forwarder can whitelist it (compile-time constant `RECOVERY_HASH`). If unanswered by deploy time: ship without it — fallback-address recovery covers us; issuer backstop becomes best-effort | Monerium tech team (compliance punted) | **Asked? No — send follow-up** | -| T2 | Chainlink EUR/USD weekend behavior (heartbeat continues vs stops) → weekend policy | G0 spike | Open | +| T2 | Chainlink EUR/USD weekend behavior → weekend policy | G0 spike | **Answered 2026-07-17**: rounds observed on Sat/Sun (deviation-triggered), gaps up to 48 h; see P8. Weekend policy: execute normally with `MAX_ORACLE_AGE ≥ 52 h` | +| T6 | Liquidity baseline (review F12 reproducibility) | G0 spike | **Recorded 2026-07-17, mainnet block 25553101**, QuoterV2 on pinned path EURe→(500)→EURC→(500)→USDC: 1k → 1.14298, 5k → 1.14288, 10k → 1.14278, 25k → 1.14252 USDC/EURe (Chainlink same day 1.14410 — 25k within ~14 bps incl. 2×5 bps fees). Deeper than the 07-10 snapshot; €10k cap comfortable. Re-run at deploy + wire into monitoring (task 6) | | T3 | Corporate KYB mechanism under whitelabel: Monerium-run verification vs KYC-reliance (reliance requires licenses we may not hold) | Monerium MSA negotiation | Open — fold into G1 | | T4 | Whitelabel sandbox: verify EIP-1271 link works against a deployed forwarder E2E (G0 headline item) | Us | Open | | T5 | Whether Monerium rejects linking an address already linked to another profile (defense-in-depth question) | Monerium tech | Nice-to-have | From 3227baed53caef0b51b8dc30baff3b4121dae752 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 18:39:12 +0200 Subject: [PATCH 15/59] G0 spike: sandbox EIP-1271 attestor link VALIDATED (eip191 variant), IBAN issued to forwarder --- docs/prd/monerium-onramp-deferred-decisions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md index a5eb666fa..078d19fc3 100644 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -38,7 +38,7 @@ | T2 | Chainlink EUR/USD weekend behavior → weekend policy | G0 spike | **Answered 2026-07-17**: rounds observed on Sat/Sun (deviation-triggered), gaps up to 48 h; see P8. Weekend policy: execute normally with `MAX_ORACLE_AGE ≥ 52 h` | | T6 | Liquidity baseline (review F12 reproducibility) | G0 spike | **Recorded 2026-07-17, mainnet block 25553101**, QuoterV2 on pinned path EURe→(500)→EURC→(500)→USDC: 1k → 1.14298, 5k → 1.14288, 10k → 1.14278, 25k → 1.14252 USDC/EURe (Chainlink same day 1.14410 — 25k within ~14 bps incl. 2×5 bps fees). Deeper than the 07-10 snapshot; €10k cap comfortable. Re-run at deploy + wire into monitoring (task 6) | | T3 | Corporate KYB mechanism under whitelabel: Monerium-run verification vs KYC-reliance (reliance requires licenses we may not hold) | Monerium MSA negotiation | Open — fold into G1 | -| T4 | Whitelabel sandbox: verify EIP-1271 link works against a deployed forwarder E2E (G0 headline item) | Us | Open | +| T4 | Whitelabel sandbox: verify EIP-1271 link works against a deployed forwarder E2E (G0 headline item) | Us | **VALIDATED 2026-07-17**: Monerium sandbox accepted the attestor-signed link (HTTP 201, `state: linked`) on first attempt and issued an IBAN (state approved) — zero client interaction. Hash variant presented: **EIP-191** → narrow the contract to `LINK_HASH_191` only before audit (drop `LINK_HASH_RAW`; folded into review-r1 follow-ups). Sandbox artifacts (Sepolia): factory `0x82f4953CF3ACaa464b67f932AAF008af010a9376`, forwarder `0x67592847844958b455ae907D3Ef1EADBf6827fdc`, MockOracle `0x337dd479435aE2593c9B023B48617278c6AB34E3`, profile `d2de6768-b0e7-11f0-a4ad-fabb3106d2e3`, IBAN `EE08 7224 5745 6244 9516`. Client API notes: `POST /addresses` body `{address, chain, message, profile, signature}` confirmed; `GET /profiles` list 404s (use per-profile paths); `POST /ibans` is async 202 → poll. Remaining G0 sliver: simulate SEPA deposit → observe mint + webhook (mechanism TBD, likely sandbox dashboard) | | T5 | Whether Monerium rejects linking an address already linked to another profile (defense-in-depth question) | Monerium tech | Nice-to-have | ## G1 — written approval package to collect from Monerium From b575a74522cf89fa6fc2d8995fc2073c8ebbc12d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 18:47:38 +0200 Subject: [PATCH 16/59] Review-r1 dispositions: EIP-191-only + chainid binding (sandbox re-validated), remainder re-arm, gap tests --- .../services/monerium-b2b/attestor.test.ts | 50 +++++---- .../src/api/services/monerium-b2b/attestor.ts | 38 +++---- .../src/VortexForwarder.sol | 22 ++-- .../test/VortexForwarder.t.sol | 106 +++++++++++++++++- docs/prd/monerium-b2b-code-review-r1.md | 17 +++ .../monerium-eur-usdc-onramp-b2b-variant.md | 5 +- .../prd/monerium-onramp-deferred-decisions.md | 2 +- 7 files changed, 175 insertions(+), 65 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/attestor.test.ts b/apps/api/src/api/services/monerium-b2b/attestor.test.ts index 204be25bc..3f8a6801a 100644 --- a/apps/api/src/api/services/monerium-b2b/attestor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/attestor.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { Address, encodePacked, hashMessage, Hex, hexToBigInt, hexToNumber, keccak256, recoverAddress, slice, stringToBytes } from "viem"; +import { Address, encodePacked, hashMessage, Hex, hexToBigInt, hexToNumber, keccak256, recoverAddress, slice } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { config } from "../../../config/vars"; import { attestationBoundHash, attestorAddress, LINK_MESSAGE, linkMessageHash, signLinkAttestation } from "./attestor"; @@ -7,6 +7,7 @@ import { attestationBoundHash, attestorAddress, LINK_MESSAGE, linkMessageHash, s // Well-known test key (Foundry/Anvil account #0) — never a real attestor key. const TEST_KEY: Hex = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; const FORWARDER: Address = "0x1111111111111111111111111111111111111111"; +const CHAIN_ID = 11155111n; // Sepolia, matching the G0 sandbox validation // Malleability bound from VortexForwarder.isValidSignature (secp256k1 n/2). const HALF_ORDER = hexToBigInt("0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0"); @@ -22,19 +23,19 @@ afterAll(() => { }); describe("monerium-b2b attestor", () => { - it("derives LINK_HASH_191 and LINK_HASH_RAW exactly as the forwarder immutables", () => { + it("derives LINK_HASH_191 exactly as the forwarder immutable", () => { // The Solidity constant hardcodes "\x19Ethereum Signed Message:\n45" — LINK_MESSAGE must be 45 bytes. expect(Buffer.byteLength(LINK_MESSAGE, "utf8")).toBe(45); - expect(linkMessageHash("raw")).toBe(keccak256(stringToBytes(LINK_MESSAGE))); - expect(linkMessageHash("eip191")).toBe(keccak256(stringToBytes(`\x19Ethereum Signed Message:\n45${LINK_MESSAGE}`))); // Independent derivation via viem's EIP-191 implementation. expect(linkMessageHash()).toBe(hashMessage(LINK_MESSAGE)); }); - it("binds the signature to the forwarder address exactly like isValidSignature", async () => { - const attestation = await signLinkAttestation(FORWARDER); - // bound = keccak256(abi.encodePacked(address(this), hash)) — the contract-side recomputation. - const expectedBound = keccak256(encodePacked(["address", "bytes32"], [FORWARDER, hashMessage(LINK_MESSAGE)])); + it("binds the signature to chainid + forwarder address exactly like isValidSignature", async () => { + const attestation = await signLinkAttestation(CHAIN_ID, FORWARDER); + // bound = keccak256(abi.encodePacked(block.chainid, address(this), hash)) — contract-side recomputation. + const expectedBound = keccak256( + encodePacked(["uint256", "address", "bytes32"], [CHAIN_ID, FORWARDER, hashMessage(LINK_MESSAGE)]) + ); expect(attestation.boundHash).toBe(expectedBound); expect(attestation.linkHash).toBe(hashMessage(LINK_MESSAGE)); expect(attestation.message).toBe(LINK_MESSAGE); @@ -44,29 +45,30 @@ describe("monerium-b2b attestor", () => { expect(signer).toBe(attestorAddress()); }); - it("emits a 65-byte low-s signature with v in 27/28 for both hash variants", async () => { - for (const variant of ["eip191", "raw"] as const) { - const { boundHash, linkHash, signature } = await signLinkAttestation(FORWARDER, variant); - expect(signature.length).toBe(2 + 65 * 2); - const v = hexToNumber(slice(signature, 64, 65)); - expect([27, 28]).toContain(v); - const s = hexToBigInt(slice(signature, 32, 64)); - expect(s <= HALF_ORDER).toBe(true); - expect(boundHash).toBe(attestationBoundHash(FORWARDER, linkHash)); - expect(await recoverAddress({ hash: boundHash, signature })).toBe(privateKeyToAccount(TEST_KEY).address); - } + it("emits a 65-byte low-s signature with v in 27/28", async () => { + const { boundHash, linkHash, signature } = await signLinkAttestation(CHAIN_ID, FORWARDER); + expect(signature.length).toBe(2 + 65 * 2); + const v = hexToNumber(slice(signature, 64, 65)); + expect([27, 28]).toContain(v); + const s = hexToBigInt(slice(signature, 32, 64)); + expect(s <= HALF_ORDER).toBe(true); + expect(boundHash).toBe(attestationBoundHash(CHAIN_ID, FORWARDER, linkHash)); + expect(await recoverAddress({ hash: boundHash, signature })).toBe(privateKeyToAccount(TEST_KEY).address); }); - it("does not verify against a different forwarder address", async () => { - const { signature } = await signLinkAttestation(FORWARDER); - const otherBound = attestationBoundHash("0x2222222222222222222222222222222222222222", linkMessageHash()); - expect(await recoverAddress({ hash: otherBound, signature })).not.toBe(privateKeyToAccount(TEST_KEY).address); + it("does not verify against a different forwarder address or chain", async () => { + const { signature } = await signLinkAttestation(CHAIN_ID, FORWARDER); + const otherAddress = attestationBoundHash(CHAIN_ID, "0x2222222222222222222222222222222222222222", linkMessageHash()); + expect(await recoverAddress({ hash: otherAddress, signature })).not.toBe(privateKeyToAccount(TEST_KEY).address); + // Cross-chain replay: same address, different chainid must not recover the attestor. + const otherChain = attestationBoundHash(1n, FORWARDER, linkMessageHash()); + expect(await recoverAddress({ hash: otherChain, signature })).not.toBe(privateKeyToAccount(TEST_KEY).address); }); it("refuses to sign when the attestor key is not configured", async () => { config.moneriumB2b.attestorPrivateKey = undefined; try { - await expect(signLinkAttestation(FORWARDER)).rejects.toThrow("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY"); + await expect(signLinkAttestation(CHAIN_ID, FORWARDER)).rejects.toThrow("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY"); } finally { config.moneriumB2b.attestorPrivateKey = TEST_KEY; } diff --git a/apps/api/src/api/services/monerium-b2b/attestor.ts b/apps/api/src/api/services/monerium-b2b/attestor.ts index 3b15ffe08..8ffe1fcf6 100644 --- a/apps/api/src/api/services/monerium-b2b/attestor.ts +++ b/apps/api/src/api/services/monerium-b2b/attestor.ts @@ -6,10 +6,12 @@ import { config } from "../../../config/vars"; * Attestor signature construction for VortexForwarder address linking. * * Mirrors contracts/monerium-forwarder/src/VortexForwarder.sol `isValidSignature`: - * the forwarder returns the EIP-1271 magic value iff the presented hash is one of the - * two whitelisted link hashes (EIP-191 personal-message hash or raw keccak of - * LINK_MESSAGE) and the 65-byte (r,s,v; v in 27/28; low-s) signature recovers the - * ATTESTOR over `keccak256(abi.encodePacked(address(this), hash))`. + * the forwarder returns the EIP-1271 magic value iff the presented hash is the EIP-191 + * link hash (the raw-keccak variant was removed after the G0 sandbox validation on + * 2026-07-17 confirmed Monerium presents EIP-191) and the 65-byte (r,s,v; v in 27/28; + * low-s) signature recovers the ATTESTOR over + * `keccak256(abi.encodePacked(block.chainid, address(this), hash))` — chainid is part + * of the binding to prevent cross-chain replay (review r1 P2). * * The attestor key authorizes NOTHING beyond this fixed link statement — it can never * move funds (security-spec/05-integrations/monerium-b2b.md). @@ -17,8 +19,6 @@ import { config } from "../../../config/vars"; export const LINK_MESSAGE = "I hereby declare that I am the address owner."; -export type LinkHashVariant = "eip191" | "raw"; - export interface LinkAttestation { boundHash: Hex; linkHash: Hex; @@ -26,19 +26,16 @@ export interface LinkAttestation { signature: Hex; } -/** LINK_HASH_191 / LINK_HASH_RAW from the forwarder immutables. */ -export function linkMessageHash(variant: LinkHashVariant = "eip191"): Hex { - if (variant === "raw") { - return keccak256(stringToBytes(LINK_MESSAGE)); - } +/** LINK_HASH_191 from the forwarder immutables (EIP-191 personal-message hash). */ +export function linkMessageHash(): Hex { // hashMessage would work too; spelled out to match the Solidity constant byte for byte // (LINK_MESSAGE is 45 bytes, hence the fixed "\x19Ethereum Signed Message:\n45"). return keccak256(stringToBytes(`\x19Ethereum Signed Message:\n45${LINK_MESSAGE}`)); } -/** `bound = keccak256(abi.encodePacked(forwarderAddress, hash))` — the digest the attestor signs. */ -export function attestationBoundHash(forwarderAddress: Address, hash: Hex): Hex { - return keccak256(encodePacked(["address", "bytes32"], [forwarderAddress, hash])); +/** `bound = keccak256(abi.encodePacked(block.chainid, forwarderAddress, hash))`. */ +export function attestationBoundHash(chainId: bigint, forwarderAddress: Address, hash: Hex): Hex { + return keccak256(encodePacked(["uint256", "address", "bytes32"], [chainId, forwarderAddress, hash])); } function attestorPrivateKey(): Hex { @@ -59,17 +56,10 @@ export function attestorAddress(): Address { * Signs the bound digest directly (no extra EIP-191 prefix — the contract ecrecovers * the bound hash as-is). viem's signer emits canonical low-s signatures, so the * forwarder's malleability check passes. - * - * Defaults to the EIP-191 variant (personal_sign is what Monerium documents for the - * link message). TODO(sandbox/T4): confirm during the G0 whitelabel spike which hash - * Monerium's EIP-1271 validation presents and pin the variant. */ -export async function signLinkAttestation( - forwarderAddress: Address, - variant: LinkHashVariant = "eip191" -): Promise { - const linkHash = linkMessageHash(variant); - const boundHash = attestationBoundHash(forwarderAddress, linkHash); +export async function signLinkAttestation(chainId: bigint, forwarderAddress: Address): Promise { + const linkHash = linkMessageHash(); + const boundHash = attestationBoundHash(chainId, forwarderAddress, linkHash); const signature = await sign({ hash: boundHash, privateKey: attestorPrivateKey() }); return { boundHash, diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index 7c08a4ea2..f2baa1b56 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -85,7 +85,6 @@ contract VortexForwarder { /// exact hashing scheme is a G0 spike output (task 4); accepting both is safe /// because both encode only the fixed link message. bytes32 public immutable LINK_HASH_191; - bytes32 public immutable LINK_HASH_RAW; /// @dev Monerium issuer-recovery message hash (registry T1). bytes32(0) = disabled. /// When enabled, allows Monerium's recovery burn to validate against this @@ -182,7 +181,6 @@ contract VortexForwarder { POOL_FEE_EURC_USDC = cfg.poolFeeEurcUsdc; RECOVERY_HASH = cfg.recoveryHash; - LINK_HASH_RAW = keccak256(bytes(LINK_MESSAGE)); LINK_HASH_191 = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n45", LINK_MESSAGE)); // Brick the implementation itself; only clones can be initialized. @@ -229,12 +227,15 @@ contract VortexForwarder { /// @notice Constrained EIP-1271: validates ONLY the fixed Monerium link message /// (and, if enabled via RECOVERY_HASH, Monerium's recovery message), - /// signed by ATTESTOR over keccak256(address(this), hash). - /// Binding to address(this) prevents replay across clones; restricting to - /// ATTESTOR prevents third parties from linking this address to a foreign - /// Monerium profile. + /// signed by ATTESTOR over keccak256(chainid, address(this), hash). + /// Binding to chainid + address(this) prevents replay across clones AND + /// across chains (review r1 P2); restricting to ATTESTOR prevents third + /// parties from linking this address to a foreign Monerium profile. + /// Only the EIP-191 hash variant is accepted: G0 sandbox validation + /// (2026-07-17) confirmed Monerium presents the EIP-191 hash, so the + /// raw-keccak fallback was removed to minimize surface. function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { - bool isLink = (hash == LINK_HASH_191 || hash == LINK_HASH_RAW); + bool isLink = hash == LINK_HASH_191; bool isRecovery = (RECOVERY_HASH != bytes32(0) && hash == RECOVERY_HASH); if (!isLink && !isRecovery) return EIP1271_FAIL; if (signature.length != 65) return EIP1271_FAIL; @@ -252,7 +253,7 @@ contract VortexForwarder { if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) return EIP1271_FAIL; if (v != 27 && v != 28) return EIP1271_FAIL; - bytes32 bound = keccak256(abi.encodePacked(address(this), hash)); + bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(this), hash)); address signer = ecrecover(bound, v, r, s); if (signer == address(0) || signer != ATTESTOR) return EIP1271_FAIL; return EIP1271_MAGIC; @@ -330,7 +331,10 @@ contract VortexForwarder { uint256 forwarded = USDC.balanceOf(address(this)); _transfer(USDC, destination, forwarded); - strandedSince = 0; + // Re-arm instead of clearing when a perSwapCap remainder stays behind (review r1 + // P2): otherwise the remainder's dead-man/permissionless timers would silently + // restart from zero only after a fresh poke(). + strandedSince = EURE.balanceOf(address(this)) >= FACTORY.MIN_SWAP_FLOOR() ? uint64(block.timestamp) : 0; emit SwapExecuted(msg.sender, amountIn, usdcReceived, fee, forwarded); } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 0c803a203..43728a7b7 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -145,7 +145,7 @@ contract VortexForwarderTest is Test { // ---------------------------------------------------------------- helpers function _attest(address forwarder, bytes32 hash) internal view returns (bytes memory) { - bytes32 bound = keccak256(abi.encodePacked(forwarder, hash)); + bytes32 bound = keccak256(abi.encodePacked(block.chainid, forwarder, hash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); return abi.encodePacked(r, s, v); } @@ -156,11 +156,80 @@ contract VortexForwarderTest is Test { // ---------------------------------------------------------------- EIP-1271 - function test_linkSignature_valid_bothHashSchemes() public view { + function test_linkSignature_valid_eip191Only() public view { bytes32 h191 = fwd.LINK_HASH_191(); - bytes32 hRaw = fwd.LINK_HASH_RAW(); assertEq(fwd.isValidSignature(h191, _attest(address(fwd), h191)), bytes4(0x1626ba7e)); - assertEq(fwd.isValidSignature(hRaw, _attest(address(fwd), hRaw)), bytes4(0x1626ba7e)); + // Raw-keccak variant was removed after G0 sandbox validation confirmed Monerium + // presents the EIP-191 hash; it must now be rejected even with a valid attestor sig. + bytes32 hRaw = keccak256(bytes("I hereby declare that I am the address owner.")); + assertEq(fwd.isValidSignature(hRaw, _attest(address(fwd), hRaw)), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsCrossChainReplay() public { + bytes32 h = fwd.LINK_HASH_191(); + bytes memory sig = _attest(address(fwd), h); // bound to current chainid + vm.chainId(999); + assertEq(fwd.isValidSignature(h, sig), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsMalleatedAndMalformed() public view { + bytes32 h = fwd.LINK_HASH_191(); + bytes memory good = _attest(address(fwd), h); + // Malleate: s' = n - s, v' = flipped — same ECDSA validity, must be rejected. + (bytes32 r, bytes32 s, uint8 v) = (bytes32(0), bytes32(0), 0); + assembly { + r := mload(add(good, 0x20)) + s := mload(add(good, 0x40)) + v := byte(0, mload(add(good, 0x60))) + } + uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + bytes memory malleated = abi.encodePacked(r, bytes32(n - uint256(s)), v == 27 ? uint8(28) : uint8(27)); + assertEq(fwd.isValidSignature(h, malleated), bytes4(0xffffffff)); + // Wrong lengths. + assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s)), bytes4(0xffffffff)); + assertEq(fwd.isValidSignature(h, abi.encodePacked(good, uint8(1))), bytes4(0xffffffff)); + // Bad v. + assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s, uint8(29))), bytes4(0xffffffff)); + } + + function test_recoveryHash_enabledBranch() public { + bytes32 recoveryHash = keccak256("monerium-recovery-message-placeholder"); + VortexForwarderFactory f2 = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(router), + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 26 hours, + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: SWEEP_DELAY, + triggerDelay: TRIGGER_DELAY, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: recoveryHash + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(8)))); + // Recovery hash validates with attestor binding; link still validates; others fail. + bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(fwd2), recoveryHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); + assertEq(fwd2.isValidSignature(recoveryHash, abi.encodePacked(r, s, v)), bytes4(0x1626ba7e)); + bytes32 h191 = fwd2.LINK_HASH_191(); + bytes32 bound191 = keccak256(abi.encodePacked(block.chainid, address(fwd2), h191)); + (v, r, s) = vm.sign(attestorPk, bound191); + assertEq(fwd2.isValidSignature(h191, abi.encodePacked(r, s, v)), bytes4(0x1626ba7e)); + bytes32 evil = keccak256("anything else"); + bytes32 boundEvil = keccak256(abi.encodePacked(block.chainid, address(fwd2), evil)); + (v, r, s) = vm.sign(attestorPk, boundEvil); + assertEq(fwd2.isValidSignature(evil, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); } function test_linkHash191_matchesEip191OfFixedMessage() public view { @@ -176,7 +245,7 @@ contract VortexForwarderTest is Test { function test_linkSignature_rejectsWrongSigner() public { bytes32 h = fwd.LINK_HASH_191(); - bytes32 bound = keccak256(abi.encodePacked(address(fwd), h)); + bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(fwd), h)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xBAD, bound); assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); } @@ -258,6 +327,33 @@ contract VortexForwarderTest is Test { assertEq(usdc.balanceOf(destination), 1_130e6); } + function test_swapAndForward_revertsOnZeroOrNegativePrice() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + oracle.set(0, block.timestamp); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InvalidPrice.selector); + fwd.swapAndForward(); + oracle.set(-1, block.timestamp); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InvalidPrice.selector); + fwd.swapAndForward(); + } + + /// Review r1 P2: a perSwapCap remainder must keep its stranding timers armed — + /// the swap re-arms the marker rather than clearing it when balance stays >= floor. + function test_swapAndForward_reArmsMarkerForCapRemainder() public { + _fund(15_000e18); // cap is 10k + fwd.poke(); + assertGt(fwd.strandedSince(), 0); + router.setNextOut(11_290e6); + skip(1 hours); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(eure.balanceOf(address(fwd)), 5_000e18); + assertEq(fwd.strandedSince(), block.timestamp, "remainder must stay armed (fresh timestamp)"); + } + function test_swapAndForward_respectsPerSwapCap() public { _fund(15_000e18); // cap is 10k // minOut for 10k at 1.14*0.99 = 11286 USDC diff --git a/docs/prd/monerium-b2b-code-review-r1.md b/docs/prd/monerium-b2b-code-review-r1.md index 5f333af91..96719957f 100644 --- a/docs/prd/monerium-b2b-code-review-r1.md +++ b/docs/prd/monerium-b2b-code-review-r1.md @@ -155,3 +155,20 @@ Named, missing tests (each maps to a finding or an untested branch): - Deploy-time `destination` correctness is the documented S0 provisioning trust (variant §4, plan R01); the contract cannot verify it and relies on the off-chain manifest + client confirmation. Correctly restricted post-deploy. - Reentrancy: EURe/USDC are not ERC-777; all fund-movers are `nonReentrant`; a hooked token could reenter only ungated no-op functions (`poke`) with no harmful effect. The router-reentrancy guard is test-covered. - Registry placeholders (fees, timings, T1–T5) are known-open and not reported as findings. + +--- + +## Author dispositions (2026-07-17) + +| Finding | Disposition | Resolution | +|---|---|---| +| F1 (P1) guardian disarms dead-man via minSwapAmount raise | **Fixed** (commit eff320f68) | `poke()` arms/clears against immutable `MIN_SWAP_FLOOR`; regression test added | +| P2: no chainid in EIP-1271 binding | **Fixed** | Binding is now `keccak256(chainid ‖ address(this) ‖ hash)`; cross-chain replay test added; backend attestor updated; **re-validated against Monerium sandbox (201, linked)** | +| P2: two accepted link-hash variants | **Fixed** | Narrowed to `LINK_HASH_191` only — G0 sandbox confirmed Monerium presents EIP-191; raw-variant rejection tested | +| P2: strandedSince reset discards perSwapCap remainder | **Fixed** | Swap re-arms the marker when post-swap balance ≥ floor; test added | +| P2: oracle round-completeness (answeredInRound) | **Rejected** | `answeredInRound` is deprecated by Chainlink for OCR feeds; `answer > 0` + `updatedAt` staleness ceiling is the current recommended validation. Zero/negative-answer test added | +| P2: permissionless swap path MEV-exposed | **Accepted-documented** | Bounded by oracle `minOut` by design; the permissionless path is a liveness fallback, not the normal path. Noted in security spec | +| P2: spec/code drift on attestation digest encoding | **Fixed** | Variant doc §3.3 sketch aligned with code (encodePacked, chainid, single hash) | +| P2: RECOVERY_HASH non-custody depends on parameterless recovery message | **Accepted** | Requirement added to registry T1: enable only if Monerium's recovery message is parameterless (or parameters are payout-neutral); else keep disabled | +| P2: EURe compliance-freeze behavior unmodeled | **Accepted-documented** | Added to variant doc failure notes: issuer freeze ⇒ nothing moves until resolved; inherent to e-money tokens | +| Test gaps (11 named) | **Closed (9) / N/A (2)** | Added: threshold-raise regression, RECOVERY_HASH-enabled branch, oracle answer≤0, 1271 malleability + length + bad-v, cross-chain replay, raw-variant rejection, cap-remainder re-arm. N/A: EURe-transfer-restriction assumption (documented instead), fee-rounding edge (covered by existing pro-rata unit tests backend-side) | diff --git a/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md b/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md index ff1726407..4aaf44788 100644 --- a/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md +++ b/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md @@ -56,9 +56,10 @@ bytes32 constant LINK_HASH = /* EIP-191 hash of the fixed Monerium link message function isValidSignature(bytes32 hash, bytes calldata sig) external view returns (bytes4) { if (hash != LINK_HASH) return 0xffffffff; - // attestor signs keccak256(address(this), LINK_HASH): no cross-contract replay, + // attestor signs keccak256(chainid, address(this), LINK_HASH): no cross-contract + // and no cross-chain replay, // and no third party can link this contract to a foreign Monerium profile - address signer = ECDSA.recover(keccak256(abi.encode(address(this), LINK_HASH)), sig); + address signer = ECDSA.recover(keccak256(abi.encodePacked(block.chainid, address(this), LINK_HASH)), sig); return signer == VORTEX_ATTESTOR ? bytes4(0x1626ba7e) : bytes4(0xffffffff); } ``` diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md index 078d19fc3..589d8acce 100644 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -34,7 +34,7 @@ | # | Item | Owner | Status | |---|---|---|---| -| T1 | **Monerium recovery-burn mechanism for contract addresses**: exact message/hash their recovery flow validates via EIP-1271, so the forwarder can whitelist it (compile-time constant `RECOVERY_HASH`). If unanswered by deploy time: ship without it — fallback-address recovery covers us; issuer backstop becomes best-effort | Monerium tech team (compliance punted) | **Asked? No — send follow-up** | +| T1 | **Monerium recovery-burn mechanism for contract addresses**: exact message/hash their recovery flow validates via EIP-1271, so the forwarder can whitelist it (compile-time constant `RECOVERY_HASH`). If unanswered by deploy time: ship without it — fallback-address recovery covers us; issuer backstop becomes best-effort. **Requirement (review r1)**: enable only if the recovery message is parameterless or payout-neutral — a parameterized message validated by our attestor would grant Vortex disposal discretion | Monerium tech team (compliance punted) | **Asked? No — send follow-up** | | T2 | Chainlink EUR/USD weekend behavior → weekend policy | G0 spike | **Answered 2026-07-17**: rounds observed on Sat/Sun (deviation-triggered), gaps up to 48 h; see P8. Weekend policy: execute normally with `MAX_ORACLE_AGE ≥ 52 h` | | T6 | Liquidity baseline (review F12 reproducibility) | G0 spike | **Recorded 2026-07-17, mainnet block 25553101**, QuoterV2 on pinned path EURe→(500)→EURC→(500)→USDC: 1k → 1.14298, 5k → 1.14288, 10k → 1.14278, 25k → 1.14252 USDC/EURe (Chainlink same day 1.14410 — 25k within ~14 bps incl. 2×5 bps fees). Deeper than the 07-10 snapshot; €10k cap comfortable. Re-run at deploy + wire into monitoring (task 6) | | T3 | Corporate KYB mechanism under whitelabel: Monerium-run verification vs KYC-reliance (reliance requires licenses we may not hold) | Monerium MSA negotiation | Open — fold into G1 | From 7b28b469a7098ae75edeff82d53ec20fdddaa437 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 18:48:37 +0200 Subject: [PATCH 17/59] Registry: record hardened-binding re-validation artifacts --- docs/prd/monerium-onramp-deferred-decisions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md index 589d8acce..2c5cc6b78 100644 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -38,7 +38,7 @@ | T2 | Chainlink EUR/USD weekend behavior → weekend policy | G0 spike | **Answered 2026-07-17**: rounds observed on Sat/Sun (deviation-triggered), gaps up to 48 h; see P8. Weekend policy: execute normally with `MAX_ORACLE_AGE ≥ 52 h` | | T6 | Liquidity baseline (review F12 reproducibility) | G0 spike | **Recorded 2026-07-17, mainnet block 25553101**, QuoterV2 on pinned path EURe→(500)→EURC→(500)→USDC: 1k → 1.14298, 5k → 1.14288, 10k → 1.14278, 25k → 1.14252 USDC/EURe (Chainlink same day 1.14410 — 25k within ~14 bps incl. 2×5 bps fees). Deeper than the 07-10 snapshot; €10k cap comfortable. Re-run at deploy + wire into monitoring (task 6) | | T3 | Corporate KYB mechanism under whitelabel: Monerium-run verification vs KYC-reliance (reliance requires licenses we may not hold) | Monerium MSA negotiation | Open — fold into G1 | -| T4 | Whitelabel sandbox: verify EIP-1271 link works against a deployed forwarder E2E (G0 headline item) | Us | **VALIDATED 2026-07-17**: Monerium sandbox accepted the attestor-signed link (HTTP 201, `state: linked`) on first attempt and issued an IBAN (state approved) — zero client interaction. Hash variant presented: **EIP-191** → narrow the contract to `LINK_HASH_191` only before audit (drop `LINK_HASH_RAW`; folded into review-r1 follow-ups). Sandbox artifacts (Sepolia): factory `0x82f4953CF3ACaa464b67f932AAF008af010a9376`, forwarder `0x67592847844958b455ae907D3Ef1EADBf6827fdc`, MockOracle `0x337dd479435aE2593c9B023B48617278c6AB34E3`, profile `d2de6768-b0e7-11f0-a4ad-fabb3106d2e3`, IBAN `EE08 7224 5745 6244 9516`. Client API notes: `POST /addresses` body `{address, chain, message, profile, signature}` confirmed; `GET /profiles` list 404s (use per-profile paths); `POST /ibans` is async 202 → poll. Remaining G0 sliver: simulate SEPA deposit → observe mint + webhook (mechanism TBD, likely sandbox dashboard) | +| T4 | Whitelabel sandbox: verify EIP-1271 link works against a deployed forwarder E2E (G0 headline item) | Us | **VALIDATED 2026-07-17**: Monerium sandbox accepted the attestor-signed link (HTTP 201, `state: linked`) on first attempt and issued an IBAN (state approved) — zero client interaction. Hash variant presented: **EIP-191** → narrow the contract to `LINK_HASH_191` only before audit (drop `LINK_HASH_RAW`; folded into review-r1 follow-ups). Sandbox artifacts (Sepolia): factory `0x82f4953CF3ACaa464b67f932AAF008af010a9376`, forwarder `0x67592847844958b455ae907D3Ef1EADBf6827fdc`, MockOracle `0x337dd479435aE2593c9B023B48617278c6AB34E3`, profile `d2de6768-b0e7-11f0-a4ad-fabb3106d2e3`, IBAN `EE08 7224 5745 6244 9516`. Client API notes: `POST /addresses` body `{address, chain, message, profile, signature}` confirmed; `GET /profiles` list 404s (use per-profile paths); `POST /ibans` is async 202 → poll. **Re-validated with hardened binding** (EIP-191-only + chainid, review-r1 fixes) same day: factory2 `0xcBE354e847bF597513148918E7EbDff72aC75842`, forwarder2 `0xD7444AB7270A142227Fe659D63873ABdc8AF9b72`, link 201. Remaining G0 sliver: simulate SEPA deposit → observe mint + webhook (dashboard button at sandbox.monerium.dev → Receive → "Simulate bank transfer" — needs Marcel's dashboard login, one click) | | T5 | Whether Monerium rejects linking an address already linked to another profile (defense-in-depth question) | Monerium tech | Nice-to-have | ## G1 — written approval package to collect from Monerium From 3177984f0e83cfdbf36bddb3f6a60c111961e390 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 17 Jul 2026 19:13:25 +0200 Subject: [PATCH 18/59] Ops deliverables: manifest generator/verifier, monitoring pass, runbooks, terms inputs --- .../services/monerium-b2b/monitoring.test.ts | 172 +++++++ .../api/services/monerium-b2b/monitoring.ts | 457 ++++++++++++++++++ .../monerium-b2b/whitelabel-client.ts | 43 +- .../src/api/workers/monerium-b2b.worker.ts | 26 +- contracts/monerium-forwarder/README.md | 28 +- ...354e847bF597513148918E7EbDff72aC75842.json | 65 +++ contracts/monerium-forwarder/package.json | 9 +- .../script/generate-manifest.ts | 91 ++++ .../script/manifest-core.ts | 416 ++++++++++++++++ .../script/verify-manifest.ts | 211 ++++++++ docs/prd/monerium-b2b-terms-inputs.md | 106 ++++ docs/runbooks/monerium-b2b-dormancy.md | 64 +++ docs/runbooks/monerium-b2b-incident.md | 112 +++++ docs/runbooks/monerium-b2b-onboarding.md | 114 +++++ .../05-integrations/monerium-b2b.md | 14 +- 15 files changed, 1902 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/api/services/monerium-b2b/monitoring.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/monitoring.ts create mode 100644 contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json create mode 100644 contracts/monerium-forwarder/script/generate-manifest.ts create mode 100644 contracts/monerium-forwarder/script/manifest-core.ts create mode 100644 contracts/monerium-forwarder/script/verify-manifest.ts create mode 100644 docs/prd/monerium-b2b-terms-inputs.md create mode 100644 docs/runbooks/monerium-b2b-dormancy.md create mode 100644 docs/runbooks/monerium-b2b-incident.md create mode 100644 docs/runbooks/monerium-b2b-onboarding.md diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts new file mode 100644 index 000000000..79b108497 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import { + classifyStranding, + computeQuoteImpactBps, + detectConfigDrift, + diffAssociation, + eip1167RuntimeCode, + normalizeIban, + STRANDED_WARN_MS +} from "./monitoring"; + +// Pure monitoring logic (implementation plan D3): quote-impact math against the T6 +// liquidity baseline, stranding severity, association-diff detection (S1 detective +// control) and config-drift classification (R07). No chain or API involved. + +const EUR = 10n ** 18n; +const USDC = 10n ** 6n; + +describe("computeQuoteImpactBps", () => { + // T6 baseline (registry, mainnet block 25553101): Chainlink 1.14410, QuoterV2 + // 10k EURe -> 1.14278 USDC/EURe. Impact vs oracle: (11441 - 11427.8) / 11441 = 11.5 bps. + const CHAINLINK_EUR_USD = 114410000n; // 8 decimals + + it("matches the T6 baseline impact at 10k EURe", () => { + const amountIn = 10_000n * EUR; + const quoted = 11_427_800_000n; // 10_000 * 1.14278 in USDC 6dp + expect(computeQuoteImpactBps(amountIn, quoted, CHAINLINK_EUR_USD, 8)).toBe(11); + }); + + it("returns 0 for a quote exactly at the oracle rate", () => { + const amountIn = 1_000n * EUR; + const quoted = 1_144_100_000n; // 1_000 * 1.14410 + expect(computeQuoteImpactBps(amountIn, quoted, CHAINLINK_EUR_USD, 8)).toBe(0); + }); + + it("is negative when the quote beats the oracle", () => { + const amountIn = 1_000n * EUR; + expect(computeQuoteImpactBps(amountIn, 1_150n * USDC, CHAINLINK_EUR_USD, 8)).toBeLessThan(0); + }); + + it("flags a pause-threshold breach above SLIPPAGE_BPS", () => { + const amountIn = 25n * EUR; // minSwapAmount placeholder (registry P6) + const expectedOut = (amountIn * CHAINLINK_EUR_USD) / 10n ** 20n; + const quoted = (expectedOut * 9_850n) / 10_000n; // 150 bps impact + expect(computeQuoteImpactBps(amountIn, quoted, CHAINLINK_EUR_USD, 8)).toBeGreaterThan(100); // > P1 SLIPPAGE_BPS + }); + + it("handles a zero-ish expected output without dividing by zero", () => { + expect(computeQuoteImpactBps(0n, 0n, CHAINLINK_EUR_USD, 8)).toBe(0); + }); +}); + +describe("classifyStranding", () => { + const TRIGGER_DELAY = 86_400n; // 24h, registry P4 placeholder + const now = 1_800_000_000_000; // fixed epoch ms + + const armedAt = (msAgo: number): bigint => BigInt(Math.floor((now - msAgo) / 1000)); + + it("is ok when the marker is not armed", () => { + expect(classifyStranding(0n, TRIGGER_DELAY, now)).toBe("ok"); + }); + + it("is ok within the warn window", () => { + expect(classifyStranding(armedAt(60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("ok"); + }); + + it("warns after 12h", () => { + expect(classifyStranding(armedAt(STRANDED_WARN_MS + 60_000), TRIGGER_DELAY, now)).toBe("warn"); + }); + + it("errors past TRIGGER_DELAY", () => { + expect(classifyStranding(armedAt(25 * 60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("error"); + }); +}); + +describe("diffAssociation", () => { + const FORWARDER = "0xD7444AB7270A142227Fe659D63873ABdc8AF9b72"; + const IBAN = "EE08 7224 5745 6244 9516"; + const db = { forwarderAddress: FORWARDER, iban: IBAN }; + + it("reports no changes when the live state matches (case- and space-insensitively)", () => { + const live = { + ibans: [{ address: FORWARDER.toLowerCase(), iban: "ee08722457456244 9516" }], + profileAddresses: [FORWARDER.toLowerCase()] + }; + expect(diffAssociation(db, live)).toEqual([]); + }); + + it("detects the forwarder being unlinked", () => { + const changes = diffAssociation(db, { ibans: [{ address: FORWARDER, iban: IBAN }], profileAddresses: [] }); + expect(changes).toContain(`forwarder ${FORWARDER} is no longer linked to the profile`); + }); + + it("detects a new address linked to the profile", () => { + const intruder = "0x9999999999999999999999999999999999999999"; + const changes = diffAssociation(db, { + ibans: [{ address: FORWARDER, iban: IBAN }], + profileAddresses: [FORWARDER, intruder] + }); + expect(changes).toEqual([`unexpected address linked to the profile: ${intruder}`]); + }); + + it("detects the IBAN moving to another address (PATCH /ibans scenario)", () => { + const elsewhere = "0x8888888888888888888888888888888888888888"; + const changes = diffAssociation(db, { + ibans: [{ address: elsewhere, iban: IBAN }], + profileAddresses: [FORWARDER] + }); + expect(changes).toEqual([`IBAN ${IBAN} moved to address ${elsewhere}`]); + }); + + it("detects the IBAN disappearing", () => { + const changes = diffAssociation(db, { ibans: [], profileAddresses: [FORWARDER] }); + expect(changes).toEqual([`IBAN ${IBAN} no longer exists at Monerium`]); + }); + + it("detects an unrecorded IBAN on the forwarder", () => { + const other = "DE89370400440532013000"; + const changes = diffAssociation( + { forwarderAddress: FORWARDER, iban: null }, + { ibans: [{ address: FORWARDER, iban: other }], profileAddresses: [FORWARDER] } + ); + expect(changes).toEqual([`unrecorded IBAN issued for the forwarder: ${other}`]); + }); +}); + +describe("normalizeIban", () => { + it("strips whitespace and uppercases", () => { + expect(normalizeIban(" ee08 7224 5745\t6244 9516 ")).toBe("EE087224574562449516"); + }); +}); + +describe("detectConfigDrift", () => { + const base = { + destination: "0x1111111111111111111111111111111111111111", + fallbackAddress: "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", + feeBps: 0 + }; + + it("reports nothing when the chain matches the db (case-insensitively)", () => { + const onchain = { ...base, destination: base.destination.toLowerCase() }; + expect(detectConfigDrift(base, onchain)).toEqual({ errors: [], ownerAuthorizedUpdates: {} }); + }); + + it("classifies destination/fallback changes as owner-authorized updates (R07)", () => { + const onchain = { + ...base, + destination: "0x4444444444444444444444444444444444444444", + fallbackAddress: "0x5555555555555555555555555555555555555555" + }; + const drift = detectConfigDrift(base, onchain); + expect(drift.errors).toEqual([]); + expect(drift.ownerAuthorizedUpdates).toEqual({ + destination: onchain.destination, + fallbackAddress: onchain.fallbackAddress + }); + }); + + it("classifies a feeBps change as an error, never a reconciliation", () => { + const drift = detectConfigDrift(base, { ...base, feeBps: 50 }); + expect(drift.errors).toEqual(["immutable feeBps mismatch: db=0 chain=50"]); + expect(drift.ownerAuthorizedUpdates).toEqual({}); + }); +}); + +describe("eip1167RuntimeCode", () => { + it("produces the canonical minimal-proxy runtime code for an implementation", () => { + expect(eip1167RuntimeCode("0x7e1c653CaAFCa44258d8680B09F42a33475504a9")).toBe( + "0x363d3d373d3d3d363d737e1c653caafca44258d8680b09f42a33475504a95af43d82803e903d91602b57fd5bf3" + ); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts new file mode 100644 index 000000000..b79ea2717 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -0,0 +1,457 @@ +import { Op } from "sequelize"; +import { Address, encodePacked, Hex, parseAbi } from "viem"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import { erc20Abi, factoryAbi, forwarderAbi, getChainId, getForwarderImmutables, getPublicClient } from "./chain"; +import { getProfileAddresses, listIbans } from "./whitelabel-client"; + +/** + * Monitoring pass for the Monerium B2B onramp (implementation plan D3 / phase 3), run + * from the keeper worker. Four read-only monitors, alerting via the standard logger: + * + * 1. Executable-depth check (main PRD §7.4, T6 follow-up): QuoterV2 static quote on the + * pinned EURe->EURC->USDC path at perSwapCap and minSwapAmount sizes vs the + * Chainlink EUR/USD rate. Impact above SLIPPAGE_BPS at minSwapAmount size is the + * PAUSE THRESHOLD (error-level -> engage guardian pause per the incident runbook); + * at perSwapCap size it is an early warning. Mainnet-only (QuoterV2 pin). + * 2. Stranded-balance monitor: forwarders whose on-chain stranding marker (R03) has + * been armed for more than STRANDED_WARN_MS warn; past TRIGGER_DELAY (the + * permissionless-trigger delay, registry P4) they error — the keeper should have + * converted long before either. + * 3. Association monitor (S1 detective control, trust model in the b2b-variant doc): + * re-reads the linked-address and IBAN state from the Monerium API per active + * account and alerts on ANY divergence from the DB record (IBAN moved, new address + * linked). Vortex holds the whitelabel credentials, so association changes cannot + * be prevented client-side — only detected. + * 4. Config reconciliation (manifest re-verification, R07): re-reads per-clone config + * and clone bytecode. destination/fallbackAddress changes are owner-authorized by + * construction (`onlyFallback` in the contract) — they are reconciled into the DB + * and logged, not alarmed. feeBps/bytecode/registration drift is an incident. + * + * None of these monitors hold keys or send transactions; they are detection-only. + */ + +/** Uniswap V3 QuoterV2 on Ethereum mainnet (the pinned quoting contract, PRD §7.4). */ +export const MAINNET_QUOTER_V2: Address = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"; + +/** Stranding marker armed longer than this warns (the keeper converts within minutes normally). */ +export const STRANDED_WARN_MS = 12 * 60 * 60 * 1000; + +/** Full monitoring pass at most this often (the worker cycles every minute). */ +const MONITORING_INTERVAL_MS = 30 * 60_000; + +const quoterV2Abi = parseAbi([ + "function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)" +]); + +const chainlinkAbi = parseAbi([ + "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)" +]); + +// Read-only getters beyond the keeper ABI surface in ./chain.ts. +const forwarderMonitoringAbi = parseAbi([ + "function destination() view returns (address)", + "function fallbackAddress() view returns (address)", + "function feeBps() view returns (uint16)", + "function EURC() view returns (address)", + "function USDC() view returns (address)", + "function ORACLE() view returns (address)", + "function ORACLE_DECIMALS() view returns (uint8)", + "function SLIPPAGE_BPS() view returns (uint16)", + "function TRIGGER_DELAY() view returns (uint256)", + "function POOL_FEE_EURE_EURC() view returns (uint24)", + "function POOL_FEE_EURC_USDC() view returns (uint24)" +]); + +const factoryMonitoringAbi = parseAbi([ + "function implementation() view returns (address)", + "function isForwarder(address forwarder) view returns (bool)" +]); + +// ------------------------------------------------------------------ pure logic + +/** + * Price impact of an executable quote vs the Chainlink EUR/USD rate, in bps (floored; + * negative when the quote beats the oracle). Same scaling as VortexForwarder._minOut + * without the slippage haircut: EURe 18 dp in, USDC 6 dp out. + */ +export function computeQuoteImpactBps( + amountInRaw: bigint, + quotedOutRaw: bigint, + oracleAnswer: bigint, + oracleDecimals: number +): number { + const expectedOut = (amountInRaw * oracleAnswer) / 10n ** BigInt(12 + oracleDecimals); + if (expectedOut <= 0n) { + return 0; + } + return Number(((expectedOut - quotedOutRaw) * 10_000n) / expectedOut); +} + +export type StrandingSeverity = "error" | "ok" | "warn"; + +/** + * Severity of an armed stranding marker (R03): older than TRIGGER_DELAY (the + * permissionless-trigger delay) is an error; older than STRANDED_WARN_MS a warning. + */ +export function classifyStranding(strandedSinceSec: bigint, triggerDelaySec: bigint, nowMs: number): StrandingSeverity { + if (strandedSinceSec === 0n) { + return "ok"; + } + const armedMs = nowMs - Number(strandedSinceSec) * 1000; + if (armedMs >= Number(triggerDelaySec) * 1000) { + return "error"; + } + if (armedMs >= STRANDED_WARN_MS) { + return "warn"; + } + return "ok"; +} + +export interface AssociationDbRecord { + forwarderAddress: string; + iban: string | null; +} + +export interface LiveAssociationState { + /** All IBANs visible to the partner context: { iban, address } pairs. */ + ibans: { address: string; iban: string }[]; + /** Addresses linked to this account's profile. */ + profileAddresses: string[]; +} + +export function normalizeIban(iban: string): string { + return iban.replace(/\s+/g, "").toUpperCase(); +} + +/** + * Detects ANY divergence between the DB association record and the live Monerium + * state (S1/PATCH-ibans detective control): forwarder unlinked, extra addresses on + * the profile, the IBAN moved to another address, or an IBAN we did not record. + */ +export function diffAssociation(db: AssociationDbRecord, live: LiveAssociationState): string[] { + const changes: string[] = []; + const forwarder = db.forwarderAddress.toLowerCase(); + + if (!live.profileAddresses.some(address => address.toLowerCase() === forwarder)) { + changes.push(`forwarder ${db.forwarderAddress} is no longer linked to the profile`); + } + for (const address of live.profileAddresses) { + if (address.toLowerCase() !== forwarder) { + changes.push(`unexpected address linked to the profile: ${address}`); + } + } + + const dbIban = db.iban ? normalizeIban(db.iban) : null; + if (dbIban) { + const entry = live.ibans.find(candidate => normalizeIban(candidate.iban) === dbIban); + if (!entry) { + changes.push(`IBAN ${db.iban} no longer exists at Monerium`); + } else if (entry.address.toLowerCase() !== forwarder) { + changes.push(`IBAN ${db.iban} moved to address ${entry.address}`); + } + } + for (const entry of live.ibans) { + if (entry.address.toLowerCase() === forwarder && normalizeIban(entry.iban) !== dbIban) { + changes.push(`unrecorded IBAN issued for the forwarder: ${entry.iban}`); + } + } + return changes; +} + +export interface ForwarderConfigRecord { + destination: string; + fallbackAddress: string; + feeBps: number; +} + +export interface ConfigDriftResult { + /** Immutable-config violations — should be impossible; alarm, never reconcile. */ + errors: string[]; + /** destination/fallbackAddress drift: owner-authorized by construction (R07) — reconcile the DB. */ + ownerAuthorizedUpdates: Partial>; +} + +/** + * Classifies drift between the DB config record and on-chain clone state. + * destination/fallbackAddress are mutable ONLY by the client's fallbackAddress + * (`onlyFallback`), so any change there is an expected owner-authorized transition + * (re-review R07); feeBps is immutable post-init, so a change there is an incident. + */ +export function detectConfigDrift(db: ForwarderConfigRecord, onchain: ForwarderConfigRecord): ConfigDriftResult { + const result: ConfigDriftResult = { errors: [], ownerAuthorizedUpdates: {} }; + if (db.feeBps !== onchain.feeBps) { + result.errors.push(`immutable feeBps mismatch: db=${db.feeBps} chain=${onchain.feeBps}`); + } + if (db.destination.toLowerCase() !== onchain.destination.toLowerCase()) { + result.ownerAuthorizedUpdates.destination = onchain.destination; + } + if (db.fallbackAddress.toLowerCase() !== onchain.fallbackAddress.toLowerCase()) { + result.ownerAuthorizedUpdates.fallbackAddress = onchain.fallbackAddress; + } + return result; +} + +/** Runtime code of a standard EIP-1167 minimal proxy pointing at `implementation`. */ +export function eip1167RuntimeCode(implementation: Address): Hex { + return `0x363d3d373d3d3d363d73${implementation.slice(2).toLowerCase()}5af43d82803e903d91602b57fd5bf3` as Hex; +} + +// ------------------------------------------------------------------ monitor runners + +async function monitoredAccounts(statuses: MoneriumAccountStatus[]): Promise { + return MoneriumAccount.findAll({ where: { status: { [Op.in]: statuses } } }); +} + +/** + * Executable-depth check (PRD §7.4): QuoterV2 static quotes at minSwapAmount and + * perSwapCap on the pinned path vs Chainlink. Runs only against Ethereum mainnet — + * MAINNET_QUOTER_V2 is a mainnet pin. + */ +export async function runExecutableDepthCheck(): Promise { + if ((await getChainId()) !== 1) { + return; + } + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const client = getPublicClient(); + const forwarder = accounts[0].forwarderAddress as Address; + const { eure, factory } = await getForwarderImmutables(forwarder); + const [eurc, usdc, oracle, oracleDecimals, slippageBps, poolFeeEureEurc, poolFeeEurcUsdc, minSwapAmount, perSwapCap] = + await Promise.all([ + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "EURC" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "USDC" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "ORACLE" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "ORACLE_DECIMALS" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "SLIPPAGE_BPS" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "POOL_FEE_EURE_EURC" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "POOL_FEE_EURC_USDC" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) + ]); + + const [, answer, , updatedAt] = await client.readContract({ + abi: chainlinkAbi, + address: oracle, + functionName: "latestRoundData" + }); + if (answer <= 0n) { + logger.error(`monerium-b2b: depth check aborted — Chainlink EUR/USD answered ${answer}`); + return; + } + + const path = encodePacked( + ["address", "uint24", "address", "uint24", "address"], + [eure, poolFeeEureEurc, eurc, poolFeeEurcUsdc, usdc] + ); + const quote = async (amountIn: bigint): Promise => { + const { result } = await client.simulateContract({ + abi: quoterV2Abi, + address: MAINNET_QUOTER_V2, + args: [path, amountIn], + functionName: "quoteExactInput" + }); + return result[0]; + }; + + const [minOut, capOut] = await Promise.all([quote(minSwapAmount), quote(perSwapCap)]); + const minImpactBps = computeQuoteImpactBps(minSwapAmount, minOut, answer, Number(oracleDecimals)); + const capImpactBps = computeQuoteImpactBps(perSwapCap, capOut, answer, Number(oracleDecimals)); + const detail = + `oracle=${answer} (updatedAt=${updatedAt}), minSwapAmount=${minSwapAmount} -> ${minOut} (${minImpactBps} bps), ` + + `perSwapCap=${perSwapCap} -> ${capOut} (${capImpactBps} bps), SLIPPAGE_BPS=${slippageBps}`; + + if (minImpactBps > slippageBps) { + // PAUSE THRESHOLD (PRD §7.4): even minimum-size swaps would revert on minOut. + logger.error( + "monerium-b2b: PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS; engage guardian pause per " + + `docs/runbooks/monerium-b2b-incident.md. ${detail}` + ); + } else if (capImpactBps > slippageBps) { + logger.warn(`monerium-b2b: executable depth below perSwapCap — cap-sized swaps would revert on minOut. ${detail}`); + } else { + logger.info(`monerium-b2b: depth check ok. ${detail}`); + } +} + +/** Stranded-balance monitor: armed R03 markers older than 12h warn, older than TRIGGER_DELAY error. */ +export async function runStrandedBalanceMonitor(now: number = Date.now()): Promise { + const accounts = await monitoredAccounts([ + MoneriumAccountStatus.Onboarding, + MoneriumAccountStatus.Active, + MoneriumAccountStatus.Suspended + ]); + if (accounts.length === 0) { + return; + } + const client = getPublicClient(); + const { factory } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const [minSwapFloor, triggerDelay] = await Promise.all([ + client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), + client.readContract({ + abi: forwarderMonitoringAbi, + address: accounts[0].forwarderAddress as Address, + functionName: "TRIGGER_DELAY" + }) + ]); + + for (const account of accounts) { + const forwarder = account.forwarderAddress as Address; + const { eure } = await getForwarderImmutables(forwarder); + const [balance, strandedSince] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }) + ]); + if (balance < minSwapFloor) { + continue; + } + const severity = classifyStranding(strandedSince, triggerDelay, now); + if (severity === "ok") { + continue; + } + const hours = Math.floor((now - Number(strandedSince) * 1000) / 3_600_000); + const message = + `monerium-b2b: stranded EURe on forwarder ${forwarder} (account ${account.id}): balance=${balance}, ` + + `marker armed ${hours}h ago${severity === "error" ? " — past TRIGGER_DELAY, permissionless trigger is live" : ""}`; + if (severity === "error") { + logger.error(message); + } else { + logger.warn(message); + } + } +} + +/** + * Association monitor (S1 detective control): compares the Monerium-side linked + * addresses + IBAN state per active account against the DB record and alerts on ANY + * change. Error-level: an unexplained association change is an incident trigger + * (docs/runbooks/monerium-b2b-incident.md). + */ +export async function runAssociationMonitor(): Promise { + const accounts = await monitoredAccounts([MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const ibans = (await listIbans()).map(entry => ({ address: entry.address, iban: entry.iban })); + for (const account of accounts) { + try { + const profileAddresses = await getProfileAddresses(account.profileId); + const changes = diffAssociation( + { forwarderAddress: account.forwarderAddress, iban: account.iban }, + { ibans, profileAddresses } + ); + if (changes.length > 0) { + logger.error( + `monerium-b2b: ASSOCIATION CHANGE for account ${account.id} (profile ${account.profileId}): ${changes.join("; ")}` + ); + } + } catch (error) { + logger.warn(`monerium-b2b: association monitor failed for account ${account.id}:`, error); + } + } +} + +/** + * Config reconciliation (manifest re-verification pass, R07): re-checks per-clone + * state against the DB. Owner-authorized destination/fallback changes are reconciled + * (DB update + configVersion bump), immutable violations are alarmed. + */ +export async function runConfigReconciliation(): Promise { + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const client = getPublicClient(); + const implementationByFactory = new Map(); + + for (const account of accounts) { + try { + const forwarder = account.forwarderAddress as Address; + const { factory } = await getForwarderImmutables(forwarder); + let implementation = implementationByFactory.get(factory.toLowerCase()); + if (!implementation) { + implementation = await client.readContract({ + abi: factoryMonitoringAbi, + address: factory, + functionName: "implementation" + }); + implementationByFactory.set(factory.toLowerCase(), implementation); + } + + const [destination, fallbackAddress, feeBps, isForwarder, code] = await Promise.all([ + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "destination" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "fallbackAddress" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "feeBps" }), + client.readContract({ abi: factoryMonitoringAbi, address: factory, args: [forwarder], functionName: "isForwarder" }), + client.getCode({ address: forwarder }) + ]); + + if (!isForwarder) { + logger.error(`monerium-b2b: forwarder ${forwarder} (account ${account.id}) is not registered on factory ${factory}`); + } + if ((code ?? "0x").toLowerCase() !== eip1167RuntimeCode(implementation).toLowerCase()) { + logger.error( + `monerium-b2b: forwarder ${forwarder} (account ${account.id}) bytecode is not the EIP-1167 clone of ${implementation}` + ); + } + + const drift = detectConfigDrift( + { destination: account.destination, fallbackAddress: account.fallbackAddress, feeBps: account.feeBps }, + { destination, fallbackAddress, feeBps: Number(feeBps) } + ); + for (const error of drift.errors) { + logger.error(`monerium-b2b: config violation on forwarder ${forwarder} (account ${account.id}): ${error}`); + } + if (Object.keys(drift.ownerAuthorizedUpdates).length > 0) { + // Owner-authorized transition (R07): only the client's fallbackAddress can + // change these on-chain — reconcile, do not alarm. + await account.update({ ...drift.ownerAuthorizedUpdates, configVersion: account.configVersion + 1 }); + logger.warn( + `monerium-b2b: reconciled owner-authorized config change on forwarder ${forwarder} (account ${account.id}): ` + + `${JSON.stringify(drift.ownerAuthorizedUpdates)} (configVersion -> ${account.configVersion})` + ); + } + } catch (error) { + logger.warn(`monerium-b2b: config reconciliation failed for account ${account.id}:`, error); + } + } +} + +// ------------------------------------------------------------------ pass orchestration + +let lastPassAt = 0; + +export function resetMonitoringStateForTests(): void { + lastPassAt = 0; +} + +async function guarded(name: string, run: () => Promise): Promise { + try { + await run(); + } catch (error) { + logger.error(`monerium-b2b: ${name} failed:`, error); + } +} + +/** + * Runs the monitors at most every MONITORING_INTERVAL_MS. Chain monitors need only + * MONERIUM_B2B_RPC_URL (no keys); the association monitor needs the whitelabel API + * credentials. Each monitor is skipped, never fatal, when its config is absent. + */ +export async function runMonitoringPass(now: number = Date.now()): Promise { + if (now - lastPassAt < MONITORING_INTERVAL_MS) { + return; + } + lastPassAt = now; + if (config.moneriumB2b.rpcUrl) { + await guarded("executable-depth check", runExecutableDepthCheck); + await guarded("stranded-balance monitor", () => runStrandedBalanceMonitor(now)); + await guarded("config reconciliation", runConfigReconciliation); + } + if (config.moneriumB2b.clientId && config.moneriumB2b.clientSecret) { + await guarded("association monitor", runAssociationMonitor); + } +} diff --git a/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts b/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts index 9a9074d37..e36fc5935 100644 --- a/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts +++ b/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts @@ -169,25 +169,48 @@ export async function requestIban(address: string, chain: string): Promise { +/** GET /ibans — all IBANs visible to the partner context (association monitor + lookups). */ +export async function listIbans(): Promise { const response = (await request("/ibans", { method: "GET" })) as { ibans?: unknown } | unknown[] | null; const entries = Array.isArray(response) ? response : Array.isArray(response?.ibans) ? response.ibans : []; + const ibans: WhitelabelIban[] = []; for (const entry of entries as Record[]) { - if ( - typeof entry?.iban === "string" && - typeof entry.address === "string" && - entry.address.toLowerCase() === address.toLowerCase() - ) { - return { + if (typeof entry?.iban === "string" && typeof entry.address === "string") { + ibans.push({ address: entry.address, bic: typeof entry.bic === "string" ? entry.bic : undefined, chain: typeof entry.chain === "string" ? entry.chain : "", iban: entry.iban - }; + }); + } + } + return ibans; +} + +/** GET /ibans — returns the IBAN issued for an address, or null if none yet. */ +export async function getIbanForAddress(address: string): Promise { + const ibans = await listIbans(); + return ibans.find(entry => entry.address.toLowerCase() === address.toLowerCase()) ?? null; +} + +/** + * GET /addresses?profile={id} — the addresses linked to a profile. Used by the + * association monitor (S1 detective control): any address linked to a client profile + * beyond the forwarder is an alert condition. + */ +export async function getProfileAddresses(profileId: string): Promise { + const response = (await request(`/addresses?profile=${encodeURIComponent(profileId)}`, { method: "GET" })) as + | { addresses?: unknown } + | unknown[] + | null; + const entries = Array.isArray(response) ? response : Array.isArray(response?.addresses) ? response.addresses : []; + const addresses: string[] = []; + for (const entry of entries as Record[]) { + if (typeof entry?.address === "string") { + addresses.push(entry.address); } } - return null; + return addresses; } /** GET /orders/{orderId} */ diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 7c095c2a4..b7c33f726 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -9,6 +9,7 @@ import { runConversionExecutor } from "../services/monerium-b2b/conversion-execu import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; import { runDormancyGate } from "../services/monerium-b2b/dormancy"; import { runMintWatcher } from "../services/monerium-b2b/mint-watcher"; +import { runMonitoringPass } from "../services/monerium-b2b/monitoring"; const DEFAULT_CRON_TIME = "* * * * *"; // every minute @@ -52,20 +53,23 @@ class MoneriumB2bWorker { "monerium-b2b: MONERIUM_B2B_RPC_URL / MONERIUM_B2B_KEEPER_PRIVATE_KEY not configured — keeper chain steps disabled" ); } - return; - } - - const mintedAccountIds = await runMintWatcher(); - const candidateIds = await this.conversionCandidates(mintedAccountIds); - for (const accountId of candidateIds) { - try { - await runConversionExecutor(accountId); - } catch (error) { - logger.error(`monerium-b2b: conversion executor failed for account ${accountId}:`, error); + } else { + const mintedAccountIds = await runMintWatcher(); + const candidateIds = await this.conversionCandidates(mintedAccountIds); + for (const accountId of candidateIds) { + try { + await runConversionExecutor(accountId); + } catch (error) { + logger.error(`monerium-b2b: conversion executor failed for account ${accountId}:`, error); + } } + + await runDormancyGate(); } - await runDormancyGate(); + // Detection-only monitors (plan D3); internally rate-limited and gated on the + // read RPC / API credentials, so this is safe to call every cycle. + await runMonitoringPass(); } catch (error) { logger.error("Error during Monerium B2B keeper cycle:", error); } finally { diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index 375a8c8d6..5532e407a 100644 --- a/contracts/monerium-forwarder/README.md +++ b/contracts/monerium-forwarder/README.md @@ -18,4 +18,30 @@ forge test # unit tests (mocks) ETH_RPC_URL=... forge test # + mainnet fork tests (pending, task 2) ``` -Linted by `forge fmt`/forge-lint, not Biome. +Linted by `forge fmt`/forge-lint, not Biome (the TypeScript in `script/` is Biome-governed). + +## Config manifest (D3) + +`script/generate-manifest.ts` emits a versioned JSON manifest for a factory deployment +(bytecode hashes, all immutables, per-clone config, deploy-tx provenance); +`script/verify-manifest.ts` re-checks every field against live chain state and exits +nonzero with a field-level diff on mismatch. Runnable by third parties from a repo +checkout (`bun install` once for viem): + +```bash +bun script/generate-manifest.ts [outFile] [--logs-rpc ] +bun script/verify-manifest.ts [--logs-rpc ] +``` + +`--logs-rpc`: many free public RPCs refuse historical `eth_getLogs` ("archive" gating); +pass a logs-capable endpoint for the ForwarderDeployed enumeration — all other reads, +including per-forwarder deploy provenance via transaction receipts, work on any full +node. Published manifests live in `manifests/`. + +**The manifest is consistency evidence, NOT a trust root** (re-review R01): it is +produced by Vortex from the same chain state it attests to, so a verifier pass proves +only that the deployment has not silently changed since publication — not that it was +honest. Independent verification of contract behavior requires the verified source on a +block explorer. Client-authorized config changes (destination/fallback rotation by the +client's own fallbackAddress) are reported as expected transitions, not failures +(re-review R07). diff --git a/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json new file mode 100644 index 000000000..bd54e30c1 --- /dev/null +++ b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json @@ -0,0 +1,65 @@ +{ + "chainId": 11155111, + "factory": { + "address": "0xcBE354e847bF597513148918E7EbDff72aC75842", + "immutables": { + "CAP_CEILING": "50000000000000000000000", + "implementation": "0x7e1c653CaAFCa44258d8680B09F42a33475504a9", + "MIN_SWAP_FLOOR": "1000000000000000000" + }, + "operational": { + "globalPaused": false, + "guardian": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", + "minSwapAmount": "25000000000000000000", + "perSwapCap": "10000000000000000000000" + }, + "runtimeBytecodeHash": "0x3a651b091d179f24618becebeba26698ac13e366899e08f5f03ba8cbac879e98" + }, + "forwarders": [ + { + "address": "0xD7444AB7270A142227Fe659D63873ABdc8AF9b72", + "clientMutable": { + "destination": "0x1111111111111111111111111111111111111111", + "fallbackAddress": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1" + }, + "deploy": { + "blockNumber": "11293035", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000002", + "txHash": "0x90e67a4f9698c9d009687eaaedd2d8b89eebfa104a2edb4c7923fa7a033f2787" + }, + "immutables": { + "feeBps": 0, + "isForwarder": true + }, + "runtimeBytecodeHash": "0x215c88056e67f5925d5660d5eb02a45dca8e69b50db65bacb5ba9ed44d63f364" + } + ], + "generatedAt": "2026-07-17T17:11:57.612Z", + "implementation": { + "address": "0x7e1c653CaAFCa44258d8680B09F42a33475504a9", + "immutables": { + "ATTESTOR": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", + "EURC": "0x539fA90D0a29eB2a513f6b88fEd30b529dF071ca", + "EURE": "0x67b34b93ac295c985e856E5B8A20D83026b580Eb", + "FACTORY": "0xcBE354e847bF597513148918E7EbDff72aC75842", + "FEE_RECIPIENT": "0x3333333333333333333333333333333333333333", + "LINK_HASH_191": "0xb77c35c892a1b24b10a2ce49b424e578472333ee8d2456234fff90626332c50f", + "LINK_MESSAGE": "I hereby declare that I am the address owner.", + "MAX_FEE_BPS": 100, + "MAX_ORACLE_AGE": "187200", + "ORACLE": "0x337dd479435aE2593c9B023B48617278c6AB34E3", + "ORACLE_DECIMALS": 8, + "POOL_FEE_EURC_USDC": 500, + "POOL_FEE_EURE_EURC": 500, + "RECOVERY_HASH": "0x0000000000000000000000000000000000000000000000000000000000000000", + "ROUTER": "0x2222222222222222222222222222222222222222", + "SLIPPAGE_BPS": 100, + "SWEEP_DELAY": "5184000", + "TRIGGER_DELAY": "86400", + "USDC": "0xEc262C76Ff70330BBa90e2c477B185d6665c4aA2" + }, + "runtimeBytecodeHash": "0x0c362c605fc7a795c4cab02fca0751e27817caf84017296d8fa2325dde6f1d11" + }, + "manifestVersion": 1, + "purpose": "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). NOT a trust root (re-review R01): this file is produced by Vortex from the same chain state it attests to, so it proves only that the deployment has not silently changed since publication — not that it was honest. Verify contract behavior independently against the verified source on a block explorer." +} diff --git a/contracts/monerium-forwarder/package.json b/contracts/monerium-forwarder/package.json index 660ff1ec5..d507051b1 100644 --- a/contracts/monerium-forwarder/package.json +++ b/contracts/monerium-forwarder/package.json @@ -1,10 +1,13 @@ { + "description": "Attestor-linked forwarder contracts for the Monerium B2B zero-touch onramp (Foundry)", + "devDependencies": { + "viem": "catalog:" + }, "name": "@vortexfi/contracts-monerium-forwarder", - "version": "0.1.0", "private": true, - "description": "Attestor-linked forwarder contracts for the Monerium B2B zero-touch onramp (Foundry)", "scripts": { "compile": "forge build", "test": "forge test" - } + }, + "version": "0.1.0" } diff --git a/contracts/monerium-forwarder/script/generate-manifest.ts b/contracts/monerium-forwarder/script/generate-manifest.ts new file mode 100644 index 000000000..26cca7bad --- /dev/null +++ b/contracts/monerium-forwarder/script/generate-manifest.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env bun +import { writeFileSync } from "node:fs"; +import { Address, createPublicClient, http, isAddress, PublicClient } from "viem"; +import { + enumerateForwarders, + MANIFEST_PURPOSE, + MANIFEST_VERSION, + Manifest, + readCoreState, + readForwarderEntry +} from "./manifest-core"; + +/** + * Emits a versioned JSON config manifest for a VortexForwarderFactory deployment + * (Monerium B2B onramp, implementation plan D3): factory + implementation runtime + * bytecode hashes, all implementation-level immutables, and per-clone config with + * deploy transaction provenance (ForwarderDeployed events). + * + * The manifest is CONSISTENCY EVIDENCE, NOT A TRUST ROOT (re-review R01): it is + * produced by Vortex from the same chain state it attests to. Publishing it lets + * third parties detect silent changes (via verify-manifest.ts) — it does not prove + * the deployment was honest in the first place. + * + * Usage: + * bun script/generate-manifest.ts [outFile] [--logs-rpc ] + * + * Without [outFile] the manifest is printed to stdout (progress goes to stderr). + * --logs-rpc: many free public RPCs refuse historical eth_getLogs ("archive" gating); + * pass a logs-capable endpoint here for the ForwarderDeployed enumeration — every + * other read still goes through . + */ + +function parseArgs(argv: string[]): { factoryAddress: Address; logsRpcUrl?: string; outFile?: string; rpcUrl: string } { + const positional: string[] = []; + let logsRpcUrl: string | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--logs-rpc") { + logsRpcUrl = argv[++i]; + } else { + positional.push(argv[i]); + } + } + const [factoryAddress, rpcUrl, outFile] = positional; + if (!factoryAddress || !rpcUrl) { + console.error("Usage: bun script/generate-manifest.ts [outFile] [--logs-rpc ]"); + process.exit(2); + } + if (!isAddress(factoryAddress)) { + console.error(`error: ${factoryAddress} is not a valid address`); + process.exit(2); + } + return { factoryAddress: factoryAddress as Address, logsRpcUrl, outFile, rpcUrl }; +} + +async function main(): Promise { + const { factoryAddress, logsRpcUrl, outFile, rpcUrl } = parseArgs(process.argv.slice(2)); + + const client = createPublicClient({ transport: http(rpcUrl) }); + const logsClient: PublicClient = logsRpcUrl ? createPublicClient({ transport: http(logsRpcUrl) }) : client; + + console.error(`reading deployment state for factory ${factoryAddress} ...`); + const core = await readCoreState(client, factoryAddress); + const deployed = await enumerateForwarders(logsClient, factoryAddress); + const forwarders = []; + for (const { deploy, forwarder } of deployed) { + forwarders.push(await readForwarderEntry(client, factoryAddress, forwarder, deploy)); + } + const manifest: Manifest = { + ...core, + forwarders, + generatedAt: new Date().toISOString(), + manifestVersion: MANIFEST_VERSION, + purpose: MANIFEST_PURPOSE + }; + console.error( + `chainId ${manifest.chainId}, implementation ${manifest.implementation.address}, ${manifest.forwarders.length} forwarder(s)` + ); + + const json = `${JSON.stringify(manifest, null, 2)}\n`; + if (outFile) { + writeFileSync(outFile, json); + console.error(`manifest written to ${outFile}`); + } else { + process.stdout.write(json); + } +} + +main().catch(error => { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/contracts/monerium-forwarder/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts new file mode 100644 index 000000000..42aae9c48 --- /dev/null +++ b/contracts/monerium-forwarder/script/manifest-core.ts @@ -0,0 +1,416 @@ +import { Address, getAddress, Hex, keccak256, PublicClient, parseAbi, parseAbiItem, parseEventLogs } from "viem"; + +/** + * Shared chain-reading core for generate-manifest.ts / verify-manifest.ts. + * + * R01 (docs/prd/monerium-b2b-implementation-plan.md §5): the manifest produced from + * this state is CONSISTENCY EVIDENCE, NOT A TRUST ROOT. It is generated by Vortex from + * the same chain state it attests to, so it cannot prove the deployment was honest — + * only that the deployment has not silently changed since the manifest was published. + * Independent verification of WHAT the contracts do requires reading the verified + * source on a block explorer. + */ + +export const MANIFEST_VERSION = 1; + +export const MANIFEST_PURPOSE = + "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). " + + "NOT a trust root (re-review R01): this file is produced by Vortex from the same chain state it attests to, " + + "so it proves only that the deployment has not silently changed since publication — not that it was honest. " + + "Verify contract behavior independently against the verified source on a block explorer."; + +// ------------------------------------------------------------------ ABI surface +// Mirrors contracts/monerium-forwarder/src/VortexForwarder.sol + VortexForwarderFactory.sol. + +export const factoryAbi = parseAbi([ + "function implementation() view returns (address)", + "function MIN_SWAP_FLOOR() view returns (uint256)", + "function CAP_CEILING() view returns (uint256)", + "function guardian() view returns (address)", + "function globalPaused() view returns (bool)", + "function minSwapAmount() view returns (uint256)", + "function perSwapCap() view returns (uint256)", + "function isForwarder(address forwarder) view returns (bool)" +]); + +export const forwarderDeployedEvent = parseAbiItem( + "event ForwarderDeployed(address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt)" +); + +export const forwarderConfigAbi = parseAbi([ + "function destination() view returns (address)", + "function fallbackAddress() view returns (address)", + "function feeBps() view returns (uint16)" +]); + +export const implementationAbi = parseAbi([ + "function EURE() view returns (address)", + "function EURC() view returns (address)", + "function USDC() view returns (address)", + "function ROUTER() view returns (address)", + "function ORACLE() view returns (address)", + "function ORACLE_DECIMALS() view returns (uint8)", + "function FACTORY() view returns (address)", + "function ATTESTOR() view returns (address)", + "function FEE_RECIPIENT() view returns (address)", + "function MAX_ORACLE_AGE() view returns (uint256)", + "function SLIPPAGE_BPS() view returns (uint16)", + "function MAX_FEE_BPS() view returns (uint16)", + "function SWEEP_DELAY() view returns (uint256)", + "function TRIGGER_DELAY() view returns (uint256)", + "function POOL_FEE_EURE_EURC() view returns (uint24)", + "function POOL_FEE_EURC_USDC() view returns (uint24)", + "function LINK_HASH_191() view returns (bytes32)", + "function RECOVERY_HASH() view returns (bytes32)", + "function LINK_MESSAGE() view returns (string)" +]); + +// ------------------------------------------------------------------ manifest shape + +/** Implementation-level immutables shared by all clones (plan §2.1). */ +export interface ImplementationImmutables { + ATTESTOR: string; + EURC: string; + EURE: string; + FACTORY: string; + FEE_RECIPIENT: string; + LINK_HASH_191: Hex; + LINK_MESSAGE: string; + MAX_FEE_BPS: number; + MAX_ORACLE_AGE: string; + ORACLE: string; + ORACLE_DECIMALS: number; + POOL_FEE_EURC_USDC: number; + POOL_FEE_EURE_EURC: number; + RECOVERY_HASH: Hex; + ROUTER: string; + SLIPPAGE_BPS: number; + SWEEP_DELAY: string; + TRIGGER_DELAY: string; + USDC: string; +} + +export interface ForwarderManifestEntry { + address: string; + /** + * Mutable ONLY by the client's fallbackAddress (contract `onlyFallback`). A drift here + * is an owner-authorized state transition, not an incident (re-review R07): the + * verifier reports it as EXPECTED-TRANSITION and the manifest should be regenerated. + */ + clientMutable: { + destination: string; + fallbackAddress: string; + }; + deploy: { + blockNumber: string; + salt: Hex; + txHash: Hex; + }; + /** Set once in the deploy transaction; immutable afterwards. Mismatch = incident. */ + immutables: { + feeBps: number; + isForwarder: boolean; + }; + /** keccak256 of the clone's runtime code; must equal the EIP-1167 code for `implementation.address`. */ + runtimeBytecodeHash: Hex; +} + +export interface CoreState { + chainId: number; + factory: { + address: string; + immutables: { + CAP_CEILING: string; + MIN_SWAP_FLOOR: string; + implementation: string; + }; + /** + * Guardian-tunable within the immutable bounds (registry P6/P7) plus role/pause + * state. Drift here is legitimate operation: the verifier reports NOTICE, not + * failure. + */ + operational: { + globalPaused: boolean; + guardian: string; + minSwapAmount: string; + perSwapCap: string; + }; + runtimeBytecodeHash: Hex; + }; + implementation: { + address: string; + immutables: ImplementationImmutables; + runtimeBytecodeHash: Hex; + }; +} + +export interface Manifest extends CoreState { + forwarders: ForwarderManifestEntry[]; + generatedAt: string; + manifestVersion: number; + purpose: string; +} + +// ------------------------------------------------------------------ helpers + +/** Runtime code of a standard EIP-1167 minimal proxy pointing at `implementation`. */ +export function eip1167RuntimeCode(implementation: Address): Hex { + return `0x363d3d373d3d3d363d73${implementation.slice(2).toLowerCase()}5af43d82803e903d91602b57fd5bf3` as Hex; +} + +async function codeHash(client: PublicClient, address: Address): Promise { + const code = await client.getCode({ address }); + if (!code || code === "0x") { + throw new Error(`no contract code at ${address}`); + } + return keccak256(code); +} + +function read( + client: PublicClient, + abi: typeof factoryAbi | typeof implementationAbi | typeof forwarderConfigAbi, + address: Address, + functionName: string, + args: unknown[] = [] +): Promise { + // biome-ignore lint/suspicious/noExplicitAny: generic dispatcher over three hand-pinned ABIs + return client.readContract({ abi, address, args, functionName } as any) as Promise; +} + +// ------------------------------------------------------------------ deploy provenance + +export interface DeployProvenance { + blockNumber: bigint; + salt: Hex; + txHash: Hex; +} + +const LOG_CHUNK = 50_000n; + +/** + * Finds the factory's deploy block by binary search over getCode (needs an archive + * RPC); falls back to 0 when historical getCode is unavailable. + */ +async function findDeployBlock(client: PublicClient, factory: Address, latest: bigint): Promise { + try { + let lo = 0n; + let hi = latest; + while (lo < hi) { + const mid = (lo + hi) / 2n; + const code = await client.getCode({ address: factory, blockNumber: mid }); + if (code && code !== "0x") { + hi = mid; + } else { + lo = mid + 1n; + } + } + return lo; + } catch { + return 0n; + } +} + +/** + * Enumerates all forwarders ever deployed by the factory from its ForwarderDeployed + * events, oldest first. Needs an RPC that serves historical eth_getLogs (many free + * endpoints gate this behind archive plans — pass a logs-capable RPC to the scripts + * via --logs-rpc in that case). + */ +export async function enumerateForwarders( + client: PublicClient, + factoryAddress: Address +): Promise<{ deploy: DeployProvenance; forwarder: Address }[]> { + const factory = getAddress(factoryAddress); + const latest = await client.getBlockNumber(); + // biome-ignore lint/suspicious/noExplicitAny: viem log generics collapse across the two getLogs call shapes below + let rawLogs: any[]; + try { + // Single full-range query first — cheap when the provider allows it. + rawLogs = await client.getLogs({ address: factory, event: forwarderDeployedEvent, fromBlock: 0n, toBlock: latest }); + } catch { + const start = await findDeployBlock(client, factory, latest); + rawLogs = []; + for (let from = start; from <= latest; from += LOG_CHUNK) { + const to = from + LOG_CHUNK - 1n > latest ? latest : from + LOG_CHUNK - 1n; + rawLogs.push( + ...(await client.getLogs({ address: factory, event: forwarderDeployedEvent, fromBlock: from, toBlock: to })) + ); + } + } + return rawLogs + .filter(log => log.blockNumber !== null && log.transactionHash !== null) + .map(log => ({ + deploy: { + blockNumber: log.blockNumber as bigint, + salt: log.args.salt as Hex, + txHash: log.transactionHash as Hex + }, + forwarder: getAddress(log.args.forwarder as Address) + })); +} + +/** + * Re-derives a forwarder's deploy provenance from its deploy transaction receipt: the + * receipt must be successful and contain the factory's ForwarderDeployed event for the + * forwarder. Works on any full node (receipts by hash are not archive-gated), which is + * what lets third parties verify provenance through free public RPCs. + */ +export async function resolveDeployProvenance( + client: PublicClient, + factoryAddress: Address, + forwarderAddress: Address, + txHash: Hex +): Promise { + const receipt = await client.getTransactionReceipt({ hash: txHash }); + if (receipt.status !== "success") { + throw new Error(`deploy tx ${txHash} did not succeed`); + } + const events = parseEventLogs({ abi: [forwarderDeployedEvent], logs: receipt.logs }).filter( + log => + log.address.toLowerCase() === factoryAddress.toLowerCase() && + (log.args.forwarder as Address).toLowerCase() === forwarderAddress.toLowerCase() + ); + if (events.length === 0) { + throw new Error(`deploy tx ${txHash} contains no ForwarderDeployed event for ${forwarderAddress} from ${factoryAddress}`); + } + return { blockNumber: receipt.blockNumber, salt: events[0].args.salt as Hex, txHash }; +} + +// ------------------------------------------------------------------ state readers + +/** Reads chainId + factory + implementation sections from live chain state. */ +export async function readCoreState(client: PublicClient, factoryAddress: Address): Promise { + const factory = getAddress(factoryAddress); + const chainId = await client.getChainId(); + + const [implementation, minSwapFloor, capCeiling, guardian, globalPaused, minSwapAmount, perSwapCap] = await Promise.all([ + read
(client, factoryAbi, factory, "implementation"), + read(client, factoryAbi, factory, "MIN_SWAP_FLOOR"), + read(client, factoryAbi, factory, "CAP_CEILING"), + read
(client, factoryAbi, factory, "guardian"), + read(client, factoryAbi, factory, "globalPaused"), + read(client, factoryAbi, factory, "minSwapAmount"), + read(client, factoryAbi, factory, "perSwapCap") + ]); + + const [ + eure, + eurc, + usdc, + router, + oracle, + oracleDecimals, + implFactory, + attestor, + feeRecipient, + maxOracleAge, + slippageBps, + maxFeeBps, + sweepDelay, + triggerDelay, + poolFeeEureEurc, + poolFeeEurcUsdc, + linkHash191, + recoveryHash, + linkMessage + ] = await Promise.all([ + read
(client, implementationAbi, implementation, "EURE"), + read
(client, implementationAbi, implementation, "EURC"), + read
(client, implementationAbi, implementation, "USDC"), + read
(client, implementationAbi, implementation, "ROUTER"), + read
(client, implementationAbi, implementation, "ORACLE"), + read(client, implementationAbi, implementation, "ORACLE_DECIMALS"), + read
(client, implementationAbi, implementation, "FACTORY"), + read
(client, implementationAbi, implementation, "ATTESTOR"), + read
(client, implementationAbi, implementation, "FEE_RECIPIENT"), + read(client, implementationAbi, implementation, "MAX_ORACLE_AGE"), + read(client, implementationAbi, implementation, "SLIPPAGE_BPS"), + read(client, implementationAbi, implementation, "MAX_FEE_BPS"), + read(client, implementationAbi, implementation, "SWEEP_DELAY"), + read(client, implementationAbi, implementation, "TRIGGER_DELAY"), + read(client, implementationAbi, implementation, "POOL_FEE_EURE_EURC"), + read(client, implementationAbi, implementation, "POOL_FEE_EURC_USDC"), + read(client, implementationAbi, implementation, "LINK_HASH_191"), + read(client, implementationAbi, implementation, "RECOVERY_HASH"), + read(client, implementationAbi, implementation, "LINK_MESSAGE") + ]); + + return { + chainId, + factory: { + address: factory, + immutables: { + CAP_CEILING: capCeiling.toString(), + implementation: getAddress(implementation), + MIN_SWAP_FLOOR: minSwapFloor.toString() + }, + operational: { + globalPaused, + guardian: getAddress(guardian), + minSwapAmount: minSwapAmount.toString(), + perSwapCap: perSwapCap.toString() + }, + runtimeBytecodeHash: await codeHash(client, factory) + }, + implementation: { + address: getAddress(implementation), + immutables: { + ATTESTOR: getAddress(attestor), + EURC: getAddress(eurc), + EURE: getAddress(eure), + FACTORY: getAddress(implFactory), + FEE_RECIPIENT: getAddress(feeRecipient), + LINK_HASH_191: linkHash191, + LINK_MESSAGE: linkMessage, + MAX_FEE_BPS: Number(maxFeeBps), + MAX_ORACLE_AGE: maxOracleAge.toString(), + ORACLE: getAddress(oracle), + ORACLE_DECIMALS: Number(oracleDecimals), + POOL_FEE_EURC_USDC: Number(poolFeeEurcUsdc), + POOL_FEE_EURE_EURC: Number(poolFeeEureEurc), + RECOVERY_HASH: recoveryHash, + ROUTER: getAddress(router), + SLIPPAGE_BPS: Number(slippageBps), + SWEEP_DELAY: sweepDelay.toString(), + TRIGGER_DELAY: triggerDelay.toString(), + USDC: getAddress(usdc) + }, + runtimeBytecodeHash: await codeHash(client, implementation) + } + }; +} + +/** Reads one forwarder's live per-clone config + code hash into a manifest entry. */ +export async function readForwarderEntry( + client: PublicClient, + factoryAddress: Address, + forwarderAddress: Address, + deploy: DeployProvenance +): Promise { + const factory = getAddress(factoryAddress); + const forwarder = getAddress(forwarderAddress); + const [destination, fallbackAddress, feeBps, isForwarder, forwarderCodeHash] = await Promise.all([ + read
(client, forwarderConfigAbi, forwarder, "destination"), + read
(client, forwarderConfigAbi, forwarder, "fallbackAddress"), + read(client, forwarderConfigAbi, forwarder, "feeBps"), + read(client, factoryAbi, factory, "isForwarder", [forwarder]), + codeHash(client, forwarder) + ]); + return { + address: forwarder, + clientMutable: { + destination: getAddress(destination), + fallbackAddress: getAddress(fallbackAddress) + }, + deploy: { + blockNumber: deploy.blockNumber.toString(), + salt: deploy.salt, + txHash: deploy.txHash + }, + immutables: { + feeBps: Number(feeBps), + isForwarder + }, + runtimeBytecodeHash: forwarderCodeHash + }; +} diff --git a/contracts/monerium-forwarder/script/verify-manifest.ts b/contracts/monerium-forwarder/script/verify-manifest.ts new file mode 100644 index 000000000..4c300114a --- /dev/null +++ b/contracts/monerium-forwarder/script/verify-manifest.ts @@ -0,0 +1,211 @@ +#!/usr/bin/env bun +import { readFileSync } from "node:fs"; +import { Address, createPublicClient, Hex, http, keccak256, PublicClient } from "viem"; +import { + eip1167RuntimeCode, + enumerateForwarders, + ForwarderManifestEntry, + MANIFEST_VERSION, + Manifest, + readCoreState, + readForwarderEntry, + resolveDeployProvenance +} from "./manifest-core"; + +/** + * Re-checks every field of a published config manifest against live chain state and + * exits nonzero with a field-level diff on mismatch. Self-contained: needs only this + * repo checkout (`bun install` once for viem), the manifest file, and any public RPC — + * runnable by third parties: + * + * bun script/verify-manifest.ts [--logs-rpc ] + * + * Deploy provenance is verified from each forwarder's deploy transaction RECEIPT (the + * receipt must contain the factory's ForwarderDeployed event), which works on any full + * node. Only the completeness check — "no forwarders were deployed that the manifest + * does not list" — needs historical eth_getLogs; many free RPCs gate that behind + * archive plans, so pass --logs-rpc with a logs-capable endpoint or accept a NOTICE + * that completeness was not checked. + * + * What a PASS means — and what it does not (re-review R01): the manifest is + * CONSISTENCY EVIDENCE, NOT A TRUST ROOT. A pass proves the deployment still matches + * what Vortex published, i.e. nothing changed silently. It does NOT prove the + * published configuration was correct or honest — for that, read the verified + * contract source on a block explorer. + * + * Severity classes: + * FAIL immutable/bytecode/deploy-provenance mismatch -> exit 1 + * EXPECTED-TRANSITION clientMutable fields (destination/fallbackAddress) changed by + * the client's own fallbackAddress (`onlyFallback` in the + * contract). Owner-authorized, not an incident (re-review R07); + * regenerate + republish the manifest. exit 0 + * NOTICE guardian-tunable operational parameters, a stale forwarder + * list (new deployments since publication), or a skipped + * completeness check. exit 0 + */ + +type Severity = "FAIL" | "EXPECTED-TRANSITION" | "NOTICE"; + +interface Diff { + actual: string; + expected: string; + path: string; + severity: Severity; +} + +function flatten(value: unknown, prefix: string, out: Map): void { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const [key, child] of Object.entries(value)) { + flatten(child, prefix ? `${prefix}.${key}` : key, out); + } + return; + } + out.set(prefix, String(value)); +} + +function severityFor(path: string): Severity { + if (path.includes(".clientMutable.")) return "EXPECTED-TRANSITION"; + if (path.includes(".operational.")) return "NOTICE"; + return "FAIL"; +} + +function diffSection(path: string, expected: unknown, actual: unknown, diffs: Diff[]): void { + const expectedFlat = new Map(); + const actualFlat = new Map(); + flatten(expected, path, expectedFlat); + flatten(actual, path, actualFlat); + for (const key of new Set([...expectedFlat.keys(), ...actualFlat.keys()])) { + const expectedValue = expectedFlat.get(key) ?? ""; + const actualValue = actualFlat.get(key) ?? ""; + if (expectedValue !== actualValue) { + diffs.push({ actual: actualValue, expected: expectedValue, path: key, severity: severityFor(key) }); + } + } +} + +async function verifyForwarder( + client: PublicClient, + manifest: Manifest, + entry: ForwarderManifestEntry, + expectedCloneHash: Hex, + diffs: Diff[] +): Promise { + const factory = manifest.factory.address as Address; + const forwarder = entry.address as Address; + let live: ForwarderManifestEntry; + try { + // Provenance from the deploy tx receipt: must contain the factory's + // ForwarderDeployed event for this forwarder (works on any full node). + const provenance = await resolveDeployProvenance(client, factory, forwarder, entry.deploy.txHash); + live = await readForwarderEntry(client, factory, forwarder, provenance); + } catch (error) { + diffs.push({ + actual: `<${error instanceof Error ? error.message : String(error)}>`, + expected: "verifiable forwarder", + path: `forwarders.${entry.address}`, + severity: "FAIL" + }); + return; + } + diffSection(`forwarders.${entry.address}`, entry, live, diffs); + if (live.runtimeBytecodeHash.toLowerCase() !== expectedCloneHash.toLowerCase()) { + diffs.push({ + actual: live.runtimeBytecodeHash, + expected: expectedCloneHash, + path: `forwarders.${entry.address}.runtimeBytecodeHash (EIP-1167 for implementation)`, + severity: "FAIL" + }); + } +} + +async function checkCompleteness(logsClient: PublicClient, manifest: Manifest, diffs: Diff[]): Promise { + try { + const deployed = await enumerateForwarders(logsClient, manifest.factory.address as Address); + const listed = new Set(manifest.forwarders.map(entry => entry.address.toLowerCase())); + for (const { forwarder } of deployed) { + if (!listed.has(forwarder.toLowerCase())) { + // Deployed after publication: stale manifest, not tampering — regenerate. + diffs.push({ actual: forwarder, expected: "", path: `forwarders.${forwarder}`, severity: "NOTICE" }); + } + } + } catch { + diffs.push({ + actual: "", + expected: "ForwarderDeployed enumeration", + path: "forwarders.", + severity: "NOTICE" + }); + } +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const positional: string[] = []; + let logsRpcUrl: string | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--logs-rpc") { + logsRpcUrl = argv[++i]; + } else { + positional.push(argv[i]); + } + } + const [manifestFile, rpcUrl] = positional; + if (!manifestFile || !rpcUrl) { + console.error("Usage: bun script/verify-manifest.ts [--logs-rpc ]"); + process.exit(2); + } + + const manifest = JSON.parse(readFileSync(manifestFile, "utf8")) as Manifest; + if (manifest.manifestVersion !== MANIFEST_VERSION) { + console.error(`error: manifest version ${manifest.manifestVersion} is not supported (expected ${MANIFEST_VERSION})`); + process.exit(1); + } + + const client = createPublicClient({ transport: http(rpcUrl) }); + const logsClient: PublicClient = logsRpcUrl ? createPublicClient({ transport: http(logsRpcUrl) }) : client; + + console.error(`re-reading live state for factory ${manifest.factory.address} ...`); + const core = await readCoreState(client, manifest.factory.address as Address); + + const diffs: Diff[] = []; + diffSection("chainId", manifest.chainId, core.chainId, diffs); + diffSection("factory", manifest.factory, core.factory, diffs); + diffSection("implementation", manifest.implementation, core.implementation, diffs); + + // Every clone's runtime code must be the EIP-1167 proxy for the published + // implementation — checked against live state below. + const expectedCloneHash = keccak256(eip1167RuntimeCode(manifest.implementation.address as Address)); + for (const entry of manifest.forwarders) { + await verifyForwarder(client, manifest, entry, expectedCloneHash, diffs); + } + await checkCompleteness(logsClient, manifest, diffs); + + for (const diff of diffs) { + console.log(`[${diff.severity}] ${diff.path}: manifest=${diff.expected} live=${diff.actual}`); + } + + const failures = diffs.filter(diff => diff.severity === "FAIL").length; + const transitions = diffs.filter(diff => diff.severity === "EXPECTED-TRANSITION").length; + const notices = diffs.filter(diff => diff.severity === "NOTICE").length; + + if (failures > 0) { + console.log(`VERIFICATION FAILED: ${failures} mismatch(es), ${transitions} expected transition(s), ${notices} notice(s)`); + process.exit(1); + } + if (transitions > 0 || notices > 0) { + console.log( + `VERIFICATION PASSED with ${transitions} owner-authorized transition(s) and ${notices} notice(s) — ` + + "regenerate and republish the manifest to fold them in (consistency evidence only, NOT a trust root — R01)" + ); + return; + } + console.log( + `VERIFICATION PASSED: all ${manifest.forwarders.length} forwarder(s), implementation and factory match the manifest ` + + "(consistency evidence only, NOT a trust root — R01)" + ); +} + +main().catch(error => { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/docs/prd/monerium-b2b-terms-inputs.md b/docs/prd/monerium-b2b-terms-inputs.md new file mode 100644 index 000000000..cbecb5df1 --- /dev/null +++ b/docs/prd/monerium-b2b-terms-inputs.md @@ -0,0 +1,106 @@ +# Monerium B2B Onramp — Terms & Disclosure Inputs (D4) + +**Status:** engineering inputs for G2 legal review and the partner agreement — not final legal +text. Every bracketed value **[ID: …]** is a placeholder tracked in the +[deferred-decisions registry](./monerium-onramp-deferred-decisions.md); G2/partner own the final +wording, engineering owns the factual accuracy of the mechanics described. + +**Sources:** [b2b-variant doc](./monerium-eur-usdc-onramp-b2b-variant.md) §6, +[implementation plan](./monerium-b2b-implementation-plan.md) §5 (R08/R11 dispositions). + +## 1. Redemption limitation (registry B6 — committed to Monerium) + +Monerium's acceptance of the attestor pattern is conditional on clients being told explicitly +that the setup limits direct redemption. Commitment made in the TG thread 2026-07-17; this +disclosure is **mandatory** in the client terms. + +Draft text: + +> EURe received at your dedicated forwarding address **cannot be redeemed directly with +> Monerium from that address**. If you need to redeem EURe (rather than receive the automatic +> USDC conversion), you must first withdraw it to your fallback address — from which you can +> redeem normally — or, as a last resort, use Monerium's recovery process, which pays out only +> to your own verified bank account. + +Notes for G2: the issuer recovery backstop is best-effort until its technical mechanism is +settled **[T1: recovery-message format unanswered — if unresolved at deploy, recovery for +contract addresses fails closed and the fallback address is the only redemption path; the +disclosure must not overpromise the backstop]**. + +## 2. Destination warranty & CEX rotation liability (registry B5) + +Allocation follows the traditional payout-processor model: a wrong/closed destination account is +the instructing party's loss; contractual allocation is the only mechanism that can hold this +risk, since a CEX deposit address's continued validity is not verifiable on-chain. + +Structure to draft: + +- **Client/partner warrants** that the named `destination` is valid, under the client's control + (or a deposit address of an account under the client's control), and that they will notify + Vortex of any change **before** further deposits. +- **Client/partner bears** losses from destination rotation, closure, or mis-crediting by the + destination platform. +- **Exchange-address attestation:** for CEX destinations the client attests awareness of + rotation and minimum-deposit behavior. +- **Vortex diligence commitments** (the consideration for the warranty): a verification transfer + before activation **[B2: 5 USDC]**; automatic pause after prolonged inactivity pending + re-confirmation (§3); minimum forward size at or above the destination's minimum deposit + **[P6: minSwapAmount, €25 floor]**; and never sending unconverted EURe to the destination. +- **Destination changes are client-only:** only the client's fallback key can change the + destination on-chain; Vortex cannot redirect funds (see §6). + +## 3. Dormancy re-confirmation (registry B5, P5) + +> If no conversion completes for **[P5: 60 days]**, forwarding pauses automatically and resumes +> only after you (or the partner on your behalf) re-confirm that your payout address is still +> valid. Deposits made while paused remain in your forwarding account and convert after +> re-confirmation; your fallback-address rights are unaffected by the pause. + +Mechanics reference for the drafters: `docs/runbooks/monerium-b2b-dormancy.md`. The +re-confirmation channel (partner API ping vs written confirmation) is a partner-agreement +decision **[B5]**. + +## 4. Fee disclosure structure (registry B1, P1, P2) + +Fee and slippage are disclosed **separately** — one is deterministic, the other a worst-case +market bound: + +- **Service fee:** a per-client percentage fixed at account creation and immutable thereafter, + assessed on the gross USDC output of each conversion. Current pilot value **[B1: 0]**; + contractual ceiling equal to the on-chain immutable cap **[P2: MAX_FEE_BPS = 100 bps = 1%]**. +- **Conversion bound (not a fee):** each conversion delivers **at least** the Chainlink EUR/USD + reference rate minus **[P1: SLIPPAGE_BPS = 100 bps = 1%]**, or it does not execute at all + (deposits then wait and retry). This bound covers market execution and both stablecoins' + deviation from their pegs; it is enforced by the contract, assuming an honest oracle — it is + not a principal guarantee under oracle failure or a stablecoin collapse beyond the bound. +- Batching: deposits arriving between conversions are converted together; fee and output are + allocated pro-rata by deposit amount, so batching never changes a client's effective rate. + +## 5. Processing SLA (registry B3) + +Placeholder wording pending the business decision: + +> Deposits at or above the minimum **[P6: €25]** convert **[B3: within 1 business hour]** under +> normal market conditions. Conversions execute on weekends; the EUR/USD reference rate updates +> less frequently outside FX market hours **[T2/P8: observed weekend gaps up to 48 h; oracle +> staleness ceiling 52 h]**, so weekend conversions may execute at a rate up to that age — +> always within the conversion bound of §4. Deposits below the minimum accumulate until the +> minimum is reached. + +Include: SLA is a service target, not a guarantee; keeper outages beyond +**[P4: TRIGGER_DELAY = 24 h]** open a permissionless execution path so conversion does not +depend on Vortex's liveness. + +## 6. Vortex-powers and self-custody disclosures (plan §5 R08/R11) + +- **What Vortex can do:** deploy the account, run the conversion, pause it (per-account and + globally), and tune bounded operational parameters. **What Vortex cannot do:** move, redeem, + or redirect funds; every exit target is client-controlled. Pauses can delay conversions but + never block the client's fallback-address rights or the delayed automatic sweep to the + fallback address **[P3: 60 days]**. +- **Fallback-key responsibility (R11 — do not overpromise):** exit guarantees are scoped to the + client's continued control of their fallback key, plus the issuer backstop (best-effort, + §1 note). Loss of the fallback key combined with a broken destination is an ordinary + self-custody residual risk and is borne by the client. +- **Deposit acceptance:** Vortex cannot prevent inbound SEPA to an issued IBAN; deposits during + a pause accumulate as EURe at the forwarding address under the protections above. diff --git a/docs/runbooks/monerium-b2b-dormancy.md b/docs/runbooks/monerium-b2b-dormancy.md new file mode 100644 index 000000000..2c2cb1127 --- /dev/null +++ b/docs/runbooks/monerium-b2b-dormancy.md @@ -0,0 +1,64 @@ +# Runbook: Monerium B2B Onramp — Dormancy Gate + +Why this exists: CEX destination-rotation risk concentrates in dormant accounts — an exchange +silently rotates a deposit address, months later a deposit arrives, and USDC is forwarded to an +address the client no longer controls. Undetectable on-chain. The dormancy gate converts that +silent loss into a pause (b2b-variant doc §6). + +## Gate mechanics (automatic) + +Implemented in `apps/api/src/api/services/monerium-b2b/dormancy.ts`, run every keeper cycle: + +- An `active` account with **no confirmed conversion for 60 days** (placeholder — registry P5; + anchor = last confirmed `MoneriumConversionExecution`, or account creation if none) is paused: + the backend calls `setGuardianPaused(true)` on the clone with the guardian key and records + `dormant_since` on the `MoneriumAccount` row. +- If `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY` is unset, the gate runs **log-only**: `dormant_since` + is recorded and a warning states that no on-chain pause exists. +- While `dormant_since` is set, the conversion executor skips the account entirely. + +The pause is protective-only (contract invariant): it blocks `swapAndForward` and nothing else. +The client's fallback paths (`sweep`, `setDestination`, `setClientPaused`, …) and the +permissionless dead-man sweep (`sweepStrandedEure` after SWEEP_DELAY, registry P3) keep working. +EURe arriving during dormancy accumulates safely on the forwarder; if it strands past +SWEEP_DELAY it flows to the client's `fallbackAddress` automatically. + +## Re-confirmation (manual, via partner) + +Re-confirmation mechanics are a partner-agreement item (**registry B5**) — until settled, the +operational procedure is: + +1. Ask the partner to re-confirm with the client that the `destination` address is still valid + and under the client's control (a partner API ping/written confirmation suffices — zero + client UI by design). +2. If the destination changed: the **client** updates it via their fallback key + (`setDestination` from `fallbackAddress`) — Vortex cannot and must not do this. For CEX + destinations, re-run the penny test (onboarding runbook §7; amount registry B2). +3. Archive the confirmation evidence with the account record. + +## Un-pause + +Only after re-confirmation: + +```bash +cast send "setGuardianPaused(bool)" false --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Then clear the dormancy flag so the executor resumes: + +```sql +UPDATE monerium_accounts SET dormant_since = NULL WHERE forwarder_address = ''; +``` + +The next keeper cycle converts any accumulated balance. Verify: a `SwapExecuted` for the +forwarder, the execution row `confirmed`, and no `stranded EURe` alert on the next monitoring +pass. + +## Edge notes + +- Un-pausing without clearing `dormant_since` leaves the executor skipping the account (the DB + flag, not the chain flag, gates the executor) — always do both. +- A dormant account that re-confirms but stays unused simply re-enters the gate after another + window; that is intended. +- Do not un-pause to "flush" a balance without re-confirmation — the balance is exactly the + rotation-risk scenario the gate exists for. diff --git a/docs/runbooks/monerium-b2b-incident.md b/docs/runbooks/monerium-b2b-incident.md new file mode 100644 index 000000000..4ab340797 --- /dev/null +++ b/docs/runbooks/monerium-b2b-incident.md @@ -0,0 +1,112 @@ +# Runbook: Monerium B2B Onramp — Incident Response + +Scope: the B2B forwarder deployment (`contracts/monerium-forwarder/`) and its keeper/monitoring +backend (`apps/api/src/api/services/monerium-b2b/`). Spec: `docs/prd/monerium-b2b-implementation-plan.md`, +`docs/security-spec/05-integrations/monerium-b2b.md`. + +Ground rules that shape every procedure here: + +- **Vortex powers are delay-only.** Guardian/keeper can pause and execute the policy — never move + or redirect funds. There is no Vortex-side rescue path by design. +- **Pauses never trap client funds.** `fallbackAddress` functions (`sweep`, `setDestination`, + `setFallbackAddress`, `setClientPaused`) and the permissionless dead-man sweep + (`sweepStrandedEure`, after SWEEP_DELAY) work while paused. Do not promise otherwise in comms. +- **Never send raw EURe to a CEX destination.** EURe recovery targets are `fallbackAddress` only. + +## 1. Pause procedures + +The guardian key is `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY` (distinct from keeper and attestor keys). +`$RPC` = the chain RPC, `$FACTORY` = the factory address from the published manifest. + +**Per-clone pause** (one client account — compliance hold, dormancy, targeted issue): + +```bash +cast send "setGuardianPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +**Global pause** (all clones at once — protocol-level incident): + +```bash +cast send $FACTORY "setGlobalPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Both block `swapAndForward` only. Unpause = same call with `false`. Cap reduction (availability +lever, instant, bounded by immutables): + +```bash +cast send $FACTORY "setPerSwapCap(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +## 2. Monerium IBAN suspension ask + +Per-IBAN suspension capability is **G1 item 7 — not yet contractual** (registry). Until the MSA +settles it, this is a best-effort ask: + +1. Contact Monerium support/emergency channel; identify the whitelabel partner account and the + affected IBAN(s) + linked forwarder address(es). +2. Ask for: suspension of inbound SEPA on the IBAN(s) (deposits bounce back to senders), NOT + profile closure. +3. Record ticket/response — feed the outcome back into the G1 negotiation record. + +While unsuspended, inbound SEPA keeps minting EURe to the forwarder. That is safe (funds sit +behind the contract's invariants) but grows exposure — factor it into comms urgency. + +## 3. User notification + +Clients have no Vortex UI; all comms run through the partner plus direct email. + +1. Notify the partner ops contact first (they own the client relationship). +2. Email affected clients: **stop sending EUR to your IBAN until further notice**; deposits + already sent will either convert normally after resolution or be recoverable via the + fallback address — no funds are lost by pausing. +3. Status page entry if the pause is global. + +## 4. Critical-vulnerability sequence (the 02:00-UTC runbook) + +Adapted from PRD v2 §12 for the B2B topology (immutable clones, no per-user migration signature). +Assume a suspected vulnerability in `VortexForwarder`/factory: + +1. **Pause all** — `setGlobalPaused(true)` (§1). Instant, protective-only, reversible. +2. **Ask Monerium to suspend affected IBANs** (§2) so no new EURe mints while assessing. +3. **Notify** partner + clients to stop sending EUR (§3). +4. **Assess.** Funds at risk are EURe balances on forwarders (check with the stranded-balance + monitor output or `cast call "balanceOf(address)" `). Run the manifest + verifier against the live deployment as part of the assessment: + `bun script/verify-manifest.ts $RPC` (from `contracts/monerium-forwarder/`). +5. **If funds must move: only clients can move them.** Instruct clients (via partner) to sweep + EURe to safety with their fallback key: `sweep(EURE, )` from `fallbackAddress`. + Provide exact calldata and a verification walkthrough. The issuer recovery backstop + (Monerium burn + payout to the client's own bank account) is the last resort — best-effort + until registry T1 is resolved. +6. **Ship the fix as a migration.** Immutable contracts: deploy fixed implementation + factory + (new audit), deploy new clones per client, link the new clone to the client's profile + (attestor flow, onboarding runbook §3), issue/move the IBAN, penny-test, regenerate + publish + the manifest. Old clones stay paused; residual balances leave via fallback sweep or dead-man + sweep (never lost — SWEEP_DELAY, registry P3). +7. **Unpause / decommission.** Unpause only contracts that are confirmed unaffected. + +## 5. Alert triage (monitoring log lines → action) + +The monitors live in `apps/api/src/api/services/monerium-b2b/monitoring.ts` (worker-run, every +30 min). All lines are prefixed `monerium-b2b:`. + +| Log line contains | Meaning | Action | +|---|---|---| +| `PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Executable depth below even minimum-size swaps (PRD §7.4 pause threshold); swaps would revert on minOut | Engage global pause (§1); investigate pool state (LP exit, depeg); consider lowering `perSwapCap`; re-run the T6 quote methodology before unpausing | +| `executable depth below perSwapCap` | Cap-sized swaps would revert; availability, not fund risk | Lower `perSwapCap` (§1) or accept keeper retries; watch for escalation to pause threshold | +| `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB record (IBAN moved, address linked) — the S1 detective control | Treat as potential whitelabel-credential compromise: confirm with Monerium whether the change was authorized; if not: global pause, rotate `MONERIUM_B2B_CLIENT_SECRET`, ask for IBAN suspension (§2), full incident | +| `stranded EURe on forwarder` (warn ≥12h) | Keeper is not converting | Check worker liveness, RPC health, keeper gas balance, oracle staleness (swaps revert on `StalePrice`) | +| `stranded EURe ... past TRIGGER_DELAY` | ≥ TRIGGER_DELAY (registry P4) — permissionless trigger now live; SLA long since broken | Escalate keeper outage; anyone can call `swapAndForward()` now, which is acceptable (same policy applies); communicate delay to client | +| `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on factory` | Should-be-impossible state (immutable feeBps changed, wrong code) | Full incident: global pause, run the manifest verifier, compare against the published manifest history | +| `reconciled owner-authorized config change` | Client's fallbackAddress rotated destination/fallback — expected transition (R07), DB updated | No incident. Confirm with the partner that the client intended it; an unexpected change suggests a compromised fallback key → client should `setClientPaused(true)` and rotate via `setFallbackAddress` | +| `MONERIUM_B2B_PRIVATE_RPC_URL is not set` | Keeper writes going through public mempool | Operational finding on mainnet — set the private orderflow RPC | + +## 6. Key compromise quick reference + +| Key | Blast radius | Response | +|---|---|---| +| Attestor | Can link addresses to profiles, never move funds | Rotate key; new forwarders need a new implementation deployment (ATTESTOR is immutable); existing links unaffected | +| Keeper | Can call `poke`/`swapAndForward` (policy-constrained) — no fund redirection possible | Rotate `MONERIUM_B2B_KEEPER_PRIVATE_KEY`; `setKeeper(old,false)` + `setKeeper(new,true)` on the factory | +| Guardian | Can pause/unpause and tune bounded params — delay-only | Two-step `transferGuardian`/`acceptGuardian` to a new key; audit pause state afterwards | +| Whitelabel API credentials | Control-plane: could move IBANs/links at Monerium (S1) | Rotate at Monerium; association monitor is the detective control; check its history for unauthorized changes | +| Client fallback key (client-side) | Full control of that client's funds/config | Client's own responsibility (terms); assist via partner: pause account, client rotates `setFallbackAddress` if still in control | diff --git a/docs/runbooks/monerium-b2b-onboarding.md b/docs/runbooks/monerium-b2b-onboarding.md new file mode 100644 index 000000000..92be4239e --- /dev/null +++ b/docs/runbooks/monerium-b2b-onboarding.md @@ -0,0 +1,114 @@ +# Runbook: Monerium B2B Onramp — Client Onboarding + +Deploy → manifest → verify → link → IBAN → penny test → activate. One pass per client. +Spec: `docs/prd/monerium-b2b-implementation-plan.md`; API call shapes below are the +sandbox-validated ones from registry item T4 (2026-07-17). + +Prerequisites: guardian key funded on the target chain; `MONERIUM_B2B_*` env set +(API creds, attestor key, RPC); partner paperwork complete. + +## 1. Paperwork inputs (from the partner agreement) + +- `destination` — client's payout address. CEX deposit addresses allowed; validate: EIP-55 + checksum, not zero/dead/precompile/token/router (the contract re-rejects token/router/self at + init), warn-and-attest for contract addresses and CEX addresses (rotation risk — terms doc). +- `fallbackAddress` — client's **self-custodied** recovery address. Mandatory, no exceptions + (Monerium acceptance condition). Must be distinct from custodial/CEX addresses. +- `feeBps` — per-client, immutable post-init. Pilot: `0` (registry B1). +- Signed terms including the redemption-limitation disclosure (registry B6 — Monerium requires + it; see `docs/prd/monerium-b2b-terms-inputs.md`). + +## 2. Deploy the forwarder + +```bash +# predict, then deploy (guardian-only); salt = any unused bytes32, convention: client index +cast call $FACTORY "predictAddress(bytes32)(address)" $SALT --rpc-url $RPC +cast send $FACTORY "deployForwarder(address,address,uint16,bytes32)" \ + $DESTINATION $FALLBACK $FEE_BPS $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). Record the +forwarder address + deploy tx hash. + +## 3. Manifest: generate, verify, publish + +From `contracts/monerium-forwarder/`: + +```bash +bun script/generate-manifest.ts $FACTORY $RPC manifests/-$FACTORY.json +bun script/verify-manifest.ts manifests/-$FACTORY.json $RPC # must PASS +``` + +(Some free RPCs refuse historical `eth_getLogs`; add `--logs-rpc ` for +the event enumeration — all other reads stay on `$RPC`.) + +Publish the manifest (commit + public location). The manifest is **consistency evidence, not a +trust root** (re-review R01): it lets anyone detect silent changes; it does not prove the +deployment was honest — that requires the verified source on the block explorer, so verify the +factory + implementation source there as part of this step. + +## 4. Create the Monerium profile + KYB + +``` +POST /profiles { "kind": "corporate" } +``` + +Note: `GET /profiles` (list) 404s on the whitelabel sandbox — use per-profile paths +(`GET /profiles/{id}`). KYB submission is deliberately unimplemented (`submitKybData` → 501) +until the whitelabel KYB mechanism is contractually settled — **registry T3**; for sandbox and +until then, KYB completion happens on Monerium's side. + +## 5. Link the forwarder address (attestor flow) + +The backend signs the fixed link message with the attestor key +(`signLinkAttestation` in `apps/api/src/api/services/monerium-b2b/attestor.ts` — bound to +chainid + forwarder address; the contract validates via constrained EIP-1271, EIP-191 variant +only). Sandbox-validated call shape (T4): + +``` +POST /addresses +{ + "address": "", + "chain": "", // e.g. "ethereum"; sandbox spike used Sepolia + "message": "I hereby declare that I am the address owner.", + "profile": "", + "signature": "" +} +``` + +Expected: HTTP 201, address `state: linked` — zero client interaction (validated 2026-07-17, +including the hardened chainid-bound re-validation; sandbox artifacts in registry T4). + +## 6. IBAN issuance + +``` +POST /ibans { "address": "", "chain": "" } +``` + +**Async: expect HTTP 202** (T4). Poll `GET /ibans` until the entry for the address appears with +state approved. Record the IBAN on the `MoneriumAccount` row (status stays `onboarding`) — +the association monitor treats the DB record as the reference state from here on. + +## 7. Penny test + +Purpose: prove the destination actually credits contract-originated USDC transfers (CEXes can +rotate or mis-credit) before real volume flows. + +1. Send a small SEPA deposit to the new IBAN (sandbox: dashboard at sandbox.monerium.dev → + Receive → "Simulate bank transfer"). Target forward amount: **5 USDC** (placeholder — + registry B2). +2. Keeper converts and forwards automatically once the balance ≥ `minSwapAmount`; for a + sub-minimum penny test, temporarily lower `minSwapAmount` (guardian, bounded by + `MIN_SWAP_FLOOR`) or fund up to the minimum. +3. **Partner/client confirms credit at the destination** (explicit written confirmation — + this is a diligence commitment in the terms, registry B5). + +## 8. Activate + +1. Set the `MoneriumAccount` row to `active`. +2. Confirm the monitoring pass picks the account up cleanly (no association/config alerts on + the next cycle). +3. Hand the client's IBAN over via the partner. Done. + +Failure at any step: nothing is at risk — the forwarder holds no funds until the client wires +EUR, and every recovery path (fallback sweep, dead-man sweep) is live from deployment. diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index a81574871..9a589a1d9 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -7,7 +7,7 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e **Provider type:** on-ramp (EUR → USDC) **Fiat currencies:** EUR **Chains involved:** Ethereum (forwarder contracts, EURe/USDC) -**Modules:** `monerium-b2b/whitelabel-client.ts`, `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts` +**Modules:** `monerium-b2b/whitelabel-client.ts`, `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts`; monitoring: `monerium-b2b/monitoring.ts` **API auth method:** OAuth client credentials (`MONERIUM_B2B_CLIENT_ID`/`MONERIUM_B2B_CLIENT_SECRET`) against `MONERIUM_B2B_API_URL` (sandbox `api.monerium.dev` by default); inbound webhooks authenticated by HMAC-SHA256 (`MONERIUM_B2B_WEBHOOK_SECRET`) ## Security Invariants @@ -35,6 +35,16 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 5. **Attribution is snapshot-based and idempotent (R04)** — on confirmation, unallocated minted deposits with mint block ≤ execution block are selected oldest-first up to `eureInRaw`, USDC attribution is pro-rata by `amount_raw` against `eureInRaw` with floor division and remainder to the largest deposit (unit-tested), and `allocated_execution_id` links them. Mint identity is the `(chain_id, tx_hash, log_index)` partial unique index, so watcher re-scans after a crash cannot double-record; non-Monerium EURe inflows become `unattr:`-prefixed deposit rows (R09) and are never presented as customer deposits. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). +## Monitoring + +The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-limited to one pass per 30 minutes) is detection-only; its invariants: + +1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per `docs/runbooks/monerium-b2b-incident.md`). +2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on the pinned EURe→EURC→USDC path at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline). Impact above `SLIPPAGE_BPS` at `minSwapAmount` size logs the error-level PAUSE THRESHOLD line; at `perSwapCap` size a warning. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. +3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal). +4. **Association monitor (S1 detective control)** — per active account, re-reads the profile's linked addresses (`GET /addresses?profile=`) and the partner-context IBAN list (`GET /ibans`) and error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. +5. **Config reconciliation (R07)** — re-reads per-clone config and bytecode. `destination`/`fallbackAddress` drift is owner-authorized by construction (`onlyFallback` in the contract): it is reconciled into the DB (with a `configVersion` bump) and logged at warn, never alarmed. `feeBps` drift (immutable post-init), a clone whose bytecode is not the EIP-1167 proxy of the factory's implementation, or a missing `isForwarder` registration are error-level should-be-impossible states. Mirrors the standalone manifest verifier (`contracts/monerium-forwarder/script/verify-manifest.ts`), which is documented as consistency evidence, not a trust root (R01). + ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | @@ -66,3 +76,5 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox - [ ] Keeper, guardian, and attestor private keys are three distinct keys in production; none logged - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) - [ ] Conversion execution rows are created before broadcast and every terminal row has status confirmed/failed with a cause; R04 allocation math covered by `conversion-executor.test.ts` +- [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 owner-authorized config reconciliation; quote-impact, stranding, association-diff and drift classification covered by `monitoring.test.ts` +- [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; owner-authorized config changes (R07) are warn-level reconciliations, never incidents From 1cb5ad4a047bde1da297b32e5e4c0707b6264ca6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 19:45:59 +0200 Subject: [PATCH 19/59] chore(repo): register the monerium-forwarder workspace in the lockfile The workspace and its scripts were committed without the matching bun.lock entry, so frozen-lockfile installs failed on this branch. --- bun.lock | 41 ++++++++++++++--------------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index efff76cb1..236e491a3 100644 --- a/bun.lock +++ b/bun.lock @@ -322,6 +322,13 @@ "typescript": "catalog:", }, }, + "contracts/monerium-forwarder": { + "name": "@vortexfi/contracts-monerium-forwarder", + "version": "0.1.0", + "devDependencies": { + "viem": "catalog:", + }, + }, "contracts/relayer": { "name": "@vortexfi/contracts-relayer", "version": "1.0.0", @@ -2208,6 +2215,8 @@ "@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + "@vortexfi/contracts-monerium-forwarder": ["@vortexfi/contracts-monerium-forwarder@workspace:contracts/monerium-forwarder"], + "@vortexfi/contracts-relayer": ["@vortexfi/contracts-relayer@workspace:contracts/relayer"], "@vortexfi/kyc": ["@vortexfi/kyc@workspace:packages/kyc"], @@ -4736,7 +4745,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], @@ -4880,12 +4889,8 @@ "@graphql-tools/executor-graphql-ws/@graphql-tools/executor-common": ["@graphql-tools/executor-common@0.0.6", "", { "dependencies": { "@envelop/core": "^5.3.0", "@graphql-tools/utils": "^10.9.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow=="], - "@graphql-tools/executor-graphql-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@graphql-tools/executor-legacy-ws/@graphql-tools/utils": ["@graphql-tools/utils@11.2.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-eu9h1R3j/wWc4rvmYJF5AKtlwniDzstrZ/c6KSz+HdI+n7I7iog9xyKmBfpUwSbG1TqPNZBzWjFMkzdYOKq6Bg=="], - "@graphql-tools/executor-legacy-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@graphql-tools/git-loader/@graphql-tools/utils": ["@graphql-tools/utils@11.2.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-eu9h1R3j/wWc4rvmYJF5AKtlwniDzstrZ/c6KSz+HdI+n7I7iog9xyKmBfpUwSbG1TqPNZBzWjFMkzdYOKq6Bg=="], "@graphql-tools/github-loader/sync-fetch": ["sync-fetch@0.6.0-2", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A=="], @@ -4914,8 +4919,6 @@ "@graphql-tools/url-loader/sync-fetch": ["sync-fetch@0.6.0-2", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A=="], - "@graphql-tools/url-loader/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "@inquirer/core/mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], @@ -4994,8 +4997,6 @@ "@polkadot/x-ws/@polkadot/x-global": ["@polkadot/x-global@14.0.3", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-MzMEynJ7HMTy/plLmdyP8rv14RS/6s29HZodUG9aCOscBnEiEDxVEax/ztRJqxhhQuHeYdx0LYDwVbdQDTkqNw=="], - "@polkadot/x-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@reown/appkit/@walletconnect/universal-provider": ["@walletconnect/universal-provider@2.23.7", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/jsonrpc-http-connection": "1.0.8", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "3.0.2", "@walletconnect/sign-client": "2.23.7", "@walletconnect/types": "2.23.7", "@walletconnect/utils": "2.23.7", "es-toolkit": "1.44.0", "events": "3.3.0" } }, "sha512-6UicU/Mhr/1bh7MNoajypz7BhigORbHpP1LFTf8FYLQGDqzmqHMqmMH2GDAImtaY2sFTi2jBvc22tLl8VMze/A=="], "@reown/appkit/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -5064,8 +5065,6 @@ "@solana/errors/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], - "@solana/rpc-subscriptions-channel-websocket/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@solana/rpc-transport-http/undici-types": ["undici-types@7.28.0", "", {}, "sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ=="], "@storybook/csf-plugin/unplugin": ["unplugin@1.16.1", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w=="], @@ -5162,6 +5161,8 @@ "@walletconnect/jsonrpc-utils/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "@walletconnect/jsonrpc-ws-connection/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + "@walletconnect/modal-core/valtio": ["valtio@1.11.2", "", { "dependencies": { "proxy-compare": "2.5.1", "use-sync-external-store": "1.2.0" }, "peerDependencies": { "@types/react": ">=16.8", "react": ">=16.8" }, "optionalPeers": ["@types/react", "react"] }, "sha512-1XfIxnUXzyswPAPXo1P3Pdx2mq/pIqZICkWN60Hby0d9Iqb+MEIpqgYVlbflvHdrp2YR/q3jyKWRPJJ100yxaw=="], "@walletconnect/modal-ui/lit": ["lit@2.8.0", "", { "dependencies": { "@lit/reactive-element": "^1.6.0", "lit-element": "^3.3.0", "lit-html": "^2.8.0" } }, "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA=="], @@ -5270,8 +5271,6 @@ "elliptic/bn.js": ["bn.js@4.12.4", "", {}, "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g=="], - "engine.io-client/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "escodegen/esprima": ["esprima@2.7.3", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-OarPfz0lFCiW4/AV2Oy1Rp9qu0iusTKqykwTspGCZtPxmF81JR4MmIebvF1F9+UOKth2ZubLQ4XGGaU+hSn99A=="], "escodegen/estraverse": ["estraverse@1.9.3", "", {}, "sha512-25w1fMXQrGdoquWnScXZGckOv+Wes+JDnuN/+7ex3SauFRS72r2lFDec0EKPt2YD1wUJ/IrfEex+9yp4hfSOJA=="], @@ -5314,8 +5313,6 @@ "ethers/tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="], - "ethers/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "ethjs-unit/bn.js": ["bn.js@4.11.6", "", {}, "sha512-XWwnNNFCuuSQ0m3r3C4LE3EiORltHd9M05pq6FOlVeiophzRbMo50Sbz1ehl8K3Z+jw9+vmgnXefY1hz8X+2wA=="], "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -5360,6 +5357,8 @@ "hardhat/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "hardhat/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "http-basic/concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="], @@ -5380,8 +5379,6 @@ "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], - "jsdom/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "json-rpc-engine/@metamask/safe-event-emitter": ["@metamask/safe-event-emitter@2.0.0", "", {}, "sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q=="], "keccak/node-addon-api": ["node-addon-api@2.0.2", "", {}, "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA=="], @@ -5534,8 +5531,6 @@ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "smoldot/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "solc/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "solc/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], @@ -5556,8 +5551,6 @@ "storybook/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "storybook/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "strip-dirs/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], @@ -5604,8 +5597,6 @@ "viem/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], - "viem/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], @@ -5634,8 +5625,6 @@ "web3-providers-http/cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], - "web3-providers-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "web3-utils/ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="], "web3-validator/ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="], @@ -5982,8 +5971,6 @@ "graphql-config/@graphql-tools/url-loader/@graphql-tools/wrap": ["@graphql-tools/wrap@11.1.17", "", { "dependencies": { "@graphql-tools/delegate": "^12.0.18", "@graphql-tools/schema": "^10.0.29", "@graphql-tools/utils": "^11.0.0", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-eY5dh9ZewdzSbYNtrfhXvpaKvMyGNCGrH2VBMTs5hnyE9tncsmZjcaVRW+CGiUJUIp184D+FYrK7tmW/hUm0dQ=="], - "graphql-config/@graphql-tools/url-loader/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "graphql-config/minimatch/brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "hardhat/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], From f1c0f8ef35666eae1192c3fbc8e60e316e28ab1d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 20:03:03 +0200 Subject: [PATCH 20/59] fix(api): pass options to monerium migration table drops With the models registered, the postgres dropTable assigns options.supportsSearchPath during ENUM-type cleanup and throws on an undefined options argument. The migrator test suite now reverts across this migration (revertMigration to 066), so a bare dropTable aborted down() midway and left the schema partially dropped for every later integration suite. --- .../migrations/069-monerium-b2b-onramp-tables.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts b/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts index 3a7f6d81d..d4cd89ce2 100644 --- a/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts +++ b/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts @@ -90,10 +90,12 @@ export async function up(queryInterface: QueryInterface): Promise { } export async function down(queryInterface: QueryInterface): Promise { - await queryInterface.dropTable("monerium_webhook_events"); - await queryInterface.dropTable("monerium_conversion_executions"); - await queryInterface.dropTable("monerium_fiat_deposits"); - await queryInterface.dropTable("monerium_accounts"); + // The options object is required: with the models registered, the postgres + // dropTable assigns onto it during ENUM cleanup and throws on undefined. + await queryInterface.dropTable("monerium_webhook_events", {}); + await queryInterface.dropTable("monerium_conversion_executions", {}); + await queryInterface.dropTable("monerium_fiat_deposits", {}); + await queryInterface.dropTable("monerium_accounts", {}); for (const enumName of [ "enum_monerium_accounts_status", "enum_monerium_fiat_deposits_status", From db8733522ca4ab7643afc01946e9d12d2d4a3246 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 20:03:04 +0200 Subject: [PATCH 21/59] docs(api): sync api-surface route inventory with monerium-b2b route --- docs/security-spec/07-operations/api-surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index e7f7cd62b..464b28b5b 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -44,7 +44,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - During an active window, mutable quote/ramp operations return HTTP `503 Service Unavailable` before controller/service work starts. - Rejections include `Retry-After`, `Cache-Control: no-store`, and downtime metadata (`maintenance_start`, `maintenance_end`, affected operations) in the error payload so direct API clients can pause and retry after the window. -**Route structure:** 43 `*.route.ts` files under `api/routes/` (36 under `v1/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. +**Route structure:** 44 `*.route.ts` files under `api/routes/` (35 directly under `v1/`, 7 under `v1/admin/`, 2 under `v1/admin-console/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. **Multipart uploads:** Four operations use in-memory Multer buffering. Alfredpay's `POST /v1/alfredpay/submitKycFile`, `submitKybFile`, and `submitKybRelatedPersonFile` (also mounted under the country aliases `/v1/mx`, `/v1/co`, and `/v1/ar`) allow one file up to 5MB; secret/Bearer authentication and the managed relationship/entity-type gate run before buffering, while multipart country authorization runs after parsing. On a country alias, the path-derived country replaces any multipart country field before that authorization. Mykobo's `POST /v1/mykobo/profiles` is Supabase-authenticated before buffering and accepts up to four named files (`front`, `back`, `face`, `utility_bill`), each up to 10MB. These routes bound individual file size but do not currently configure a MIME/type `fileFilter`; the Mykobo request can buffer up to 40MB in aggregate. From 7df538e8faa504798d2ef3e53b56c89537dbbe9a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 20:27:06 +0200 Subject: [PATCH 22/59] feat(api): map monerium b2b accounts to managed profiles Corporates that Monerium onboards to the whitelabel app under the partner's KYC reliance enter Vortex through an idempotent admin endpoint: it provisions the managed child (business entity), mirrors the approved KYB into provider_customers/kyc_cases, and binds the deployed forwarder as a monerium account owned by that profile via the new vortex_profile_id link. Divergent replays are conflicts, never overwrites; pre-mapping account rows are adopted when they match. --- .../admin/moneriumB2b.controller.test.ts | 227 +++++++++++++++++ .../admin/moneriumB2b.controller.ts | 82 ++++++ .../api/routes/v1/admin/monerium-b2b.route.ts | 13 + apps/api/src/api/routes/v1/index.ts | 8 + .../monerium-b2b/account-provisioning.ts | 233 ++++++++++++++++++ .../071-link-monerium-accounts-to-profiles.ts | 21 ++ apps/api/src/models/index.ts | 2 + apps/api/src/models/moneriumAccount.model.ts | 13 +- .../07-operations/api-surface.md | 2 +- 9 files changed, 598 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts create mode 100644 apps/api/src/api/controllers/admin/moneriumB2b.controller.ts create mode 100644 apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts create mode 100644 apps/api/src/api/services/monerium-b2b/account-provisioning.ts create mode 100644 apps/api/src/database/migrations/071-link-monerium-accounts-to-profiles.ts diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts new file mode 100644 index 000000000..9515fa055 --- /dev/null +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -0,0 +1,227 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import express from "express"; +import KycCase from "../../../models/kycCase.model"; +import ManagedProfile from "../../../models/managedProfile.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import User from "../../../models/user.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import moneriumB2bRoutes from "../../routes/v1/admin/monerium-b2b.route"; + +const BASE_PATH = "/v1/admin/monerium-b2b"; +const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type": "application/json" }; + +const FORWARDER = "0x1111111111111111111111111111111111111111"; +const DESTINATION = "0x2222222222222222222222222222222222222222"; +const FALLBACK = "0x3333333333333333333333333333333333333333"; + +describe("monerium b2b account mapping admin route", () => { + let server: ReturnType; + let baseUrl: string; + + beforeAll(async () => { + await setupTestDatabase(); + + const app = express(); + app.use(express.json()); + app.use(BASE_PATH, moneriumB2bRoutes); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not bind test server"); + baseUrl = `http://127.0.0.1:${address.port}${BASE_PATH}`; + }); + + afterAll(() => { + server?.close(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + async function createManager(): Promise { + const profile = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: profile.id + }); + return profile.id; + } + + function post(body: unknown, headers: Record = ADMIN_HEADERS) { + return fetch(`${baseUrl}/accounts`, { body: JSON.stringify(body), headers, method: "POST" }); + } + + function validBody(managerProfileId: string, overrides: Record = {}) { + return { + contactEmail: "ops@client.example.com", + destination: DESTINATION, + externalSubjectId: "client-1", + fallbackAddress: FALLBACK, + forwarderAddress: FORWARDER, + managerProfileId, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e", + ...overrides + }; + } + + it("requires admin authentication", async () => { + const response = await post(validBody(crypto.randomUUID()), { "Content-Type": "application/json" }); + expect(response.status).toBe(401); + }); + + it("provisions the managed child, KYB mirror, and account", async () => { + const managerProfileId = await createManager(); + + const response = await post(validBody(managerProfileId)); + expect(response.status).toBe(201); + const { account } = await response.json(); + expect(account).toMatchObject({ + accountStatus: MoneriumAccountStatus.Onboarding, + created: true, + iban: null, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + + const child = await User.findByPk(account.profileId); + expect(child?.kind).toBe("managed"); + expect(child?.email).toBeNull(); + + const relationship = await ManagedProfile.findOne({ where: { profileId: account.profileId } }); + expect(relationship).toMatchObject({ + creationSource: "vortex", + externalSubjectId: "client-1", + managerProfileId, + status: "active" + }); + + const customer = await ProviderCustomer.findOne({ where: { customerEntityId: account.customerEntityId } }); + expect(customer).toMatchObject({ + customerType: "business", + provider: "monerium", + providerCustomerId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e", + rail: "eur", + status: VerificationStatus.Approved + }); + + const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } }); + expect(kycCase).toMatchObject({ status: VerificationStatus.Approved, type: "kyb" }); + expect(kycCase?.approvedAt).not.toBeNull(); + + const row = await MoneriumAccount.findByPk(account.accountId); + expect(row).toMatchObject({ + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: FORWARDER, + vortexProfileId: account.profileId + }); + }); + + it("is idempotent for an identical replay", async () => { + const managerProfileId = await createManager(); + + const first = await post(validBody(managerProfileId)); + expect(first.status).toBe(201); + const replay = await post(validBody(managerProfileId)); + expect(replay.status).toBe(200); + const { account } = await replay.json(); + expect(account.created).toBe(false); + + expect(await MoneriumAccount.count()).toBe(1); + expect(await ManagedProfile.count()).toBe(1); + expect(await ProviderCustomer.count()).toBe(1); + expect(await KycCase.count()).toBe(1); + }); + + it("adopts a pre-mapping account row that matches the deployed forwarder", async () => { + const managerProfileId = await createManager(); + await MoneriumAccount.create({ + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: FORWARDER, + profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + + const response = await post(validBody(managerProfileId)); + expect(response.status).toBe(200); + const { account } = await response.json(); + expect(account.created).toBe(false); + + const row = await MoneriumAccount.findByPk(account.accountId); + expect(row?.vortexProfileId).toBe(account.profileId); + }); + + it("rejects a divergent replay instead of overwriting", async () => { + const managerProfileId = await createManager(); + expect((await post(validBody(managerProfileId))).status).toBe(201); + + // Same Monerium profile, different forwarder. + const differentForwarder = await post( + validBody(managerProfileId, { forwarderAddress: "0x4444444444444444444444444444444444444444" }) + ); + expect(differentForwarder.status).toBe(409); + expect(await differentForwarder.json()).toMatchObject({ error: { code: "MONERIUM_B2B_ACCOUNT_CONFLICT" } }); + + // Same child, different Monerium profile. + const differentMonerium = await post( + validBody(managerProfileId, { moneriumProfileId: "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f" }) + ); + expect(differentMonerium.status).toBe(409); + + // Different subject claiming the same Monerium profile. + const differentSubject = await post( + validBody(managerProfileId, { + contactEmail: "other@client.example.com", + externalSubjectId: "client-2", + forwarderAddress: "0x5555555555555555555555555555555555555555" + }) + ); + expect(differentSubject.status).toBe(409); + + expect(await MoneriumAccount.count()).toBe(1); + }); + + it("rejects invalid input and unknown managers", async () => { + const managerProfileId = await createManager(); + + for (const overrides of [ + { forwarderAddress: "not-an-address" }, + { destination: "0x12345" }, + { fallbackAddress: "" }, + { moneriumProfileId: "not-a-uuid" }, + { feeBps: 3.5 }, + { feeBps: -1 }, + { externalSubjectId: "" }, + { contactEmail: "not-an-email" } + ]) { + const response = await post(validBody(managerProfileId, overrides)); + expect(response.status).toBe(400); + } + expect(await MoneriumAccount.count()).toBe(0); + + const unknownManager = await post(validBody(crypto.randomUUID())); + expect(unknownManager.status).toBe(404); + expect(await unknownManager.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_MANAGER_NOT_FOUND" } }); + }); + + it("refuses managers not allowed to provision business customers", async () => { + const profile = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["BR"], + allowedCustomerTypes: ["individual"], + isActive: true, + profileId: profile.id + }); + + const response = await post(validBody(profile.id)); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_INVALID_INPUT" } }); + expect(await MoneriumAccount.count()).toBe(0); + }); +}); diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts new file mode 100644 index 000000000..08bdec71f --- /dev/null +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -0,0 +1,82 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../../config/logger"; +import { ManagedProfileProvisioningError } from "../../services/managed-profile-provisioning.service"; +import { MoneriumB2bProvisioningError, provisionMoneriumB2bAccount } from "../../services/monerium-b2b/account-provisioning"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function postMoneriumB2bAccount(req: Request, res: Response): Promise { + try { + const { + contactEmail, + destination, + externalSubjectId, + fallbackAddress, + feeBps, + forwarderAddress, + managerProfileId, + moneriumProfileId + } = req.body ?? {}; + if ( + typeof managerProfileId !== "string" || + !UUID_PATTERN.test(managerProfileId) || + typeof moneriumProfileId !== "string" || + typeof externalSubjectId !== "string" || + externalSubjectId.trim().length === 0 || + externalSubjectId.trim().length > 255 || + typeof contactEmail !== "string" || + typeof forwarderAddress !== "string" || + typeof destination !== "string" || + typeof fallbackAddress !== "string" || + (feeBps !== undefined && typeof feeBps !== "number") + ) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MONERIUM_B2B_INVALID_INPUT", + message: + "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; feeBps must be a number when present", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + const result = await provisionMoneriumB2bAccount({ + contactEmail, + destination, + externalSubjectId, + fallbackAddress, + feeBps, + forwarderAddress, + managerProfileId, + moneriumProfileId + }); + res.status(result.created ? httpStatus.CREATED : httpStatus.OK).json({ account: result }); + } catch (error) { + if (error instanceof MoneriumB2bProvisioningError) { + const status = error.code === "MONERIUM_B2B_INVALID_INPUT" ? httpStatus.BAD_REQUEST : httpStatus.CONFLICT; + res.status(status).json({ error: { code: error.code, message: error.message, status } }); + return; + } + if (error instanceof ManagedProfileProvisioningError) { + const status = + error.code === "MANAGED_PROFILE_CONFLICT" + ? httpStatus.CONFLICT + : error.code === "MANAGED_PROFILE_MANAGER_NOT_FOUND" + ? httpStatus.NOT_FOUND + : httpStatus.BAD_REQUEST; + res.status(status).json({ error: { code: error.code, message: error.message, status } }); + return; + } + + logger.error("Error provisioning Monerium B2B account:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to provision Monerium B2B account", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts new file mode 100644 index 000000000..9a5f1bc71 --- /dev/null +++ b/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { postMoneriumB2bAccount } from "../../../controllers/admin/moneriumB2b.controller"; +import { adminAuth } from "../../../middlewares/adminAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(adminAuth); + +// Maps a Monerium-onboarded corporate to a managed profile and records its +// deployed forwarder as a B2B onramp account. Idempotent. +router.post("/accounts", postMoneriumB2bAccount); + +export default router; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index cbbd648e0..9951c0a01 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -5,6 +5,7 @@ import { setAlfredpayCountryFromRoute } from "../../middlewares/alfredpay.middle import apiClientEventsRoutes from "./admin/api-client-events.route"; import managedProfileManagersRoutes from "./admin/managed-profile-managers.route"; import adminManagedProfilesRoutes from "./admin/managed-profiles.route"; +import adminMoneriumB2bRoutes from "./admin/monerium-b2b.route"; import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; import partnerPricingConfigsRoutes from "./admin/partner-pricing-configs.route"; import profilePartnerAssignmentsRoutes from "./admin/profile-partner-assignments.route"; @@ -276,6 +277,13 @@ router.use("/admin/profile-roles", profileRolesRoutes); router.use("/admin/managed-profile-managers", managedProfileManagersRoutes); router.use("/admin/managed-profiles", adminManagedProfilesRoutes); +/** + * Admin route mapping Monerium-onboarded corporates to managed profiles and their + * deployed forwarder accounts (idempotent). + * POST /v1/admin/monerium-b2b/accounts + */ +router.use("/admin/monerium-b2b", adminMoneriumB2bRoutes); + /** * Admin routes for API client observability dashboards * GET /v1/admin/api-client-events diff --git a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts new file mode 100644 index 000000000..ab2094e35 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -0,0 +1,233 @@ +import sequelize from "../../../config/database"; +import KycCase from "../../../models/kycCase.model"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import { type ProvisionManagedProfileResult, provisionManagedProfile } from "../managed-profile-provisioning.service"; + +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/i; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export class MoneriumB2bProvisioningError extends Error { + constructor( + readonly code: "MONERIUM_B2B_ACCOUNT_CONFLICT" | "MONERIUM_B2B_INVALID_INPUT", + message: string + ) { + super(message); + this.name = this.constructor.name; + } +} + +export interface ProvisionMoneriumB2bAccountInput { + contactEmail: string; + destination: string; + externalSubjectId: string; + fallbackAddress: string; + feeBps?: number; + forwarderAddress: string; + managerProfileId: string; + moneriumProfileId: string; +} + +export interface ProvisionMoneriumB2bAccountResult { + accountId: string; + accountStatus: MoneriumAccountStatus; + created: boolean; + customerEntityId: string; + iban: string | null; + moneriumProfileId: string; + profileId: string; +} + +function normalizeAddress(value: string, name: string): string { + if (typeof value !== "string" || !ADDRESS_PATTERN.test(value.trim())) { + throw new MoneriumB2bProvisioningError("MONERIUM_B2B_INVALID_INPUT", `${name} must be a 0x-prefixed EVM address`); + } + return value.trim().toLowerCase(); +} + +// Mirrors the whitelabel KYB outcome for a reliance-onboarded corporate: 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). +async function mirrorApprovedKyb(customerEntityId: string, moneriumProfileId: string): Promise { + await sequelize.transaction(async transaction => { + const boundElsewhere = await ProviderCustomer.findOne({ + transaction, + where: { provider: "monerium", providerCustomerId: moneriumProfileId } + }); + if (boundElsewhere && boundElsewhere.customerEntityId !== customerEntityId) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The Monerium profile is already bound to a different customer" + ); + } + + const [customer] = await ProviderCustomer.findOrCreate({ + defaults: { + customerEntityId, + customerType: "business", + provider: "monerium", + providerCustomerId: moneriumProfileId, + rail: "eur", + status: VerificationStatus.Approved, + statusExternal: "approved" + }, + transaction, + where: { customerEntityId, customerType: "business", provider: "monerium", rail: "eur" } + }); + if (customer.providerCustomerId && customer.providerCustomerId !== moneriumProfileId) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The customer entity is already bound to a different Monerium profile" + ); + } + if (customer.providerCustomerId !== moneriumProfileId || customer.status !== VerificationStatus.Approved) { + await customer.update( + { providerCustomerId: moneriumProfileId, status: VerificationStatus.Approved, statusExternal: "approved" }, + { transaction } + ); + } + + const existingCase = await KycCase.findOne({ transaction, where: { providerCustomerId: customer.id } }); + if (existingCase) { + if (existingCase.status !== VerificationStatus.Approved) { + await existingCase.update( + { + approvedAt: existingCase.approvedAt ?? new Date(), + providerCaseId: moneriumProfileId, + rejectedAt: null, + status: VerificationStatus.Approved, + statusExternal: "approved" + }, + { transaction } + ); + } + } else { + await KycCase.create( + { + approvedAt: new Date(), + customerEntityId, + provider: "monerium", + providerCaseId: moneriumProfileId, + providerCustomerId: customer.id, + status: VerificationStatus.Approved, + statusExternal: "approved", + submittedAt: new Date(), + type: "kyb" + }, + { transaction } + ); + } + }); +} + +function accountMatchesInput( + account: MoneriumAccount, + childProfileId: string, + forwarderAddress: string, + destination: string, + fallbackAddress: string +): boolean { + return ( + account.forwarderAddress.toLowerCase() === forwarderAddress && + account.destination.toLowerCase() === destination && + account.fallbackAddress.toLowerCase() === fallbackAddress && + (account.vortexProfileId === null || account.vortexProfileId === childProfileId) + ); +} + +/** + * Maps a corporate that Monerium onboarded to the whitelabel app onto a Vortex + * managed profile and its B2B onramp account. Idempotent: replaying the same + * input returns the existing records; any divergence is a conflict, never an + * overwrite. The forwarder clone must already be deployed (operator runbook); + * this only records it. + */ +export async function provisionMoneriumB2bAccount( + input: ProvisionMoneriumB2bAccountInput +): Promise { + const moneriumProfileId = input.moneriumProfileId.trim().toLowerCase(); + if (!UUID_PATTERN.test(moneriumProfileId)) { + throw new MoneriumB2bProvisioningError("MONERIUM_B2B_INVALID_INPUT", "moneriumProfileId must be a UUID"); + } + const forwarderAddress = normalizeAddress(input.forwarderAddress, "forwarderAddress"); + const destination = normalizeAddress(input.destination, "destination"); + const fallbackAddress = normalizeAddress(input.fallbackAddress, "fallbackAddress"); + const feeBps = input.feeBps ?? 0; + if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 10000) { + throw new MoneriumB2bProvisioningError("MONERIUM_B2B_INVALID_INPUT", "feeBps must be an integer between 0 and 10000"); + } + + // The pilot reliance scope is KYB'd corporates only, so the child is always a + // business entity. Errors (inactive manager, subject/email conflicts) propagate + // as ManagedProfileProvisioningError for the controller to map. + const managedProfile: ProvisionManagedProfileResult = await provisionManagedProfile({ + contactEmail: input.contactEmail, + creationSource: "vortex", + customerType: "business", + externalSubjectId: input.externalSubjectId, + managerProfileId: input.managerProfileId + }); + + await mirrorApprovedKyb(managedProfile.customerEntityId, moneriumProfileId); + + const account = await sequelize.transaction(async transaction => { + const existing = await MoneriumAccount.findOne({ transaction, where: { profileId: moneriumProfileId } }); + if (existing) { + if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, fallbackAddress)) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The Monerium profile is already mapped with different account data" + ); + } + // Adopt a pre-mapping row that was inserted by hand before the managed + // profile linkage existed. + if (existing.vortexProfileId === null) { + await existing.update({ vortexProfileId: managedProfile.profileId }, { transaction }); + } + return { created: false, row: existing }; + } + + const boundToProfile = await MoneriumAccount.findOne({ + transaction, + where: { vortexProfileId: managedProfile.profileId } + }); + if (boundToProfile) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The managed profile already has a Monerium account for a different Monerium profile" + ); + } + const forwarderTaken = await MoneriumAccount.findOne({ transaction, where: { forwarderAddress } }); + if (forwarderTaken) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The forwarder address is already bound to another account" + ); + } + + const row = await MoneriumAccount.create( + { + destination, + fallbackAddress, + feeBps, + forwarderAddress, + profileId: moneriumProfileId, + status: MoneriumAccountStatus.Onboarding, + vortexProfileId: managedProfile.profileId + }, + { transaction } + ); + return { created: true, row }; + }); + + return { + accountId: account.row.id, + accountStatus: account.row.status, + created: account.created, + customerEntityId: managedProfile.customerEntityId, + iban: account.row.iban, + moneriumProfileId, + profileId: managedProfile.profileId + }; +} diff --git a/apps/api/src/database/migrations/071-link-monerium-accounts-to-profiles.ts b/apps/api/src/database/migrations/071-link-monerium-accounts-to-profiles.ts new file mode 100644 index 000000000..497502dd4 --- /dev/null +++ b/apps/api/src/database/migrations/071-link-monerium-accounts-to-profiles.ts @@ -0,0 +1,21 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Bind each Monerium B2B account to the Vortex managed profile that owns it. +// Nullable because pre-mapping sandbox rows exist; application code requires it +// for every account provisioned through the admin mapping endpoint. The partial +// unique index enforces one account per profile without rejecting legacy NULLs. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_accounts", "vortex_profile_id", { + allowNull: true, + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }); + await queryInterface.sequelize.query( + "CREATE UNIQUE INDEX monerium_accounts_vortex_profile_id ON monerium_accounts (vortex_profile_id) WHERE vortex_profile_id IS NOT NULL" + ); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query("DROP INDEX IF EXISTS monerium_accounts_vortex_profile_id"); + await queryInterface.removeColumn("monerium_accounts", "vortex_profile_id"); +} diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 59cc6f354..7828cc001 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -37,6 +37,8 @@ MoneriumAccount.hasMany(MoneriumFiatDeposit, { as: "fiatDeposits", foreignKey: " MoneriumFiatDeposit.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); MoneriumAccount.hasMany(MoneriumConversionExecution, { as: "conversionExecutions", foreignKey: "accountId" }); MoneriumConversionExecution.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); +MoneriumAccount.belongsTo(User, { as: "vortexProfile", foreignKey: "vortexProfileId" }); +User.hasOne(MoneriumAccount, { as: "moneriumAccount", foreignKey: "vortexProfileId" }); RampState.belongsTo(QuoteTicket, { as: "quote", foreignKey: "quoteId" }); QuoteTicket.hasOne(RampState, { as: "rampState", foreignKey: "quoteId" }); QuoteTicket.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); diff --git a/apps/api/src/models/moneriumAccount.model.ts b/apps/api/src/models/moneriumAccount.model.ts index 681436957..f630c2e11 100644 --- a/apps/api/src/models/moneriumAccount.model.ts +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -10,10 +10,13 @@ export enum MoneriumAccountStatus { // Persistent B2B onramp account (docs/prd/monerium-b2b-implementation-plan.md §3): // one row per client = one Monerium profile + IBAN + deployed forwarder. Long-lived, -// repeatedly funded — deliberately NOT a RampState. +// repeatedly funded — deliberately NOT a RampState. profileId is the MONERIUM profile +// UUID; vortexProfileId is the owning Vortex managed profile (nullable only for rows +// that predate the managed-profile mapping). export interface MoneriumAccountAttributes { id: string; profileId: string; + vortexProfileId: string | null; iban: string | null; forwarderAddress: string; destination: string; @@ -28,7 +31,7 @@ export interface MoneriumAccountAttributes { type MoneriumAccountCreationAttributes = Optional< MoneriumAccountAttributes, - "id" | "iban" | "configVersion" | "status" | "dormantSince" | "createdAt" | "updatedAt" + "id" | "vortexProfileId" | "iban" | "configVersion" | "status" | "dormantSince" | "createdAt" | "updatedAt" >; class MoneriumAccount @@ -37,6 +40,7 @@ class MoneriumAccount { declare id: string; declare profileId: string; + declare vortexProfileId: string | null; declare iban: string | null; declare forwarderAddress: string; declare destination: string; @@ -114,6 +118,11 @@ MoneriumAccount.init( defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE + }, + vortexProfileId: { + allowNull: true, + field: "vortex_profile_id", + type: DataTypes.UUID } }, { diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 464b28b5b..8b1162035 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -44,7 +44,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - During an active window, mutable quote/ramp operations return HTTP `503 Service Unavailable` before controller/service work starts. - Rejections include `Retry-After`, `Cache-Control: no-store`, and downtime metadata (`maintenance_start`, `maintenance_end`, affected operations) in the error payload so direct API clients can pause and retry after the window. -**Route structure:** 44 `*.route.ts` files under `api/routes/` (35 directly under `v1/`, 7 under `v1/admin/`, 2 under `v1/admin-console/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. +**Route structure:** 45 `*.route.ts` files under `api/routes/` (35 directly under `v1/`, 8 under `v1/admin/`, 2 under `v1/admin-console/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. **Multipart uploads:** Four operations use in-memory Multer buffering. Alfredpay's `POST /v1/alfredpay/submitKycFile`, `submitKybFile`, and `submitKybRelatedPersonFile` (also mounted under the country aliases `/v1/mx`, `/v1/co`, and `/v1/ar`) allow one file up to 5MB; secret/Bearer authentication and the managed relationship/entity-type gate run before buffering, while multipart country authorization runs after parsing. On a country alias, the path-derived country replaces any multipart country field before that authorization. Mykobo's `POST /v1/mykobo/profiles` is Supabase-authenticated before buffering and accepts up to four named files (`front`, `back`, `face`, `utility_bill`), each up to 10MB. These routes bound individual file size but do not currently configure a MIME/type `fileFilter`; the Mykobo request can buffer up to 40MB in aggregate. From c6b4436964de0129d381edce719c0147798acb33 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 20:38:48 +0200 Subject: [PATCH 23/59] feat(api): automate monerium b2b onboarding link and iban issuance The keeper now advances every mapped onboarding account: attestor-signed address link and IBAN request run through the profile-scoped financial_operations ledger (exactly-once across crashes and retries), and the issued IBAN is recorded from the iban.updated webhook without ever overwriting an existing one (an IBAN change is the association monitor's alert condition). Activation after the penny test moves from raw SQL to an admin status endpoint gated on the issued IBAN. --- .../admin/moneriumB2b.controller.test.ts | 30 +++ .../admin/moneriumB2b.controller.ts | 51 ++++ .../api/routes/v1/admin/monerium-b2b.route.ts | 5 +- .../monerium-b2b/deposit-processor.test.ts | 29 ++- .../monerium-b2b/deposit-processor.ts | 46 ++++ .../services/monerium-b2b/onboarding.test.ts | 226 ++++++++++++++++++ .../api/services/monerium-b2b/onboarding.ts | 149 ++++++++++++ .../src/api/workers/monerium-b2b.worker.ts | 5 + docs/runbooks/monerium-b2b-onboarding.md | 80 ++++--- .../05-integrations/monerium-b2b.md | 13 +- 10 files changed, 592 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/api/services/monerium-b2b/onboarding.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/onboarding.ts diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts index 9515fa055..3516878b5 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -210,6 +210,36 @@ describe("monerium b2b account mapping admin route", () => { expect(await unknownManager.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_MANAGER_NOT_FOUND" } }); }); + it("updates account status with the IBAN activation guard", async () => { + const managerProfileId = await createManager(); + const created = await post(validBody(managerProfileId)); + const { account } = await created.json(); + + function patchStatus(accountId: string, status: unknown) { + return fetch(`${baseUrl}/accounts/${accountId}/status`, { + body: JSON.stringify({ status }), + headers: ADMIN_HEADERS, + method: "PATCH" + }); + } + + // No IBAN yet: activation is refused, other transitions work. + const premature = await patchStatus(account.accountId, "active"); + expect(premature.status).toBe(409); + expect(await premature.json()).toMatchObject({ error: { code: "MONERIUM_B2B_ACCOUNT_NOT_READY" } }); + + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516" }, { where: { id: account.accountId } }); + const activated = await patchStatus(account.accountId, "active"); + expect(activated.status).toBe(200); + expect(await activated.json()).toMatchObject({ account: { accountStatus: "active" } }); + + const suspended = await patchStatus(account.accountId, "suspended"); + expect(suspended.status).toBe(200); + + expect((await patchStatus(account.accountId, "nonsense")).status).toBe(400); + expect((await patchStatus(crypto.randomUUID(), "active")).status).toBe(404); + }); + it("refuses managers not allowed to provision business customers", async () => { const profile = await createTestUser(); await ManagedProfileManager.create({ diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts index 08bdec71f..ad823ec98 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import { ManagedProfileProvisioningError } from "../../services/managed-profile-provisioning.service"; import { MoneriumB2bProvisioningError, provisionMoneriumB2bAccount } from "../../services/monerium-b2b/account-provisioning"; @@ -80,3 +81,53 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi }); } } + +const STATUS_VALUES = Object.values(MoneriumAccountStatus) as string[]; + +export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: string }>, res: Response): Promise { + try { + const { status } = req.body ?? {}; + if (!UUID_PATTERN.test(req.params.accountId) || typeof status !== "string" || !STATUS_VALUES.includes(status)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MONERIUM_B2B_INVALID_INPUT", + message: `accountId must be a UUID and status must be one of ${STATUS_VALUES.join(", ")}`, + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + const account = await MoneriumAccount.findByPk(req.params.accountId); + if (!account) { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND", message: "Monerium account not found", status: httpStatus.NOT_FOUND } + }); + return; + } + // Activation requires the issued IBAN: the penny test (runbook §7) cannot have + // happened without it, and the association monitor needs the reference state. + if (status === MoneriumAccountStatus.Active && account.iban === null) { + res.status(httpStatus.CONFLICT).json({ + error: { + code: "MONERIUM_B2B_ACCOUNT_NOT_READY", + message: "The account has no issued IBAN yet and cannot be activated", + status: httpStatus.CONFLICT + } + }); + return; + } + + await account.update({ status: status as MoneriumAccountStatus }); + res.status(httpStatus.OK).json({ account: { accountId: account.id, accountStatus: account.status } }); + } catch (error) { + logger.error("Error updating Monerium B2B account status:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update Monerium B2B account status", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts index 9a5f1bc71..cf405f2c4 100644 --- a/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts +++ b/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { postMoneriumB2bAccount } from "../../../controllers/admin/moneriumB2b.controller"; +import { patchMoneriumB2bAccountStatus, postMoneriumB2bAccount } from "../../../controllers/admin/moneriumB2b.controller"; import { adminAuth } from "../../../middlewares/adminAuth"; const router: Router = Router({ mergeParams: true }); @@ -10,4 +10,7 @@ router.use(adminAuth); // deployed forwarder as a B2B onramp account. Idempotent. router.post("/accounts", postMoneriumB2bAccount); +// Operator lifecycle transitions (activate after the penny test, suspend, close). +router.patch("/accounts/:accountId/status", patchMoneriumB2bAccountStatus); + export default router; diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts index 305f0b476..bd6d95214 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; -import { isForwardTransition, mapOrderStateToDepositStatus, parseOrderEvent } from "./deposit-processor"; +import { isForwardTransition, mapOrderStateToDepositStatus, parseIbanEvent, parseOrderEvent } from "./deposit-processor"; const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; @@ -84,3 +84,30 @@ describe("parseOrderEvent", () => { expect(parseOrderEvent("junk")).toBeNull(); }); }); + +describe("parseIbanEvent", () => { + const validPayload = { + data: { + address: "0x1111111111111111111111111111111111111111", + chain: "ethereum", + iban: "EE08 7224 5745 6244 9516" + }, + timestamp: "2026-07-17T00:00:00Z", + type: "iban.updated" + }; + + it("extracts the IBAN and its linked address", () => { + expect(parseIbanEvent(validPayload)).toEqual({ + address: "0x1111111111111111111111111111111111111111", + iban: "EE08 7224 5745 6244 9516" + }); + }); + + it("ignores non-iban events and payloads missing the IBAN or address", () => { + expect(parseIbanEvent({ ...validPayload, type: "order.updated" })).toBeNull(); + expect(parseIbanEvent({ ...validPayload, data: { ...validPayload.data, iban: "" } })).toBeNull(); + expect(parseIbanEvent({ ...validPayload, data: { ...validPayload.data, address: undefined } })).toBeNull(); + expect(parseIbanEvent(null)).toBeNull(); + expect(parseIbanEvent("junk")).toBeNull(); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts index db8a20f75..0d0a95db6 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -81,6 +81,46 @@ interface ParsedOrderEvent { txHash: string | null; } +export interface ParsedIbanEvent { + address: string; + iban: string; +} + +/** + * Extracts the fields of an iban.updated delivery ({ type, timestamp, data }): the + * asynchronous completion of the onboarding IBAN request. Returns null for anything + * that is not an IBAN event with both the IBAN and its linked address. + */ +export function parseIbanEvent(payload: unknown): ParsedIbanEvent | null { + const envelope = payload as { data?: unknown; type?: unknown } | null; + if (!envelope || typeof envelope !== "object") return null; + if (typeof envelope.type !== "string" || !envelope.type.startsWith("iban")) return null; + const data = (envelope.data ?? {}) as Record; + if (typeof data.iban !== "string" || typeof data.address !== "string" || data.iban.trim().length === 0) return null; + return { address: data.address, iban: data.iban.trim() }; +} + +async function processIbanEvent(row: MoneriumWebhookEvent, event: ParsedIbanEvent): Promise { + await withForwarderLock(event.address, async transaction => { + const account = await MoneriumAccount.findOne({ + transaction, + where: sequelize.where(sequelize.fn("lower", sequelize.col("forwarder_address")), event.address.toLowerCase()) + }); + if (!account) { + logger.warn("monerium-b2b: iban.updated references an unknown forwarder address, skipping"); + } else if (account.iban === null) { + await account.update({ iban: event.iban }, { transaction }); + } else if (account.iban !== event.iban) { + // Never overwrite: an IBAN change on a live account is the association + // monitor's alert condition (PATCH /ibans detective control), not routine data. + logger.error( + `monerium-b2b: iban.updated reports a different IBAN for account ${account.id} — possible IBAN move, not overwriting` + ); + } + await row.update({ processedAt: new Date() }, { transaction }); + }); +} + /** * Extracts the issue-order fields this processor acts on from a delivery payload * (documented shape: { type, timestamp, data }). Returns null for deliveries that are @@ -106,6 +146,12 @@ export function parseOrderEvent(payload: unknown): ParsedOrderEvent | null { } async function processInboxRow(row: MoneriumWebhookEvent): Promise { + const ibanEvent = parseIbanEvent(row.payload); + if (ibanEvent) { + await processIbanEvent(row, ibanEvent); + return; + } + const event = parseOrderEvent(row.payload); if (!event) { await row.update({ processedAt: new Date() }); diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts new file mode 100644 index 000000000..e6835037c --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -0,0 +1,226 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { config } from "../../../config/vars"; +import FinancialOperation from "../../../models/financialOperation.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import { provisionManagedProfile } from "../managed-profile-provisioning.service"; +import { processMoneriumWebhookInbox } from "./deposit-processor"; +import { advanceOnboardingAccounts, type OnboardingDeps } from "./onboarding"; + +const FORWARDER = "0x1111111111111111111111111111111111111111"; +const DESTINATION = "0x2222222222222222222222222222222222222222"; +const FALLBACK = "0x3333333333333333333333333333333333333333"; +const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; +const IBAN = "EE08 7224 5745 6244 9516"; + +const savedConfig = { ...config.moneriumB2b }; + +interface FakeDeps extends OnboardingDeps { + calls: { getChainId: number; getIbanForAddress: number; getProfileAddresses: number; linkAddress: unknown[][]; requestIban: unknown[][]; signLinkAttestation: unknown[][] }; + ibanByAddress: Map; + linkedAddresses: Set; +} + +function fakeDeps(): FakeDeps { + const deps: FakeDeps = { + calls: { getChainId: 0, getIbanForAddress: 0, getProfileAddresses: 0, linkAddress: [], requestIban: [], signLinkAttestation: [] }, + async getChainId() { + deps.calls.getChainId += 1; + return 1; + }, + async getIbanForAddress(address: string) { + deps.calls.getIbanForAddress += 1; + const iban = deps.ibanByAddress.get(address.toLowerCase()); + return iban ? { iban } : null; + }, + async getProfileAddresses() { + deps.calls.getProfileAddresses += 1; + return [...deps.linkedAddresses]; + }, + ibanByAddress: new Map(), + async linkAddress(...args: unknown[]) { + deps.calls.linkAddress.push(args); + return {}; + }, + linkedAddresses: new Set(), + async requestIban(...args: unknown[]) { + deps.calls.requestIban.push(args); + return {}; + }, + async signLinkAttestation(...args: unknown[]) { + deps.calls.signLinkAttestation.push(args); + return { signature: "0xattestor-signature" }; + } + }; + return deps; +} + +async function createMappedAccount(overrides: Partial[0]> = {}): Promise { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const child = await provisionManagedProfile({ + contactEmail: `client-${crypto.randomUUID()}@example.com`, + creationSource: "vortex", + customerType: "business", + externalSubjectId: crypto.randomUUID(), + managerProfileId: manager.id + }); + return MoneriumAccount.create({ + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: FORWARDER, + profileId: MONERIUM_PROFILE, + vortexProfileId: child.profileId, + ...overrides + }); +} + +describe("monerium b2b onboarding automation", () => { + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.moneriumB2b.attestorPrivateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + config.moneriumB2b.clientId = "test-client-id"; + config.moneriumB2b.clientSecret = "test-client-secret"; + config.moneriumB2b.rpcUrl = "http://rpc.invalid"; + }); + + afterAll(() => { + Object.assign(config.moneriumB2b, savedConfig); + }); + + it("links the forwarder and requests an IBAN through the financial-operation ledger", async () => { + const account = await createMappedAccount(); + const deps = fakeDeps(); + + expect(await advanceOnboardingAccounts(deps)).toBe(1); + + expect(deps.calls.signLinkAttestation).toEqual([[1n, FORWARDER]]); + expect(deps.calls.linkAddress).toEqual([[MONERIUM_PROFILE, FORWARDER, "ethereum", "0xattestor-signature"]]); + expect(deps.calls.requestIban).toEqual([[FORWARDER, "ethereum"]]); + + const ownerProfileId = account.vortexProfileId as string; + const operations = await FinancialOperation.findAll({ order: [["phase", "ASC"]] }); + expect(operations.map(op => ({ phase: op.phase, scopeId: op.scopeId, scopeType: op.scopeType, status: op.status }))).toEqual([ + { phase: "linkAddress", scopeId: ownerProfileId, scopeType: "profile", status: "confirmed" }, + { phase: "requestIban", scopeId: ownerProfileId, scopeType: "profile", status: "confirmed" } + ]); + }); + + it("never repeats a claimed provider write on replay", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + + await advanceOnboardingAccounts(deps); + // Simulate the next cycle before the link/IBAN become visible upstream. + await advanceOnboardingAccounts(deps); + + expect(deps.calls.linkAddress).toHaveLength(1); + expect(deps.calls.requestIban).toHaveLength(1); + }); + + it("skips the link call when the forwarder is already linked upstream", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + deps.linkedAddresses.add(FORWARDER); + + await advanceOnboardingAccounts(deps); + + expect(deps.calls.linkAddress).toHaveLength(0); + expect(deps.calls.requestIban).toHaveLength(1); + }); + + it("records the IBAN once issuance completes", async () => { + const account = await createMappedAccount(); + const deps = fakeDeps(); + deps.linkedAddresses.add(FORWARDER); + deps.ibanByAddress.set(FORWARDER, IBAN); + + await advanceOnboardingAccounts(deps); + + await account.reload(); + expect(account.iban).toBe(IBAN); + expect(deps.calls.requestIban).toHaveLength(0); + }); + + it("only advances mapped accounts still in onboarding", async () => { + await createMappedAccount({ status: MoneriumAccountStatus.Active }); + await MoneriumAccount.create({ + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: "0x9999999999999999999999999999999999999999", + profileId: crypto.randomUUID() + // no vortexProfileId: pre-mapping row stays operator-managed + }); + const deps = fakeDeps(); + + expect(await advanceOnboardingAccounts(deps)).toBe(0); + expect(deps.calls.getChainId).toBe(0); + expect(deps.calls.linkAddress).toHaveLength(0); + }); + + it("does nothing while the whitelabel credentials are not configured", async () => { + await createMappedAccount(); + config.moneriumB2b.clientId = ""; + const deps = fakeDeps(); + + expect(await advanceOnboardingAccounts(deps)).toBe(0); + expect(deps.calls.getProfileAddresses).toBe(0); + }); +}); + +describe("iban.updated inbox recording", () => { + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + it("records the issued IBAN on the account and never overwrites a different one", async () => { + const account = await createMappedAccount(); + await MoneriumWebhookEvent.create({ + eventId: "evt-iban-1", + payload: { data: { address: FORWARDER, chain: "ethereum", iban: IBAN }, type: "iban.updated" } + }); + + await processMoneriumWebhookInbox(); + await account.reload(); + expect(account.iban).toBe(IBAN); + + // A later iban.updated with a different IBAN is an alert condition, not data. + await MoneriumWebhookEvent.create({ + eventId: "evt-iban-2", + payload: { data: { address: FORWARDER, chain: "ethereum", iban: "EE00 0000 0000 0000 0000" }, type: "iban.updated" } + }); + await processMoneriumWebhookInbox(); + await account.reload(); + expect(account.iban).toBe(IBAN); + + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + }); + + it("acks iban events for unknown forwarders without failing the drain", async () => { + await MoneriumWebhookEvent.create({ + eventId: "evt-iban-3", + payload: { data: { address: "0x8888888888888888888888888888888888888888", iban: IBAN }, type: "iban.updated" } + }); + + expect(await processMoneriumWebhookInbox()).toBe(1); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.ts b/apps/api/src/api/services/monerium-b2b/onboarding.ts new file mode 100644 index 000000000..b86fe42d0 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/onboarding.ts @@ -0,0 +1,149 @@ +import { Op } from "sequelize"; +import type { Address } from "viem"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import { runFinancialOperation } from "../phases/blocks/core/financial-operation"; +import { signLinkAttestation } from "./attestor"; +import { getChainId } from "./chain"; +import { getIbanForAddress, getProfileAddresses, linkAddress, requestIban } from "./whitelabel-client"; + +const ONBOARDING_FLOW = { id: "monerium-b2b-onboarding", version: 1 } as const; + +// Monerium's chain identifiers for the chains the forwarder deploys to +// (docs.monerium.com chain values; the attestation binds the numeric chain id). +const MONERIUM_CHAIN_NAMES: Record = { + 1: "ethereum", + 11155111: "sepolia" +}; + +export interface OnboardingDeps { + getChainId(): Promise; + getIbanForAddress(address: string): Promise<{ iban: string } | null>; + getProfileAddresses(profileId: string): Promise; + linkAddress(profileId: string, address: string, chain: string, signature: string): Promise; + requestIban(address: string, chain: string): Promise; + signLinkAttestation(chainId: bigint, forwarderAddress: Address): Promise<{ signature: string }>; +} + +const defaultDeps: OnboardingDeps = { + getChainId, + getIbanForAddress, + getProfileAddresses, + linkAddress, + requestIban, + signLinkAttestation +}; + +export function isOnboardingConfigured(): boolean { + const { attestorPrivateKey, clientId, clientSecret, rpcUrl } = config.moneriumB2b; + return Boolean(attestorPrivateKey && clientId && clientSecret && rpcUrl); +} + +let configWarned = false; + +async function isForwarderLinked(deps: OnboardingDeps, moneriumProfileId: string, forwarderAddress: string): Promise { + const forwarderKey = forwarderAddress.toLowerCase(); + const addresses = await deps.getProfileAddresses(moneriumProfileId); + return addresses.some(address => address.toLowerCase() === forwarderKey); +} + +async function ensureLinked(deps: OnboardingDeps, account: MoneriumAccount, chainId: number, chainName: string): Promise { + if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress)) return; + await runFinancialOperation({ + attemptClass: "provider-address-link", + flow: ONBOARDING_FLOW, + perform: async () => { + const attestation = await deps.signLinkAttestation(BigInt(chainId), account.forwarderAddress as Address); + await deps.linkAddress(account.profileId, account.forwarderAddress, chainName, attestation.signature); + return { linked: true }; + }, + phase: "linkAddress", + provider: "monerium", + // A crash between the POST and its confirmation resolves by re-reading the + // profile's linked addresses instead of issuing a second link call. + reconcile: async () => + (await isForwarderLinked(deps, account.profileId, account.forwarderAddress)) ? { linked: true } : null, + request: { address: account.forwarderAddress.toLowerCase(), chain: chainName, moneriumProfileId: account.profileId }, + retryFailed: true, + // vortexProfileId is non-null for every account this loop selects. + scopeId: account.vortexProfileId as string, + scopeType: "profile" + }); +} + +async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainName: string): Promise { + if (account.iban) return; + const issued = await deps.getIbanForAddress(account.forwarderAddress); + if (issued) { + await account.update({ iban: issued.iban }); + return; + } + await runFinancialOperation({ + attemptClass: "provider-iban-request", + flow: ONBOARDING_FLOW, + perform: async () => { + await deps.requestIban(account.forwarderAddress, chainName); + return { requested: true }; + }, + phase: "requestIban", + provider: "monerium", + reconcile: async () => ((await deps.getIbanForAddress(account.forwarderAddress)) ? { requested: true } : null), + request: { address: account.forwarderAddress.toLowerCase(), chain: chainName }, + retryFailed: true, + scopeId: account.vortexProfileId as string, + scopeType: "profile" + }); + // Issuance is asynchronous: the IBAN is recorded by the iban.updated webhook or + // by the read at the top of the next cycle. +} + +/** + * Advances every mapped account still in onboarding: links its forwarder to the + * Monerium profile with the attestor signature, then requests IBAN issuance. Both + * provider writes run through the profile-scoped financial-operation ledger, so a + * crash or retry never repeats a claimed call. Activation (after the penny test) + * stays a manual operator step. + */ +export async function advanceOnboardingAccounts(deps: OnboardingDeps = defaultDeps): Promise { + if (!isOnboardingConfigured()) { + if (!configWarned) { + configWarned = true; + logger.warn( + "monerium-b2b: onboarding automation disabled — requires MONERIUM_B2B_CLIENT_ID/SECRET, MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, and MONERIUM_B2B_RPC_URL" + ); + } + return 0; + } + + const accounts = await MoneriumAccount.findAll({ + order: [["created_at", "ASC"]], + where: { status: MoneriumAccountStatus.Onboarding, vortexProfileId: { [Op.ne]: null } } + }); + if (accounts.length === 0) return 0; + + const chainId = await deps.getChainId(); + const chainName = MONERIUM_CHAIN_NAMES[chainId]; + if (!chainName) { + logger.error(`monerium-b2b: no Monerium chain name known for chain id ${chainId}; onboarding automation halted`); + return 0; + } + + let advanced = 0; + for (const account of accounts) { + try { + await ensureLinked(deps, account, chainId, chainName); + await ensureIban(deps, account, chainName); + advanced += 1; + } catch (error) { + // The next cycle retries; the financial-operation ledger keeps provider + // writes exactly-once across retries. + logger.error(`monerium-b2b: onboarding advance failed for account ${account.id}:`, error); + } + } + return advanced; +} + +export function resetOnboardingWarningForTests(): void { + configWarned = false; +} diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index b7c33f726..852a73f35 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -10,6 +10,7 @@ import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-pr import { runDormancyGate } from "../services/monerium-b2b/dormancy"; import { runMintWatcher } from "../services/monerium-b2b/mint-watcher"; import { runMonitoringPass } from "../services/monerium-b2b/monitoring"; +import { advanceOnboardingAccounts } from "../services/monerium-b2b/onboarding"; const DEFAULT_CRON_TIME = "* * * * *"; // every minute @@ -46,6 +47,10 @@ class MoneriumB2bWorker { try { await processMoneriumWebhookInbox(); + // Link + IBAN issuance for mapped accounts still in onboarding; internally + // gated on the whitelabel credentials, attestor key, and read RPC. + await advanceOnboardingAccounts(); + if (!isKeeperChainConfigured()) { if (!this.chainConfigWarned) { this.chainConfigWarned = true; diff --git a/docs/runbooks/monerium-b2b-onboarding.md b/docs/runbooks/monerium-b2b-onboarding.md index 92be4239e..4511c0b08 100644 --- a/docs/runbooks/monerium-b2b-onboarding.md +++ b/docs/runbooks/monerium-b2b-onboarding.md @@ -1,11 +1,15 @@ # Runbook: Monerium B2B Onramp — Client Onboarding -Deploy → manifest → verify → link → IBAN → penny test → activate. One pass per client. -Spec: `docs/prd/monerium-b2b-implementation-plan.md`; API call shapes below are the -sandbox-validated ones from registry item T4 (2026-07-17). +Deploy → manifest → verify → map → (automated: link + IBAN) → penny test → activate. +One pass per client. Spec: `docs/prd/monerium-b2b-implementation-plan.md`; API call +shapes below are the sandbox-validated ones from registry item T4 (2026-07-17). Prerequisites: guardian key funded on the target chain; `MONERIUM_B2B_*` env set -(API creds, attestor key, RPC); partner paperwork complete. +(API creds, attestor key, RPC); partner paperwork complete; the client company +onboarded and KYB-approved on Monerium's side (partner KYC reliance) with its +Monerium profile UUID at hand; the partner configured as a managed-profile manager +(`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type +`business`). ## 1. Paperwork inputs (from the partner agreement) @@ -47,47 +51,45 @@ trust root** (re-review R01): it lets anyone detect silent changes; it does not deployment was honest — that requires the verified source on the block explorer, so verify the factory + implementation source there as part of this step. -## 4. Create the Monerium profile + KYB +## 4. Map the client to a managed profile -``` -POST /profiles { "kind": "corporate" } -``` - -Note: `GET /profiles` (list) 404s on the whitelabel sandbox — use per-profile paths -(`GET /profiles/{id}`). KYB submission is deliberately unimplemented (`submitKybData` → 501) -until the whitelabel KYB mechanism is contractually settled — **registry T3**; for sandbox and -until then, KYB completion happens on Monerium's side. - -## 5. Link the forwarder address (attestor flow) - -The backend signs the fixed link message with the attestor key -(`signLinkAttestation` in `apps/api/src/api/services/monerium-b2b/attestor.ts` — bound to -chainid + forwarder address; the contract validates via constrained EIP-1271, EIP-191 variant -only). Sandbox-validated call shape (T4): +One idempotent admin call creates the managed child (business entity under the partner +manager), imports the Monerium KYB approval into `provider_customers`/`kyc_cases`, and +records the deployed forwarder as the `MoneriumAccount` row (status `onboarding`): ``` -POST /addresses +POST /v1/admin/monerium-b2b/accounts (Authorization: Bearer $ADMIN_SECRET) { - "address": "", - "chain": "", // e.g. "ethereum"; sandbox spike used Sepolia - "message": "I hereby declare that I am the address owner.", - "profile": "", - "signature": "" + "managerProfileId": "", + "externalSubjectId": "", + "contactEmail": "", + "moneriumProfileId": "", + "forwarderAddress": "", + "destination": "", + "fallbackAddress": "", + "feeBps": 0 } ``` -Expected: HTTP 201, address `state: linked` — zero client interaction (validated 2026-07-17, -including the hardened chainid-bound re-validation; sandbox artifacts in registry T4). +Replaying the identical call is safe (200); divergent input is a 409, never an overwrite. +KYB submission via the API remains deliberately unimplemented (`submitKybData` → 501, +registry T3) — approval always happens on Monerium's side before this step. -## 6. IBAN issuance +## 5. Link + IBAN (automated) -``` -POST /ibans { "address": "", "chain": "" } -``` +The keeper's onboarding step (`monerium-b2b/onboarding.ts`, every cycle) picks up every +mapped account in `onboarding` status and, exactly-once via the profile-scoped +`financial_operations` ledger: + +1. links the forwarder with the attestor signature (`POST /addresses`, constrained + EIP-1271, EIP-191 variant; sandbox-validated per registry T4 — HTTP 201, + `state: linked`, zero client interaction), then +2. requests IBAN issuance (`POST /ibans`, async — expect 202). -**Async: expect HTTP 202** (T4). Poll `GET /ibans` until the entry for the address appears with -state approved. Record the IBAN on the `MoneriumAccount` row (status stays `onboarding`) — -the association monitor treats the DB record as the reference state from here on. +The IBAN lands on the `MoneriumAccount` row via the `iban.updated` webhook (or the next +cycle's `GET /ibans` read); from then on the association monitor treats the DB record as +the reference state. Nothing to do manually — verify the row has its IBAN before the +penny test, and check the logs if it stays empty for more than a few cycles. ## 7. Penny test @@ -105,7 +107,13 @@ rotate or mis-credit) before real volume flows. ## 8. Activate -1. Set the `MoneriumAccount` row to `active`. +1. Activate the account (refused with 409 while the IBAN has not been issued): + + ``` + PATCH /v1/admin/monerium-b2b/accounts//status (Authorization: Bearer $ADMIN_SECRET) + { "status": "active" } + ``` + 2. Confirm the monitoring pass picks the account up cleanly (no association/config alerts on the next cycle). 3. Hand the client's IBAN over via the partner. Done. diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 9a589a1d9..d0449e19b 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -2,12 +2,12 @@ ## What This Does -The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.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, and the deposit processor. It is deliberately NOT part of the one-shot ramp state machine — accounts are persistent and repeatedly funded. +The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.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. **Provider type:** on-ramp (EUR → USDC) **Fiat currencies:** EUR **Chains involved:** Ethereum (forwarder contracts, EURe/USDC) -**Modules:** `monerium-b2b/whitelabel-client.ts`, `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts`; monitoring: `monerium-b2b/monitoring.ts` +**Modules:** `monerium-b2b/whitelabel-client.ts`, `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `monerium-b2b/account-provisioning.ts`, `monerium-b2b/onboarding.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`), `controllers/admin/moneriumB2b.controller.ts` (POST `/v1/admin/monerium-b2b/accounts`, ADMIN_SECRET); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts`; monitoring: `monerium-b2b/monitoring.ts` **API auth method:** OAuth client credentials (`MONERIUM_B2B_CLIENT_ID`/`MONERIUM_B2B_CLIENT_SECRET`) against `MONERIUM_B2B_API_URL` (sandbox `api.monerium.dev` by default); inbound webhooks authenticated by HMAC-SHA256 (`MONERIUM_B2B_WEBHOOK_SECRET`) ## Security Invariants @@ -22,7 +22,10 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e 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 is the Monerium order id** — `monerium_order_id` is unique; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are stored as 18-decimal base-unit strings converted from the provider decimal, never floats. 10. **Client credentials are env-only and requests are bounded** — whitelabel API credentials come from env, all calls carry an explicit timeout, HTTPS base URLs only, and upstream failures surface as generic 502s without echoing provider response bodies. -11. **KYB submission is a guarded stub** — `submitKybData` throws 501 until the whitelabel KYB mechanism is contractually settled (deferred-decisions registry T3); no speculative identity-data path exists. +11. **KYB submission is a guarded stub** — `submitKybData` throws 501 until the whitelabel KYB mechanism is contractually settled (deferred-decisions registry T3); no speculative identity-data path exists. Pilot corporates do not use it: they are onboarded and approved by Monerium under the partner's KYC reliance, and the admin mapping imports that outcome as an approved `kyc_cases` row. +12. **Account mapping is admin-only, idempotent, and conflict-on-divergence** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) provisions the managed child (business entity, via the managed-profile provisioning service and its manager checks), mirrors the approved KYB, and binds the operator-deployed forwarder. Replaying identical input returns the existing records; any divergence (same Monerium profile with different forwarder/destination/child, same child with a second Monerium profile, a Monerium profile already bound to a different customer entity) is a 409, never an overwrite. A Monerium profile, a forwarder address, and a managed profile can each back at most one account (unique + partial-unique indexes, migrations 069/071). +13. **Onboarding provider writes are exactly-once** — 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. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. +14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. ## Keeper @@ -69,7 +72,9 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] Inbox insert (`ON CONFLICT DO NOTHING` on `event_id`) happens before the 200 response in `monerium-b2b.controller.ts` - [ ] Forward-only transition guard covers all four statuses; regressive events are dropped, not applied - [ ] All deposit writes run under `pg_advisory_xact_lock` keyed by lower-cased forwarder address -- [ ] `monerium_order_id` unique constraint present; mint-log partial unique index present (migration 051) +- [ ] `monerium_order_id` unique constraint present; mint-log partial unique index present (migration 069) +- [ ] `monerium_accounts.vortex_profile_id` partial unique index present (migration 071); admin mapping rejects divergence with 409 (`moneriumB2b.controller.test.ts`) +- [ ] Onboarding link/IBAN calls wrapped in profile-scoped `financial_operations`; replay never repeats a provider write (`onboarding.test.ts`) - [ ] `submitKybData` still returns 501 unless registry item T3 has been resolved and this spec updated - [ ] HTTPS enforced for provider base URLs; timeouts configured on every provider call - [ ] Sandbox-verification TODOs resolved before production: exact `webhook-signature` digest encoding, delivery id field, upstream order-state vocabulary, EIP-191 vs raw link-hash variant (registry T4) From 2b793a829e5d0d62631e5ebe4f27cee4db9fd001 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 20:46:37 +0200 Subject: [PATCH 24/59] feat(api): add manager read surface for monerium b2b accounts GET /v1/monerium-b2b/account and /deposits let the partner manager (via X-Managed-Profile-Id delegation) or the child's own credential poll the account state and deposit-to-conversion history. Strictly effective-user scoped with the standard EU/business managed-profile policy; R09 unattributed inflows never surface. Docs and security spec updated for the EU corridor's B2B account scope. --- .../controllers/monerium-b2b.controller.ts | 112 ++++++++++++ apps/api/src/api/routes/v1/index.ts | 3 + .../src/api/routes/v1/monerium-b2b.route.ts | 10 ++ ...erium-b2b-account-read.integration.test.ts | 162 ++++++++++++++++++ docs/api/pages/14-managed-profiles.md | 4 +- .../05-integrations/monerium-b2b.md | 1 + 6 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/tests/monerium-b2b-account-read.integration.test.ts diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index ed39f2a5f..94156621f 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -1,9 +1,15 @@ import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; +import { Op } from "sequelize"; import logger from "../../config/logger"; import { config } from "../../config/vars"; +import MoneriumAccount from "../../models/moneriumAccount.model"; +import MoneriumConversionExecution from "../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit from "../../models/moneriumFiatDeposit.model"; import { APIError } from "../errors/api-error"; +import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { UNATTRIBUTED_ORDER_PREFIX } from "../services/monerium-b2b/mint-watcher"; import { deriveEventId, MONERIUM_SIGNATURE_HEADER, @@ -49,3 +55,109 @@ export const handleWebhook = async (req: Request, res: Response, next: NextFunct next(error); } }; + +async function findAccountForEffectiveUser(req: Request): Promise { + const effectiveUserId = getEffectiveUserId(req); + if (!effectiveUserId) return null; + return MoneriumAccount.findOne({ where: { vortexProfileId: effectiveUserId } }); +} + +function accountNotFound(res: Response): void { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND", + message: "No Monerium account exists for the acting profile", + status: httpStatus.NOT_FOUND + } + }); +} + +/** + * GET /v1/monerium-b2b/account — the acting profile's onramp account. Scoped strictly + * to the effective user (manager delegation header or the child's own credential); no + * caller-supplied account or profile identifier is accepted. + */ +export const getMoneriumB2bAccount = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const account = await findAccountForEffectiveUser(req); + if (!account) { + accountNotFound(res); + return; + } + res.status(httpStatus.OK).json({ + account: { + accountId: account.id, + createdAt: account.createdAt, + destination: account.destination, + dormantSince: account.dormantSince, + fallbackAddress: account.fallbackAddress, + feeBps: account.feeBps, + forwarderAddress: account.forwarderAddress, + iban: account.iban, + status: account.status + } + }); + } catch (error) { + next(error); + } +}; + +const DEPOSIT_LIST_MAX_LIMIT = 100; + +/** + * GET /v1/monerium-b2b/deposits — the acting profile's EUR deposits, newest first, + * each with its allocated conversion execution once the swap has run. This is the + * polling surface for "payment received / converted". + */ +export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const account = await findAccountForEffectiveUser(req); + if (!account) { + accountNotFound(res); + return; + } + + const rawLimit = Number(req.query.limit ?? 20); + const rawOffset = Number(req.query.offset ?? 0); + const limit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, DEPOSIT_LIST_MAX_LIMIT) : 20; + const offset = Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0; + + const { count, rows } = await MoneriumFiatDeposit.findAndCountAll({ + limit, + offset, + order: [["created_at", "DESC"]], + // Unattributed inflows (R09 synthetic rows) are an ops concern, never a + // customer deposit claim — spec invariant, keep them out of the API. + where: { accountId: account.id, moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` } } + }); + + const executionIds = [...new Set(rows.map(row => row.allocatedExecutionId).filter((id): id is string => id !== null))]; + const executions = executionIds.length ? await MoneriumConversionExecution.findAll({ where: { id: executionIds } }) : []; + const executionById = new Map(executions.map(execution => [execution.id, execution])); + + res.status(httpStatus.OK).json({ + deposits: rows.map(row => { + const execution = row.allocatedExecutionId ? executionById.get(row.allocatedExecutionId) : undefined; + return { + amountRaw: row.amountRaw, + conversion: execution + ? { + executionId: execution.id, + status: execution.status, + txHash: execution.txHash, + usdcNetRaw: execution.usdcNetRaw + } + : null, + createdAt: row.createdAt, + currency: row.currency, + depositId: row.id, + status: row.status, + txHash: row.txHash + }; + }), + pagination: { limit, offset, total: count } + }); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 9951c0a01..03cd98628 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -187,6 +187,9 @@ router.use("/monerium", moneriumRoutes); /** * Monerium B2B whitelabel onramp. * POST /v1/monerium-b2b/webhook — HMAC-authenticated durable-inbox webhook receiver. + * GET /v1/monerium-b2b/account — the acting profile's onramp account (manager + * delegation or child credential; EU/business policy). + * GET /v1/monerium-b2b/deposits — the acting profile's deposits with conversion status. */ router.use("/monerium-b2b", moneriumB2bRoutes); diff --git a/apps/api/src/api/routes/v1/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/monerium-b2b.route.ts index 8427f5a56..8c8705438 100644 --- a/apps/api/src/api/routes/v1/monerium-b2b.route.ts +++ b/apps/api/src/api/routes/v1/monerium-b2b.route.ts @@ -1,9 +1,19 @@ import { Router } from "express"; import * as moneriumB2bController from "../../controllers/monerium-b2b.controller"; +import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; +import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; const router = Router(); // Authenticated by HMAC signature over the raw body (no session/API-key auth). router.post("/webhook", moneriumB2bController.handleWebhook); +// Read surface for the account owner: the partner manager acting via +// X-Managed-Profile-Id, or the child's own credential. Corridor and customer-type +// policy match the B2B onramp scope (EU, business). +const accountAuth = [requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "EU", customerType: "business" })]; + +router.get("/account", ...accountAuth, moneriumB2bController.getMoneriumB2bAccount); +router.get("/deposits", ...accountAuth, moneriumB2bController.listMoneriumB2bDeposits); + export default router; diff --git a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts new file mode 100644 index 000000000..7777d6c71 --- /dev/null +++ b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts @@ -0,0 +1,162 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type { CorridorCountry } from "@vortexfi/shared"; +import ManagedProfileManager from "../models/managedProfileManager.model"; +import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../models/moneriumFiatDeposit.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestApiKey, createTestUser } from "../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; +import { provisionMoneriumB2bAccount } from "../api/services/monerium-b2b/account-provisioning"; + +const FORWARDER = "0x1111111111111111111111111111111111111111"; +const DESTINATION = "0x2222222222222222222222222222222222222222"; +const FALLBACK = "0x3333333333333333333333333333333333333333"; +const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; + +describe("monerium b2b account read surface", () => { + let app: TestApp; + let world: FakeWorld; + + beforeAll(async () => { + world = installFakeWorld(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + await app?.close(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + async function jsonRequest(path: string, headers: Record): Promise<{ body: Record; status: number }> { + const response = await app.request(path, { headers, method: "GET" }); + const text = await response.text(); + return { body: text ? (JSON.parse(text) as Record) : {}, status: response.status }; + } + + async function setupMappedChild(corridors: CorridorCountry[] = ["EU"]) { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: corridors, + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const managerCredential = await createTestApiKey({ userId: manager.id }); + const mapped = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: DESTINATION, + externalSubjectId: "client-1", + fallbackAddress: FALLBACK, + forwarderAddress: FORWARDER, + managerProfileId: manager.id, + moneriumProfileId: MONERIUM_PROFILE + }); + return { + delegatedHeaders: { "X-API-Key": managerCredential.plaintextKey, "X-Managed-Profile-Id": mapped.profileId }, + managerHeaders: { "X-API-Key": managerCredential.plaintextKey }, + mapped + }; + } + + it("returns the acting child's account and deposit history with conversion status", async () => { + const { delegatedHeaders, mapped } = await setupMappedChild(); + + const account = await jsonRequest("/v1/monerium-b2b/account", delegatedHeaders); + expect(account.status).toBe(200); + expect(account.body.account).toMatchObject({ + accountId: mapped.accountId, + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: FORWARDER, + iban: null, + status: "onboarding" + }); + + const execution = await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + destination: DESTINATION, + eureInRaw: "100000000000000000000", + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + currency: "eur", + allocatedExecutionId: execution.id, + amountRaw: "100000000000000000000", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + currency: "eur", + amountRaw: "50000000000000000000", + moneriumOrderId: "order-2", + status: MoneriumFiatDepositStatus.Pending + }); + // R09 synthetic unattributed inflow: ops-only, must never appear in the API. + await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + currency: "eur", + amountRaw: "1000000000000000000", + moneriumOrderId: "unattr:1:0xdead:0", + status: MoneriumFiatDepositStatus.Minted + }); + + const deposits = await jsonRequest("/v1/monerium-b2b/deposits", delegatedHeaders); + expect(deposits.status).toBe(200); + const rows = deposits.body.deposits as Array>; + expect(rows).toHaveLength(2); + expect(rows.map(row => row.status)).toEqual(["pending", "minted"]); + expect(rows[1]).toMatchObject({ + amountRaw: "100000000000000000000", + conversion: { executionId: execution.id, status: "confirmed", txHash: "0xswap", usdcNetRaw: "108000000" }, + txHash: "0xmint" + }); + expect(rows[0].conversion).toBeNull(); + expect(deposits.body.pagination).toMatchObject({ total: 2 }); + }); + + it("rejects delegation for a manager without the EU corridor", async () => { + const { delegatedHeaders } = await setupMappedChild(["BR"]); + const response = await jsonRequest("/v1/monerium-b2b/account", delegatedHeaders); + expect(response.status).toBe(403); + }); + + it("rejects a foreign manager and unauthenticated callers", async () => { + const { mapped } = await setupMappedChild(); + + const stranger = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: stranger.id + }); + const strangerCredential = await createTestApiKey({ userId: stranger.id }); + const foreign = await jsonRequest("/v1/monerium-b2b/account", { + "X-API-Key": strangerCredential.plaintextKey, + "X-Managed-Profile-Id": mapped.profileId + }); + expect(foreign.status).toBe(403); + + const unauthenticated = await app.request("/v1/monerium-b2b/account", { method: "GET" }); + expect(unauthenticated.status).toBe(401); + }); + + it("returns 404 for an authenticated profile without a mapped account", async () => { + const { managerHeaders } = await setupMappedChild(); + const response = await jsonRequest("/v1/monerium-b2b/account", managerHeaders); + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ error: { code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND" } }); + }); +}); diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 0b236ccb9..d0d2fea3c 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -10,10 +10,10 @@ This page is the integration walkthrough. The exact authorization contract — e Manager status is granted by Vortex, not self-service. During partner onboarding, Vortex enables your profile as a managed-profile manager and assigns: -- **Allowed corridors** — the countries (`BR`, `AR`, `CO`, `MX`, `US`) your children may operate in. +- **Allowed corridors** — the countries (`BR`, `AR`, `CO`, `MX`, `US`, `EU`) your children may operate in. - **Optional customer-type narrowing** — restrict children to `individual` or `business`; a null policy allows both wherever the corridor's canonical capability matrix does. -Every delegated operation re-checks this policy at request time, so a corridor removed from your manager record immediately blocks new mutations for children in that corridor (in-flight ramps continue). EUR is not available for managed children — its flows are bound to a verified login email. +Every delegated operation re-checks this policy at request time, so a corridor removed from your manager record immediately blocks new mutations for children in that corridor (in-flight ramps continue). Quoted EUR ramps are not available for managed children — those flows are bound to a verified login email. The `EU` corridor instead covers the dedicated business EUR onramp account surface (`GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` under delegation or a child credential), available to business children whose accounts Vortex provisions during partner onboarding. ## Create A Managed Child diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index d0449e19b..cde352018 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -26,6 +26,7 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e 12. **Account mapping is admin-only, idempotent, and conflict-on-divergence** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) provisions the managed child (business entity, via the managed-profile provisioning service and its manager checks), mirrors the approved KYB, and binds the operator-deployed forwarder. Replaying identical input returns the existing records; any divergence (same Monerium profile with different forwarder/destination/child, same child with a second Monerium profile, a Monerium profile already bound to a different customer entity) is a 409, never an overwrite. A Monerium profile, a forwarder address, and a managed profile can each back at most one account (unique + partial-unique indexes, migrations 069/071). 13. **Onboarding provider writes are exactly-once** — 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. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. +15. **The read surface is effective-user scoped and accepts no selectors** — `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` resolve the account strictly from the acting profile (manager delegation via `X-Managed-Profile-Id` under the standard managed-profile authorization with EU corridor + business policy, or the child's own credential); no caller-supplied account, profile, or IBAN identifier is accepted, a foreign manager gets the uniform managed-profile 403, and R09 `unattr:` synthetic deposit rows are never returned (`monerium-b2b-account-read.integration.test.ts`). ## Keeper From 9ff37108f9eb9fde1c8eb2ac13fabd74a6ff344a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 21:00:53 +0200 Subject: [PATCH 25/59] feat(shared): add account-scoped deposit webhook event family DEPOSIT_RECEIVED and DEPOSIT_CONVERTED extend the webhook contract for business EUR onramp accounts: quoteless envelopes carrying the deposit and its conversion outcome, deliverable to the account's controlling manager. Existing transaction payloads are unchanged. --- docs/api/wire-contract.snapshot.md | 120 +++++++++++++++++- .../shared/src/endpoints/webhook.endpoints.ts | 63 ++++++++- 2 files changed, 178 insertions(+), 5 deletions(-) diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index b6baed725..30de77044 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -12,6 +12,8 @@ A diff here means: check backward compatibility for live integrations, and keep ## packages/shared — partner wire contract (`src/endpoints`) ```text +ACCOUNT_WEBHOOK_EVENT_TYPES: readonly [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED] + AcceptedRecipientInvite: { id: string; invitation: { @@ -431,6 +433,54 @@ DeleteWebhookResponse: { success: boolean; } +DepositConvertedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_CONVERTED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + } & { + conversion: { + executionId: string; + txHash: null | string; + usdcNetRaw: null | string; + }; + }; + timestamp: string; +} + +DepositReceivedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RECEIVED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + }; + timestamp: string; +} + +DepositStatus: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" } + +DepositWebhookPayloadBase: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; +} + DomesticAddFiatAccountRequest: { accountBankCode?: string; accountName?: string; @@ -1608,7 +1658,7 @@ RegisterRampResponse: { } RegisterWebhookRequest: { - events?: Array; + events?: Array; quoteId?: string; sessionId?: string; url: string; @@ -1616,7 +1666,7 @@ RegisterWebhookRequest: { RegisterWebhookResponse: { createdAt: string; - events: Array; + events: Array; id: string; isActive: boolean; quoteId: null | string; @@ -2363,6 +2413,38 @@ WebhookDeliveryAttempt: { maxAttempts: number; nextRetryAt?: Date; payload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_CONVERTED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + } & { + conversion: { + executionId: string; + txHash: null | string; + usdcNetRaw: null | string; + }; + }; + timestamp: string; + } | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RECEIVED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + }; + timestamp: string; + } | { eventId: string; eventType: WebhookEventType.STATUS_CHANGE; payload: { @@ -2389,9 +2471,41 @@ WebhookDeliveryAttempt: { webhookId: string; } -WebhookEventType: enum WebhookEventType { STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } +WebhookEventType: enum WebhookEventType { DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } WebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_CONVERTED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + } & { + conversion: { + executionId: string; + txHash: null | string; + usdcNetRaw: null | string; + }; + }; + timestamp: string; +} | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RECEIVED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + }; + timestamp: string; +} | { eventId: string; eventType: WebhookEventType.STATUS_CHANGE; payload: { diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index dba918272..ec050c12e 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -2,7 +2,24 @@ import { RampDirection } from "../index"; export enum WebhookEventType { TRANSACTION_CREATED = "TRANSACTION_CREATED", - STATUS_CHANGE = "STATUS_CHANGE" + STATUS_CHANGE = "STATUS_CHANGE", + DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", + DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED" +} + +/** + * The account-scoped event family (business EUR onramp accounts). Subscriptions to + * these events are registered without a quoteId/sessionId, cannot be mixed with the + * transaction events in one webhook, and are delivered durably (at-least-once with + * backoff) to the account's controlling manager. + */ +export const ACCOUNT_WEBHOOK_EVENT_TYPES = [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED] as const; + +export enum DepositStatus { + PENDING = "pending", + MINTED = "minted", + HELD = "held", + RETURNED = "returned" } export enum TransactionStatus { @@ -61,7 +78,49 @@ export interface StatusChangeWebhookPayload { payload: WebhookPayloadBase; } -export type WebhookPayload = TransactionCreatedWebhookPayload | StatusChangeWebhookPayload; +export interface DepositWebhookPayloadBase { + /** The onramp account the deposit belongs to. */ + accountId: string; + /** The managed child profile that owns the account. */ + profileId: string; + depositId: string; + /** Deposit amount in 18-decimal base units of the deposit currency. */ + amountRaw: string; + currency: string; + status: DepositStatus; + /** The on-chain mint transaction, when observed. */ + txHash: string | null; +} + +export interface DepositReceivedWebhookPayload { + /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.DEPOSIT_RECEIVED; + timestamp: string; + payload: DepositWebhookPayloadBase; +} + +export interface DepositConvertedWebhookPayload { + /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.DEPOSIT_CONVERTED; + timestamp: string; + payload: DepositWebhookPayloadBase & { + conversion: { + executionId: string; + /** The swap-and-forward transaction. */ + txHash: string | null; + /** Net USDC forwarded for the whole execution, 6-decimal base units. */ + usdcNetRaw: string | null; + }; + }; +} + +export type WebhookPayload = + | TransactionCreatedWebhookPayload + | StatusChangeWebhookPayload + | DepositReceivedWebhookPayload + | DepositConvertedWebhookPayload; export interface WebhookDeliveryAttempt { webhookId: string; From a8c94b7abbdcdcf636a3e603eebb7c31714733a3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 21:00:54 +0200 Subject: [PATCH 26/59] feat(api): deliver deposit events durably to account managers Deposit-family subscriptions register without a quote or session, are profile-owned only, and can never be mixed with transaction events or silently defaulted. Delivery goes through a new durable outbox (webhook_deliveries + claim-based dispatch worker with backoff, no webhook deactivation on outages) instead of the in-process retry loop. The monerium keeper emits DEPOSIT_RECEIVED at mint and DEPOSIT_CONVERTED at the 32-block notification depth, marked per deposit so each event fires exactly once and history never replays to late subscribers. Also fixes the webhook model validator that still hardcoded the legacy event pair. --- .../monerium-b2b/manager-events.test.ts | 200 ++++++++++++++++++ .../services/monerium-b2b/manager-events.ts | 152 +++++++++++++ .../webhook/__tests__/webhook.service.test.ts | 102 +++++++++ .../webhook/webhook-delivery.service.ts | 27 ++- .../webhook/webhook-outbox.service.test.ts | 143 +++++++++++++ .../webhook/webhook-outbox.service.ts | 118 +++++++++++ .../api/services/webhook/webhook.service.ts | 93 ++++++-- .../src/api/workers/monerium-b2b.worker.ts | 5 + .../src/api/workers/webhook-outbox.worker.ts | 55 +++++ .../072-create-webhook-deliveries.ts | 41 ++++ .../073-add-monerium-deposit-event-markers.ts | 20 ++ apps/api/src/index.ts | 2 + apps/api/src/models/index.ts | 6 +- .../src/models/moneriumFiatDeposit.model.ts | 16 ++ apps/api/src/models/webhook.model.ts | 2 +- apps/api/src/models/webhookDelivery.model.ts | 76 +++++++ 16 files changed, 1035 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/api/services/monerium-b2b/manager-events.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/manager-events.ts create mode 100644 apps/api/src/api/services/webhook/webhook-outbox.service.test.ts create mode 100644 apps/api/src/api/services/webhook/webhook-outbox.service.ts create mode 100644 apps/api/src/api/workers/webhook-outbox.worker.ts create mode 100644 apps/api/src/database/migrations/072-create-webhook-deliveries.ts create mode 100644 apps/api/src/database/migrations/073-add-monerium-deposit-event-markers.ts create mode 100644 apps/api/src/models/webhookDelivery.model.ts diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts new file mode 100644 index 000000000..2b5bb1899 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts @@ -0,0 +1,200 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { WebhookEventType } from "@vortexfi/shared"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import Webhook from "../../../models/webhook.model"; +import WebhookDelivery from "../../../models/webhookDelivery.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import { provisionMoneriumB2bAccount } from "./account-provisioning"; +import { NOTIFY_CONFIRMATION_DEPTH } from "./chain"; +import { emitMoneriumDepositEvents } from "./manager-events"; + +const FORWARDER = "0x1111111111111111111111111111111111111111"; +const DESTINATION = "0x2222222222222222222222222222222222222222"; +const FALLBACK = "0x3333333333333333333333333333333333333333"; +const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; + +describe("monerium b2b manager events", () => { + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + async function setupAccountWithWebhook(events: WebhookEventType[]) { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const mapped = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: DESTINATION, + externalSubjectId: "client-1", + fallbackAddress: FALLBACK, + forwarderAddress: FORWARDER, + managerProfileId: manager.id, + moneriumProfileId: MONERIUM_PROFILE + }); + const webhook = + events.length > 0 + ? await Webhook.create({ + events, + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: "https://manager.example.com/hook", + userId: manager.id + }) + : null; + return { managerId: manager.id, mapped, webhook }; + } + + function depsAtBlock(block: bigint | null) { + return { getBlockNumber: async () => block }; + } + + it("emits DEPOSIT_RECEIVED once per minted deposit and never for unattributed rows", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + currency: "eur", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "1000000000000000000", + currency: "eur", + moneriumOrderId: "unattr:1:0xdead:0", + status: MoneriumFiatDepositStatus.Minted + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await emitMoneriumDepositEvents(depsAtBlock(null)); + + const deliveries = await WebhookDelivery.findAll(); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]).toMatchObject({ + eventId: `deposit-received:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_RECEIVED, + webhookId: webhook?.id + }); + expect(deliveries[0].payload).toMatchObject({ + eventType: WebhookEventType.DEPOSIT_RECEIVED, + payload: { + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + depositId: deposit.id, + profileId: mapped.profileId, + status: "minted", + txHash: "0xmint" + } + }); + + await deposit.reload(); + expect(deposit.receivedEventAt).not.toBeNull(); + }); + + it("marks pending events emitted even without subscribers so history never replays", async () => { + const { mapped } = await setupAccountWithWebhook([]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + currency: "eur", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await deposit.reload(); + expect(deposit.receivedEventAt).not.toBeNull(); + expect(await WebhookDelivery.count()).toBe(0); + }); + + it("emits DEPOSIT_CONVERTED only at confirmation depth", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_CONVERTED]); + const execution = await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1000, + destination: DESTINATION, + eureInRaw: "100000000000000000000", + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + allocatedExecutionId: execution.id, + amountRaw: "100000000000000000000", + currency: "eur", + moneriumOrderId: "order-1", + receivedEventAt: new Date(), + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + + // One block short of the depth: nothing emitted, marker untouched. + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1000 + NOTIFY_CONFIRMATION_DEPTH - 1))); + expect(await WebhookDelivery.count()).toBe(0); + await deposit.reload(); + expect(deposit.convertedEventAt).toBeNull(); + + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1000 + NOTIFY_CONFIRMATION_DEPTH))); + const deliveries = await WebhookDelivery.findAll(); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]).toMatchObject({ + eventId: `deposit-converted:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_CONVERTED, + webhookId: webhook?.id + }); + expect(deliveries[0].payload).toMatchObject({ + payload: { + conversion: { executionId: execution.id, txHash: "0xswap", usdcNetRaw: "108000000" }, + depositId: deposit.id + } + }); + await deposit.reload(); + expect(deposit.convertedEventAt).not.toBeNull(); + + // Replay is a no-op. + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1000 + NOTIFY_CONFIRMATION_DEPTH))); + expect(await WebhookDelivery.count()).toBe(1); + }); + + it("only enqueues to the controlling manager's webhooks", async () => { + const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); + const otherManager = await createTestUser(); + await Webhook.create({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: "https://other.example.com/hook", + userId: otherManager.id + }); + await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + currency: "eur", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + + const deliveries = await WebhookDelivery.findAll({ include: [{ as: "webhook", model: Webhook }] }); + expect(deliveries).toHaveLength(1); + expect((deliveries[0] as WebhookDelivery & { webhook: Webhook }).webhook.url).toBe("https://manager.example.com/hook"); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.ts b/apps/api/src/api/services/monerium-b2b/manager-events.ts new file mode 100644 index 000000000..9a31c1ce0 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -0,0 +1,152 @@ +import { DepositStatus, type DepositWebhookPayloadBase, WebhookEventType, type WebhookPayload } from "@vortexfi/shared"; +import { Op } from "sequelize"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import ManagedProfile from "../../../models/managedProfile.model"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import webhookService from "../webhook/webhook.service"; +import { enqueueWebhookDeliveries } from "../webhook/webhook-outbox.service"; +import { getPublicClient, NOTIFY_CONFIRMATION_DEPTH } from "./chain"; +import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; + +const BATCH_LIMIT = 100; + +export interface ManagerEventDeps { + /** Current chain head, or null when no read RPC is configured. */ + getBlockNumber(): Promise; +} + +const defaultDeps: ManagerEventDeps = { + async getBlockNumber() { + if (!config.moneriumB2b.rpcUrl) return null; + return getPublicClient().getBlockNumber(); + } +}; + +function depositPayloadBase(deposit: MoneriumFiatDeposit, account: MoneriumAccount): DepositWebhookPayloadBase { + return { + accountId: account.id, + amountRaw: deposit.amountRaw, + currency: deposit.currency, + depositId: deposit.id, + profileId: account.vortexProfileId as string, + status: deposit.status as unknown as DepositStatus, + txHash: deposit.txHash + }; +} + +/** + * Resolves the controlling manager for an account's deposit events. Returns null when + * the account is unmapped or the managed relationship is gone — the event is then + * marked emitted with no deliveries, so history is never replayed to late subscribers. + */ +async function resolveManagerProfileId(account: MoneriumAccount): Promise { + if (!account.vortexProfileId) return null; + const relationship = await ManagedProfile.findOne({ + where: { profileId: account.vortexProfileId, status: "active" } + }); + return relationship?.managerProfileId ?? null; +} + +async function enqueueForManager( + eventType: WebhookEventType, + managerProfileId: string | null, + payload: WebhookPayload +): Promise { + if (!managerProfileId) return 0; + const webhooks = await webhookService.findAccountEventWebhooks(eventType, managerProfileId); + await enqueueWebhookDeliveries(webhooks, payload); + return webhooks.length; +} + +async function emitReceivedEvents(): Promise { + const deposits = await MoneriumFiatDeposit.findAll({ + limit: BATCH_LIMIT, + order: [["created_at", "ASC"]], + where: { + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + receivedEventAt: null, + status: MoneriumFiatDepositStatus.Minted + } + }); + + for (const deposit of deposits) { + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) continue; + const managerProfileId = await resolveManagerProfileId(account); + const payload: WebhookPayload = { + eventId: `deposit-received:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_RECEIVED, + payload: depositPayloadBase(deposit, account), + timestamp: new Date().toISOString() + }; + await enqueueForManager(WebhookEventType.DEPOSIT_RECEIVED, managerProfileId, payload); + // Marked emitted even with zero subscribers: webhooks are forward-looking, a + // later registration must not receive the whole history. A crash between the + // enqueue and this marker is absorbed by the outbox (webhook_id, event_id) dedup. + await deposit.update({ receivedEventAt: new Date() }); + } +} + +async function emitConvertedEvents(deps: ManagerEventDeps): Promise { + const deposits = await MoneriumFiatDeposit.findAll({ + limit: BATCH_LIMIT, + order: [["created_at", "ASC"]], + where: { + allocatedExecutionId: { [Op.ne]: null }, + convertedEventAt: null, + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` } + } + }); + if (deposits.length === 0) return; + + const head = await deps.getBlockNumber(); + if (head === null) return; // no read RPC: emit once the chain is configured + + for (const deposit of deposits) { + const execution = await MoneriumConversionExecution.findByPk(deposit.allocatedExecutionId as string); + if (!execution || execution.status !== MoneriumConversionExecutionStatus.Confirmed) continue; + // Confirmation-depth gate (plan §3, registry P9): only notify once the execution + // block is NOTIFY_CONFIRMATION_DEPTH below the head, so a shallow reorg cannot + // produce a delivered-then-vanished conversion event. + if (execution.blockNumber === null || head < BigInt(execution.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH)) continue; + + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) continue; + const managerProfileId = await resolveManagerProfileId(account); + const payload: WebhookPayload = { + eventId: `deposit-converted:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_CONVERTED, + payload: { + ...depositPayloadBase(deposit, account), + conversion: { + executionId: execution.id, + txHash: execution.txHash, + usdcNetRaw: execution.usdcNetRaw + } + }, + timestamp: new Date().toISOString() + }; + await enqueueForManager(WebhookEventType.DEPOSIT_CONVERTED, managerProfileId, payload); + await deposit.update({ convertedEventAt: new Date() }); + } +} + +/** + * Emits the manager-facing deposit events into the durable webhook outbox: + * DEPOSIT_RECEIVED once a deposit is minted, DEPOSIT_CONVERTED once its allocated + * execution is confirmed at notification depth. Emission markers on the deposit row + * make each event fire exactly once regardless of which component advanced the state. + */ +export async function emitMoneriumDepositEvents(deps: ManagerEventDeps = defaultDeps): Promise { + try { + await emitReceivedEvents(); + await emitConvertedEvents(deps); + } catch (error) { + logger.error("monerium-b2b: manager event emission failed:", error); + } +} diff --git a/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts b/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts index 8c15fc65d..b2e970e06 100644 --- a/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts +++ b/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts @@ -381,6 +381,108 @@ describe('WebhookService', () => { }); }); + describe('registerWebhook (deposit events)', () => { + it('registers an account-scoped deposit webhook without a quote or session', async () => { + const mockWebhook = createMockWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED], + partnerId: null, + quoteId: null, + userId: 'user-1' + }); + createMock.mockResolvedValue(mockWebhook); + + await webhookService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED], + url: 'https://example.com/webhook' + }, USER_OWNER); + + expect(quoteTicketFindByPkMock).not.toHaveBeenCalled(); + expect(createMock).toHaveBeenCalledWith({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED], + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: 'https://example.com/webhook', + userId: 'user-1' + }); + }); + + it('rejects mixing deposit events with transaction events', async () => { + const error = await webhookService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.STATUS_CHANGE], + url: 'https://example.com/webhook' + }, USER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('rejects a deposit webhook carrying a quoteId or sessionId', async () => { + for (const target of [{ quoteId: 'quote-123' }, { sessionId: 'session-1' }]) { + const error = await webhookService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + url: 'https://example.com/webhook', + ...target + }, USER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); + } + expect(createMock).not.toHaveBeenCalled(); + }); + + it('rejects a deposit webhook for a partner-scoped credential', async () => { + // Delivery resolves the account's controlling manager by profile, so a + // partner-owned row could never match — refuse it up front. + const error = await webhookService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + url: 'https://example.com/webhook' + }, PARTNER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('never defaults omitted events into the deposit family', async () => { + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote()); + createMock.mockResolvedValue(createMockWebhook()); + + await webhookService.registerWebhook({ + url: 'https://example.com/webhook', + quoteId: 'quote-123' + }, PARTNER_OWNER); + + expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ + events: [WebhookEventType.TRANSACTION_CREATED, WebhookEventType.STATUS_CHANGE] + })); + }); + }); + + describe('findAccountEventWebhooks', () => { + it('filters by owner profile, event type, and active flag', async () => { + findAllMock.mockResolvedValue([]); + + await webhookService.findAccountEventWebhooks(WebhookEventType.DEPOSIT_RECEIVED, 'manager-1'); + + expect(findAllMock).toHaveBeenCalledWith({ + where: { + events: { [Op.contains]: [WebhookEventType.DEPOSIT_RECEIVED] }, + isActive: true, + userId: 'manager-1' + } + }); + }); + }); + describe('deleteWebhook', () => { it('should delete an existing webhook owned by the caller', async () => { // Mock data diff --git a/apps/api/src/api/services/webhook/webhook-delivery.service.ts b/apps/api/src/api/services/webhook/webhook-delivery.service.ts index b39cbc7f6..b097a9bf4 100644 --- a/apps/api/src/api/services/webhook/webhook-delivery.service.ts +++ b/apps/api/src/api/services/webhook/webhook-delivery.service.ts @@ -24,7 +24,12 @@ export class WebhookDeliveryService { return TransactionStatus.PENDING; } - private async deliverWebhook(webhook: Webhook, payload: WebhookPayload, attempt = 1): Promise { + /** + * One signed delivery attempt with the SSRF re-resolution guard. Shared by the + * legacy in-process retry loop and the durable outbox dispatcher, which owns its + * own retry/backoff bookkeeping. + */ + public async deliverSingleAttempt(webhook: Webhook, payload: WebhookPayload): Promise<{ ok: boolean; error: string | null }> { try { // Re-resolved on every attempt so a DNS record cannot be re-pointed at internal // infrastructure after registration (SSRF guard). @@ -59,16 +64,22 @@ export class WebhookDeliveryService { clearTimeout(timeoutId); if (response.ok) { - logger.info(`Webhook delivered successfully: ${webhook.id} to ${webhook.url} (attempt ${attempt})`); - return true; + return { error: null, ok: true }; } - - logger.warn(`Webhook delivery failed: ${webhook.id} to ${webhook.url} - Status: ${response.status} (attempt ${attempt})`); - return false; + return { error: `HTTP ${response.status}`, ok: false }; } catch (error) { - logger.error(`Webhook delivery error: ${webhook.id} to ${webhook.url} (attempt ${attempt}):`, error); - return false; + return { error: error instanceof Error ? error.message : String(error), ok: false }; + } + } + + private async deliverWebhook(webhook: Webhook, payload: WebhookPayload, attempt = 1): Promise { + const result = await this.deliverSingleAttempt(webhook, payload); + if (result.ok) { + logger.info(`Webhook delivered successfully: ${webhook.id} to ${webhook.url} (attempt ${attempt})`); + return true; } + logger.warn(`Webhook delivery failed: ${webhook.id} to ${webhook.url} - ${result.error} (attempt ${attempt})`); + return false; } private async deliverWithRetry(webhook: Webhook, payload: WebhookPayload): Promise { diff --git a/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts b/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts new file mode 100644 index 000000000..951f1914e --- /dev/null +++ b/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts @@ -0,0 +1,143 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { WebhookEventType, type WebhookPayload } from "@vortexfi/shared"; +import Webhook from "../../../models/webhook.model"; +import WebhookDelivery, { WebhookDeliveryStatus } from "../../../models/webhookDelivery.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import webhookDeliveryService from "./webhook-delivery.service"; +import { + dispatchDueWebhookDeliveries, + enqueueWebhookDeliveries, + reconcileStuckWebhookDeliveries +} from "./webhook-outbox.service"; + +const realDeliverSingleAttempt = webhookDeliveryService.deliverSingleAttempt.bind(webhookDeliveryService); +let deliverResults: Array<{ ok: boolean; error: string | null }> = []; +let deliverCalls: Array<{ url: string; payload: WebhookPayload }> = []; + +function payloadFor(eventId: string): WebhookPayload { + return { + eventId, + eventType: WebhookEventType.DEPOSIT_RECEIVED, + payload: { + accountId: "acc-1", + amountRaw: "100000000000000000000", + currency: "eur", + depositId: "dep-1", + profileId: "profile-1", + status: "minted", + txHash: null + }, + timestamp: new Date().toISOString() + } as WebhookPayload; +} + +describe("webhook delivery outbox", () => { + beforeAll(async () => { + await setupTestDatabase(); + webhookDeliveryService.deliverSingleAttempt = async (webhook, payload) => { + deliverCalls.push({ payload, url: webhook.url }); + return deliverResults.shift() ?? { error: "no scripted result", ok: false }; + }; + }); + + afterAll(() => { + webhookDeliveryService.deliverSingleAttempt = realDeliverSingleAttempt; + }); + + beforeEach(async () => { + await resetTestDatabase(); + deliverResults = []; + deliverCalls = []; + }); + + async function createDepositWebhook(): Promise { + const owner = await createTestUser(); + return Webhook.create({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED], + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: "https://integrator.example.com/hook", + userId: owner.id + }); + } + + it("enqueues idempotently per (webhook, event)", async () => { + const webhook = await createDepositWebhook(); + const payload = payloadFor("deposit-received:dep-1"); + + await enqueueWebhookDeliveries([webhook], payload); + await enqueueWebhookDeliveries([webhook], payload); + + expect(await WebhookDelivery.count()).toBe(1); + }); + + it("dispatches a due delivery and records the sent outcome", async () => { + const webhook = await createDepositWebhook(); + await enqueueWebhookDeliveries([webhook], payloadFor("deposit-received:dep-1")); + deliverResults = [{ error: null, ok: true }]; + + expect(await dispatchDueWebhookDeliveries()).toBe(1); + + const row = await WebhookDelivery.findOne({ where: { webhookId: webhook.id } }); + expect(row?.status).toBe(WebhookDeliveryStatus.Sent); + expect(row?.attempts).toBe(1); + expect(row?.sentAt).not.toBeNull(); + expect(deliverCalls).toHaveLength(1); + expect(deliverCalls[0].url).toBe("https://integrator.example.com/hook"); + }); + + it("retries with backoff and abandons after the attempt cap", async () => { + const webhook = await createDepositWebhook(); + await enqueueWebhookDeliveries([webhook], payloadFor("deposit-received:dep-1")); + + for (let attempt = 1; attempt <= 6; attempt++) { + deliverResults = [{ error: "HTTP 500", ok: false }]; + // Force the row due again regardless of the recorded backoff. + await WebhookDelivery.update({ nextAttemptAt: new Date(Date.now() - 1000) }, { where: { webhookId: webhook.id } }); + await dispatchDueWebhookDeliveries(); + + const row = await WebhookDelivery.findOne({ where: { webhookId: webhook.id } }); + if (attempt < 6) { + expect(row?.status).toBe(WebhookDeliveryStatus.Pending); + expect(row?.attempts).toBe(attempt); + expect(row?.nextAttemptAt.getTime()).toBeGreaterThan(Date.now()); + expect(row?.lastError).toBe("HTTP 500"); + } else { + expect(row?.status).toBe(WebhookDeliveryStatus.Abandoned); + } + } + + // Abandoned rows are terminal — nothing further is claimed. + deliverResults = [{ error: null, ok: true }]; + await WebhookDelivery.update({ nextAttemptAt: new Date(Date.now() - 1000) }, { where: { webhookId: webhook.id } }); + expect(await dispatchDueWebhookDeliveries()).toBe(0); + }); + + it("abandons deliveries whose webhook is gone or inactive without deactivating others", async () => { + const webhook = await createDepositWebhook(); + await enqueueWebhookDeliveries([webhook], payloadFor("deposit-received:dep-1")); + await webhook.update({ isActive: false }); + + await dispatchDueWebhookDeliveries(); + + const row = await WebhookDelivery.findOne({ where: { webhookId: webhook.id } }); + expect(row?.status).toBe(WebhookDeliveryStatus.Abandoned); + expect(deliverCalls).toHaveLength(0); + }); + + it("requeues stuck sending rows so a crash cannot strand a delivery", async () => { + const webhook = await createDepositWebhook(); + await enqueueWebhookDeliveries([webhook], payloadFor("deposit-received:dep-1")); + await WebhookDelivery.update( + { status: WebhookDeliveryStatus.Sending, updatedAt: new Date(Date.now() - 16 * 60 * 1000) }, + { silent: true, where: { webhookId: webhook.id } } + ); + + expect(await reconcileStuckWebhookDeliveries()).toBe(1); + const row = await WebhookDelivery.findOne({ where: { webhookId: webhook.id } }); + expect(row?.status).toBe(WebhookDeliveryStatus.Pending); + }); +}); diff --git a/apps/api/src/api/services/webhook/webhook-outbox.service.ts b/apps/api/src/api/services/webhook/webhook-outbox.service.ts new file mode 100644 index 000000000..9ad664733 --- /dev/null +++ b/apps/api/src/api/services/webhook/webhook-outbox.service.ts @@ -0,0 +1,118 @@ +import { WebhookPayload } from "@vortexfi/shared"; +import { literal, Op, Transaction } from "sequelize"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import Webhook from "../../../models/webhook.model"; +import WebhookDelivery, { WebhookDeliveryStatus } from "../../../models/webhookDelivery.model"; +import webhookDeliveryService from "./webhook-delivery.service"; + +// Same shape as the email-notification dispatcher: attempts are incremented at claim +// time, backoff grows per attempt, and the row is abandoned after the cap. Unlike the +// legacy in-process path, a failing endpoint never deactivates the webhook — the +// delivery is durable and the subscription survives outages. +const BACKOFF_MINUTES = [1, 5, 15, 60, 180]; +const MAX_ATTEMPTS = BACKOFF_MINUTES.length + 1; +const BATCH_SIZE = 25; +const STUCK_SENDING_MS = 15 * 60 * 1000; + +/** + * Enqueues one durable delivery per webhook for an already-built event envelope. + * Idempotent: the unique (webhook_id, event_id) pair absorbs re-emits after crashes. + */ +export async function enqueueWebhookDeliveries(webhooks: Webhook[], payload: WebhookPayload): Promise { + if (webhooks.length === 0) return; + await WebhookDelivery.bulkCreate( + webhooks.map(webhook => ({ + eventId: payload.eventId, + eventType: payload.eventType, + payload, + webhookId: webhook.id + })), + { ignoreDuplicates: true } + ); +} + +async function claimDueDeliveries(): Promise { + return sequelize.transaction(async transaction => { + const due = await WebhookDelivery.findAll({ + limit: BATCH_SIZE, + lock: Transaction.LOCK.UPDATE, + order: [["next_attempt_at", "ASC"]], + skipLocked: true, + transaction, + where: { + attempts: { [Op.lt]: MAX_ATTEMPTS }, + nextAttemptAt: { [Op.lte]: new Date() }, + status: WebhookDeliveryStatus.Pending + } + }); + if (due.length === 0) return []; + await WebhookDelivery.update( + { attempts: literal("attempts + 1") as unknown as number, status: WebhookDeliveryStatus.Sending }, + { transaction, where: { id: due.map(row => row.id) } } + ); + for (const row of due) { + row.attempts += 1; + row.status = WebhookDeliveryStatus.Sending; + } + return due; + }); +} + +async function settle(row: WebhookDelivery, ok: boolean, error: string | null): Promise { + if (ok) { + await row.update({ lastError: null, sentAt: new Date(), status: WebhookDeliveryStatus.Sent }); + return; + } + if (row.attempts >= MAX_ATTEMPTS) { + logger.error(`webhook-outbox: delivery ${row.id} abandoned after ${row.attempts} attempts: ${error}`); + await row.update({ lastError: error, status: WebhookDeliveryStatus.Abandoned }); + return; + } + const backoffMinutes = BACKOFF_MINUTES[Math.min(row.attempts - 1, BACKOFF_MINUTES.length - 1)]; + await row.update({ + lastError: error, + nextAttemptAt: new Date(Date.now() + backoffMinutes * 60 * 1000), + status: WebhookDeliveryStatus.Pending + }); +} + +/** Dispatches one claimed batch of due deliveries. Returns the number processed. */ +export async function dispatchDueWebhookDeliveries(): Promise { + const claimed = await claimDueDeliveries(); + for (const row of claimed) { + try { + const webhook = await Webhook.findByPk(row.webhookId); + if (!webhook || !webhook.isActive) { + await row.update({ lastError: "webhook missing or inactive", status: WebhookDeliveryStatus.Abandoned }); + continue; + } + const result = await webhookDeliveryService.deliverSingleAttempt(webhook, row.payload); + await settle(row, result.ok, result.error); + } catch (error) { + logger.error(`webhook-outbox: dispatch failed for delivery ${row.id}:`, error); + await settle(row, false, error instanceof Error ? error.message : String(error)).catch(() => undefined); + } + } + return claimed.length; +} + +/** + * Requeues rows stuck in `sending` (a crash between claim and settle). The attempts + * counter was already incremented at claim, so the cap still holds. + */ +export async function reconcileStuckWebhookDeliveries(): Promise { + const [count] = await WebhookDelivery.update( + { status: WebhookDeliveryStatus.Pending }, + { + where: { + status: WebhookDeliveryStatus.Sending, + updatedAt: { [Op.lt]: new Date(Date.now() - STUCK_SENDING_MS) } + } + } + ); + if (count > 0) { + logger.warn(`webhook-outbox: requeued ${count} stuck delivery row(s)`); + } + return count; +} diff --git a/apps/api/src/api/services/webhook/webhook.service.ts b/apps/api/src/api/services/webhook/webhook.service.ts index 12512931f..4ca3f4fca 100644 --- a/apps/api/src/api/services/webhook/webhook.service.ts +++ b/apps/api/src/api/services/webhook/webhook.service.ts @@ -1,4 +1,9 @@ -import { RegisterWebhookRequest, RegisterWebhookResponse, WebhookEventType } from "@vortexfi/shared"; +import { + ACCOUNT_WEBHOOK_EVENT_TYPES, + RegisterWebhookRequest, + RegisterWebhookResponse, + WebhookEventType +} from "@vortexfi/shared"; import httpStatus from "http-status"; import { Op, WhereOptions } from "sequelize"; import logger from "../../../config/logger"; @@ -76,6 +81,44 @@ export class WebhookService { } } + // Account-scoped event family (deposit events): owner-only subscriptions with + // their own rules — no quote/session target, no mixing with transaction events, + // and a profile owner (delivery resolves the account's controlling manager by + // profile, so a partner-owned row could never match). + const accountEventTypes: readonly WebhookEventType[] = ACCOUNT_WEBHOOK_EVENT_TYPES; + const requestedAccountEvents = (events ?? []).filter(event => accountEventTypes.includes(event)); + if (requestedAccountEvents.length > 0) { + if ((events ?? []).some(event => !accountEventTypes.includes(event))) { + throw new APIError({ + message: "Deposit events cannot be combined with transaction events in one webhook", + status: httpStatus.BAD_REQUEST + }); + } + if (quoteId || sessionId) { + throw new APIError({ + message: "Deposit-event webhooks are account-scoped and do not accept a quoteId or sessionId", + status: httpStatus.BAD_REQUEST + }); + } + if (!owner.userId) { + throw new APIError({ + message: "Deposit-event webhooks require a profile-scoped credential", + status: httpStatus.BAD_REQUEST + }); + } + + const webhook = await Webhook.create({ + events: requestedAccountEvents, + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url, + userId: owner.userId + }); + return this.toRegisterResponse(webhook); + } + // Validate that at least one of quoteId or sessionId is provided if (!quoteId && !sessionId) { throw new APIError({ @@ -99,7 +142,12 @@ export class WebhookService { } } - const webhookEvents: WebhookEventType[] = events || Object.values(WebhookEventType); + // Omitted events default to the transaction family only — new event families + // must always be explicit opt-in, never a silent subscription. + const webhookEvents: WebhookEventType[] = events || [ + WebhookEventType.TRANSACTION_CREATED, + WebhookEventType.STATUS_CHANGE + ]; const webhook = await Webhook.create({ events: webhookEvents, @@ -111,17 +159,7 @@ export class WebhookService { userId: owner.partnerId ? null : owner.userId }); - logger.info(`Webhook registered: ${webhook.id} for URL: ${url}`); - - return { - createdAt: webhook.createdAt.toISOString(), - events: webhook.events, - id: webhook.id, - isActive: webhook.isActive, - quoteId: webhook.quoteId, - sessionId: webhook.sessionId, - url: webhook.url - }; + return this.toRegisterResponse(webhook); } catch (error: unknown) { logger.error("Error registering webhook:", error); @@ -137,6 +175,35 @@ export class WebhookService { } } + private toRegisterResponse(webhook: Webhook): RegisterWebhookResponse { + logger.info(`Webhook registered: ${webhook.id} for URL: ${webhook.url}`); + return { + createdAt: webhook.createdAt.toISOString(), + events: webhook.events, + id: webhook.id, + isActive: webhook.isActive, + quoteId: webhook.quoteId, + sessionId: webhook.sessionId, + url: webhook.url + }; + } + + /** + * Owner-filtered lookup for the account-scoped event family: only webhooks owned by + * the given profile (the account's controlling manager, resolved by the caller from + * the managed-profile relationship). This is the deposit-event counterpart of the + * quote-owner filtering in findWebhooksForEvent — there is still no ownerless branch. + */ + public async findAccountEventWebhooks(eventType: WebhookEventType, ownerProfileId: string): Promise { + return Webhook.findAll({ + where: { + events: { [Op.contains]: [eventType] }, + isActive: true, + userId: ownerProfileId + } + }); + } + public async deleteWebhook(id: string, owner: WebhookOwner): Promise { try { if (!owner.partnerId && !owner.userId) { diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 852a73f35..dca867730 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -8,6 +8,7 @@ import { erc20Abi, getForwarderImmutables, getPublicClient, isKeeperChainConfigu import { runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; import { runDormancyGate } from "../services/monerium-b2b/dormancy"; +import { emitMoneriumDepositEvents } from "../services/monerium-b2b/manager-events"; import { runMintWatcher } from "../services/monerium-b2b/mint-watcher"; import { runMonitoringPass } from "../services/monerium-b2b/monitoring"; import { advanceOnboardingAccounts } from "../services/monerium-b2b/onboarding"; @@ -72,6 +73,10 @@ class MoneriumB2bWorker { await runDormancyGate(); } + // Manager-facing deposit events into the durable webhook outbox; the + // converted event self-gates on the read RPC for its confirmation depth. + await emitMoneriumDepositEvents(); + // Detection-only monitors (plan D3); internally rate-limited and gated on the // read RPC / API credentials, so this is safe to call every cycle. await runMonitoringPass(); diff --git a/apps/api/src/api/workers/webhook-outbox.worker.ts b/apps/api/src/api/workers/webhook-outbox.worker.ts new file mode 100644 index 000000000..f9ba3a23f --- /dev/null +++ b/apps/api/src/api/workers/webhook-outbox.worker.ts @@ -0,0 +1,55 @@ +import { CronJob } from "cron"; +import logger from "../../config/logger"; +import { dispatchDueWebhookDeliveries, reconcileStuckWebhookDeliveries } from "../services/webhook/webhook-outbox.service"; + +/** + * Dispatches the durable webhook-delivery outbox (account-scoped event family). + * Claim-before-send makes it safe to run on every backend sharing the database. + */ +class WebhookOutboxWorker { + private dispatchJob: CronJob; + private reconcileJob: CronJob; + private running = false; + + constructor(dispatchCron = "* * * * *", reconcileCron = "10 * * * *") { + this.dispatchJob = new CronJob(dispatchCron, this.dispatchCycle.bind(this), null, false, undefined, null, true); + this.reconcileJob = new CronJob(reconcileCron, this.reconcileCycle.bind(this), null, false, undefined, null, false); + } + + public start(): void { + logger.info("Starting webhook outbox worker"); + this.dispatchJob.start(); + this.reconcileJob.start(); + } + + public stop(): void { + logger.info("Stopping webhook outbox worker"); + this.dispatchJob.stop(); + this.reconcileJob.stop(); + } + + private async dispatchCycle(): Promise { + if (this.running) return; + this.running = true; + try { + // Drain everything currently due, one claimed batch at a time. + while ((await dispatchDueWebhookDeliveries()) > 0) { + // keep claiming until the due backlog is empty + } + } catch (error) { + logger.error("Error during webhook outbox dispatch cycle:", error); + } finally { + this.running = false; + } + } + + private async reconcileCycle(): Promise { + try { + await reconcileStuckWebhookDeliveries(); + } catch (error) { + logger.error("Error during webhook outbox reconcile cycle:", error); + } + } +} + +export default WebhookOutboxWorker; diff --git a/apps/api/src/database/migrations/072-create-webhook-deliveries.ts b/apps/api/src/database/migrations/072-create-webhook-deliveries.ts new file mode 100644 index 000000000..90a2500a3 --- /dev/null +++ b/apps/api/src/database/migrations/072-create-webhook-deliveries.ts @@ -0,0 +1,41 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Durable webhook delivery outbox for the account-scoped event family: a row per +// (webhook, event) is enqueued before any send, dispatched by a worker with backoff, +// and deduplicated by the unique pair so crashes and re-emits never double-deliver. +// The legacy quote-scoped transaction events keep their in-process delivery path. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("webhook_deliveries", { + attempts: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + event_id: { allowNull: false, type: DataTypes.STRING(128) }, + event_type: { allowNull: false, type: DataTypes.STRING(64) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + last_error: { allowNull: true, type: DataTypes.TEXT }, + next_attempt_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + payload: { allowNull: false, type: DataTypes.JSONB }, + sent_at: { allowNull: true, type: DataTypes.DATE }, + status: { allowNull: false, defaultValue: "pending", type: DataTypes.STRING(16) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + webhook_id: { + allowNull: false, + onDelete: "CASCADE", + references: { key: "id", model: "webhooks" }, + type: DataTypes.UUID + } + }); + await queryInterface.addConstraint("webhook_deliveries", { + fields: ["webhook_id", "event_id"], + name: "uniq_webhook_deliveries_webhook_event", + type: "unique" + }); + await queryInterface.addIndex("webhook_deliveries", ["status", "next_attempt_at"]); + await queryInterface.sequelize.query( + `ALTER TABLE webhook_deliveries ADD CONSTRAINT chk_webhook_deliveries_status + CHECK (status IN ('pending', 'sending', 'sent', 'abandoned'))` + ); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("webhook_deliveries", {}); +} diff --git a/apps/api/src/database/migrations/073-add-monerium-deposit-event-markers.ts b/apps/api/src/database/migrations/073-add-monerium-deposit-event-markers.ts new file mode 100644 index 000000000..9b83e946d --- /dev/null +++ b/apps/api/src/database/migrations/073-add-monerium-deposit-event-markers.ts @@ -0,0 +1,20 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Emission markers for the manager-facing deposit events: set once when the event is +// emitted (whether or not any webhook is subscribed at that moment), so the emitter +// never replays history to a late subscriber and never double-emits after a crash. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_fiat_deposits", "received_event_at", { + allowNull: true, + type: DataTypes.DATE + }); + await queryInterface.addColumn("monerium_fiat_deposits", "converted_event_at", { + allowNull: true, + type: DataTypes.DATE + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_fiat_deposits", "converted_event_at"); + await queryInterface.removeColumn("monerium_fiat_deposits", "received_event_at"); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index fa0852551..c3a9168b6 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -25,6 +25,7 @@ import MoneriumB2bWorker from "./api/workers/monerium-b2b.worker"; import NotificationDispatchWorker from "./api/workers/notification-dispatch.worker"; import RampRecoveryWorker from "./api/workers/ramp-recovery.worker"; import UnhandledPaymentWorker from "./api/workers/unhandled-payment.worker"; +import WebhookOutboxWorker from "./api/workers/webhook-outbox.worker"; dotenv.config({ path: [path.resolve(process.cwd(), ".env"), path.resolve(process.cwd(), "../.env")] @@ -86,6 +87,7 @@ const initializeApp = async () => { new UnhandledPaymentWorker().start(); new MoneriumB2bWorker().start(); new NotificationDispatchWorker().start(); + new WebhookOutboxWorker().start(); // Both flow-variant backends share this database and these provider accounts. Give // the replacement backend sole ownership of external status polling so the legacy // grace-period backend does not make every Avenia/Alfredpay request a second time. diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 7828cc001..117f71c2a 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -31,6 +31,7 @@ import SenderRecipient from "./senderRecipient.model"; import Subsidy from "./subsidy.model"; import User from "./user.model"; import Webhook from "./webhook.model"; +import WebhookDelivery from "./webhookDelivery.model"; // Define associations MoneriumAccount.hasMany(MoneriumFiatDeposit, { as: "fiatDeposits", foreignKey: "accountId" }); @@ -39,6 +40,8 @@ MoneriumAccount.hasMany(MoneriumConversionExecution, { as: "conversionExecutions MoneriumConversionExecution.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); MoneriumAccount.belongsTo(User, { as: "vortexProfile", foreignKey: "vortexProfileId" }); User.hasOne(MoneriumAccount, { as: "moneriumAccount", foreignKey: "vortexProfileId" }); +Webhook.hasMany(WebhookDelivery, { as: "deliveries", foreignKey: "webhookId" }); +WebhookDelivery.belongsTo(Webhook, { as: "webhook", foreignKey: "webhookId" }); RampState.belongsTo(QuoteTicket, { as: "quote", foreignKey: "quoteId" }); QuoteTicket.hasOne(RampState, { as: "rampState", foreignKey: "quoteId" }); QuoteTicket.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); @@ -155,7 +158,8 @@ const models = { SenderRecipient, Subsidy, User, - Webhook + Webhook, + WebhookDelivery }; // Export models and sequelize instance diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts index 7721fc608..7208370d9 100644 --- a/apps/api/src/models/moneriumFiatDeposit.model.ts +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -24,6 +24,8 @@ export interface MoneriumFiatDepositAttributes { blockHash: string | null; blockNumber: number | null; allocatedExecutionId: string | null; + receivedEventAt: Date | null; + convertedEventAt: Date | null; createdAt: Date; updatedAt: Date; } @@ -38,6 +40,8 @@ type MoneriumFiatDepositCreationAttributes = Optional< | "blockHash" | "blockNumber" | "allocatedExecutionId" + | "receivedEventAt" + | "convertedEventAt" | "createdAt" | "updatedAt" >; @@ -58,6 +62,8 @@ class MoneriumFiatDeposit declare blockHash: string | null; declare blockNumber: number | null; declare allocatedExecutionId: string | null; + declare receivedEventAt: Date | null; + declare convertedEventAt: Date | null; declare createdAt: Date; declare updatedAt: Date; } @@ -96,6 +102,11 @@ MoneriumFiatDeposit.init( field: "chain_id", type: DataTypes.INTEGER }, + convertedEventAt: { + allowNull: true, + field: "converted_event_at", + type: DataTypes.DATE + }, createdAt: { allowNull: false, defaultValue: DataTypes.NOW, @@ -123,6 +134,11 @@ MoneriumFiatDeposit.init( type: DataTypes.STRING(64), unique: true }, + receivedEventAt: { + allowNull: true, + field: "received_event_at", + type: DataTypes.DATE + }, status: { allowNull: false, defaultValue: MoneriumFiatDepositStatus.Pending, diff --git a/apps/api/src/models/webhook.model.ts b/apps/api/src/models/webhook.model.ts index e12da9e9a..c480283c5 100644 --- a/apps/api/src/models/webhook.model.ts +++ b/apps/api/src/models/webhook.model.ts @@ -59,7 +59,7 @@ Webhook.init( throw new Error("events must be a non-empty array"); } - const validEvents: WebhookEventType[] = [WebhookEventType.TRANSACTION_CREATED, WebhookEventType.STATUS_CHANGE]; + const validEvents: WebhookEventType[] = Object.values(WebhookEventType); for (const event of value) { if (!validEvents.includes(event)) { throw new Error(`Invalid event type: ${event}`); diff --git a/apps/api/src/models/webhookDelivery.model.ts b/apps/api/src/models/webhookDelivery.model.ts new file mode 100644 index 000000000..2bb43e064 --- /dev/null +++ b/apps/api/src/models/webhookDelivery.model.ts @@ -0,0 +1,76 @@ +import { WebhookPayload } from "@vortexfi/shared"; +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum WebhookDeliveryStatus { + Pending = "pending", + Sending = "sending", + Sent = "sent", + Abandoned = "abandoned" +} + +// Durable outbox row for one (webhook, event) delivery of the account-scoped event +// family. The unique (webhook_id, event_id) pair makes enqueueing idempotent; the +// dispatch worker claims rows, sends with backoff, and abandons after the cap. +export interface WebhookDeliveryAttributes { + id: string; + webhookId: string; + eventId: string; + eventType: string; + payload: WebhookPayload; + status: WebhookDeliveryStatus; + attempts: number; + nextAttemptAt: Date; + sentAt: Date | null; + lastError: string | null; + createdAt: Date; + updatedAt: Date; +} + +type WebhookDeliveryCreationAttributes = Optional< + WebhookDeliveryAttributes, + "id" | "status" | "attempts" | "nextAttemptAt" | "sentAt" | "lastError" | "createdAt" | "updatedAt" +>; + +class WebhookDelivery + extends Model + implements WebhookDeliveryAttributes +{ + declare id: string; + declare webhookId: string; + declare eventId: string; + declare eventType: string; + declare payload: WebhookPayload; + declare status: WebhookDeliveryStatus; + declare attempts: number; + declare nextAttemptAt: Date; + declare sentAt: Date | null; + declare lastError: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +WebhookDelivery.init( + { + attempts: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + eventId: { allowNull: false, field: "event_id", type: DataTypes.STRING(128) }, + eventType: { allowNull: false, field: "event_type", type: DataTypes.STRING(64) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + lastError: { allowNull: true, field: "last_error", type: DataTypes.TEXT }, + nextAttemptAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "next_attempt_at", type: DataTypes.DATE }, + payload: { allowNull: false, type: DataTypes.JSONB }, + sentAt: { allowNull: true, field: "sent_at", type: DataTypes.DATE }, + status: { allowNull: false, defaultValue: WebhookDeliveryStatus.Pending, type: DataTypes.STRING(16) }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE }, + webhookId: { allowNull: false, field: "webhook_id", type: DataTypes.UUID } + }, + { + indexes: [{ fields: ["status", "next_attempt_at"] }], + modelName: "WebhookDelivery", + sequelize, + tableName: "webhook_deliveries" + } +); + +export default WebhookDelivery; From f1f5c802b8cd29f4c456a98d5a2fa7ed6b326131 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 25 Aug 2026 21:02:28 +0200 Subject: [PATCH 27/59] docs(api): document deposit webhooks and amend the owner invariant The webhooks page gains the deposit-event family (registration rules, payload examples, durable at-least-once delivery semantics), the managed-profiles page narrows its no-webhooks statement to transaction events, and security-spec invariant 9 records the manager-owner delivery branch and the outbox. --- docs/api/pages/07-webhooks.md | 76 ++++++++++++++++++- docs/api/pages/14-managed-profiles.md | 2 +- .../02-signing-keys/server-side-signing.md | 2 +- .../05-integrations/monerium-b2b.md | 1 + 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 2711eb169..526974184 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -6,6 +6,7 @@ You can subscribe to: - **Transaction creation** — a new ramp is registered. - **Status changes** — a ramp's status moves between `PENDING`, `COMPLETE`, and `FAILED`. +- **Deposit events** — for partner managers with business EUR onramp accounts: a client's EUR deposit was received (`DEPOSIT_RECEIVED`) or converted and forwarded (`DEPOSIT_CONVERTED`). See [Deposit Events](#deposit-events) — they follow account-scoped rules and durable delivery. ## Security Model @@ -36,7 +37,7 @@ Content-Type: application/json } ``` -The body must include **exactly one** of `quoteId` or `sessionId`. Use `sessionId` to subscribe to events from a Widget-hosted ramp instead of a partner-created quote. +For the transaction events, the body must include **exactly one** of `quoteId` or `sessionId`. Use `sessionId` to subscribe to events from a Widget-hosted ramp instead of a partner-created quote. Omitting `events` subscribes to the two transaction events only — deposit events are never a default. Store the returned webhook ID so you can delete it later. @@ -102,15 +103,86 @@ Status values: - `COMPLETE` — ramp completed successfully. - `FAILED` — ramp failed or timed out. +## Deposit Events + +Managers whose business clients hold EUR onramp accounts can subscribe to deposit events instead of polling `GET /v1/monerium-b2b/deposits`. These subscriptions follow account-scoped rules: + +- Register with your **manager profile's own secret key** (no `X-Managed-Profile-Id` header, no `quoteId`/`sessionId`) and an explicit `events` list containing only deposit events. Mixing them with transaction events is rejected, as is a partner-scoped credential. +- One subscription covers **all your managed children's accounts**; the payload identifies the child by `profileId` and the account by `accountId`. + +```json +{ + "url": "https://manager.example.com/vortex/deposits", + "events": ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"] +} +``` + +### `DEPOSIT_RECEIVED` + +Fired once when a client's EUR deposit has been received and the corresponding funds landed in the account's on-chain forwarding contract. + +```json +{ + "eventId": "deposit-received:9f6f6a7e-...", + "eventType": "DEPOSIT_RECEIVED", + "timestamp": "2025-01-15T10:35:00.000Z", + "payload": { + "accountId": "c2a5...", + "profileId": "7d1b...", + "depositId": "9f6f6a7e-...", + "amountRaw": "100000000000000000000", + "currency": "eur", + "status": "minted", + "txHash": "0x..." + } +} +``` + +`amountRaw` is in 18-decimal base units of the deposit currency. + +### `DEPOSIT_CONVERTED` + +Fired once per deposit after its conversion has executed and reached a safe confirmation depth on chain. + +```json +{ + "eventId": "deposit-converted:9f6f6a7e-...", + "eventType": "DEPOSIT_CONVERTED", + "timestamp": "2025-01-15T10:41:00.000Z", + "payload": { + "accountId": "c2a5...", + "profileId": "7d1b...", + "depositId": "9f6f6a7e-...", + "amountRaw": "100000000000000000000", + "currency": "eur", + "status": "minted", + "txHash": "0x...", + "conversion": { + "executionId": "e77a...", + "txHash": "0x...", + "usdcNetRaw": "108000000" + } + } +} +``` + +`usdcNetRaw` (6-decimal base units) is the net USDC forwarded by the conversion execution; when one execution batches several deposits it is the execution total, with per-deposit shares proportional to `amountRaw`. + +### Delivery Semantics + +Deposit events are delivered **durably, at least once**: each event is persisted before sending and retried with growing backoff (1, 5, 15, 60, 180 minutes; abandoned after 6 attempts). Unlike transaction webhooks, a failing endpoint never deactivates the subscription — deliveries resume when your endpoint recovers, and outages lose nothing that has not exhausted its retries. Deduplicate on `eventId`; events are emitted only from subscription time forward (history is never replayed to a new subscription). + ## Retry Mechanism -Vortex automatically retries failed webhook deliveries: +Vortex automatically retries failed **transaction webhook** deliveries: - **Attempts**: up to 5 - **Backoff**: exponential (1s, 2s, 4s, 8s, 16s) - **Timeout**: 30 seconds per request - **Auto-deactivation**: after 5 consecutive failures, the webhook is disabled and must be re-registered. +Deposit events use the durable delivery semantics above instead. + Return `2xx` quickly. Do heavy work asynchronously after acknowledging the request. ## Verification diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index d0d2fea3c..bb34b63a9 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -129,7 +129,7 @@ Register, sign, and start exactly as described in [Ramp Lifecycle](https://api-d Two things behave differently for managed children: - **Pricing** is resolved as: the child's own partner-pricing assignment if one exists, otherwise **your (the manager's) active assignment**, otherwise default Vortex pricing — identically for header-delegated calls and direct child credentials. Children automatically inherit your negotiated fees. -- **Webhooks are not supported for managed subjects** — registration returns `400 MANAGED_PROFILE_UNSUPPORTED` with the header and `403` with a child credential. Poll the child-scoped ramp status and history endpoints instead. +- **Transaction webhooks are not supported for managed subjects** — registration returns `400 MANAGED_PROFILE_UNSUPPORTED` with the header and `403` with a child credential. Poll the child-scoped ramp status and history endpoints instead. The exception is the deposit-event family for EUR onramp accounts: the **manager** subscribes with their own credential (no header) and receives `DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED` for all their children's accounts — see the Webhooks page. ## Common Errors diff --git a/docs/security-spec/02-signing-keys/server-side-signing.md b/docs/security-spec/02-signing-keys/server-side-signing.md index 5816b3f92..b733926a0 100644 --- a/docs/security-spec/02-signing-keys/server-side-signing.md +++ b/docs/security-spec/02-signing-keys/server-side-signing.md @@ -20,7 +20,7 @@ All keys are loaded from environment variables. There is no HSM, secrets manager 6. **Missing mandatory keys MUST prevent server startup** — If `PENDULUM_FUNDING_SEED` or the currently required legacy-named `MOONBEAM_EXECUTOR_PRIVATE_KEY` compatibility fallback are absent, startup validation fails. This requirement reflects general EVM configuration compatibility, not active Moonbeam execution. 7. **The CryptoService singleton MUST initialize keys exactly once** — `initializeKeys()` should be called once at startup. Repeated calls should be idempotent or rejected. 8. **Webhook signatures MUST bind the delivery timestamp** — `X-Vortex-Signature` is computed over `` `${timestamp}.${body}` `` where `timestamp` is the value of the `X-Vortex-Timestamp` header (unix seconds). Consumers verify against that exact string, reject timestamps outside a bounded window, and deduplicate on the payload's `eventId`, which is unique per event and stable across delivery retries. A signature over the body alone MUST NOT verify. -9. **Every webhook row MUST have an owner principal** — the partner behind a partner-scoped secret key or the user behind a user-scoped secret key (`webhooks.partner_id` / `webhooks.user_id`). Registering a webhook for a quote requires that the owner principal owns the quote (`quote_tickets.partner_id` / `user_id` match); a foreign quote returns the same 404 as a nonexistent one. Deletion is owner-scoped with a uniform 404 for foreign IDs. Delivery matching filters webhooks by the quote's owner, so session-scoped subscriptions cannot receive another tenant's events. Ownerless rows are unrepresentable: migration 056 deletes any pre-existing rows (there were none in production) and a CHECK constraint requires exactly one of `partner_id`/`user_id`, so the delivery matcher has no ownerless branch — one would match every quote and reopen the cross-tenant hole for exactly the rows an attacker could have planted before ownership existed. An event whose quote owner cannot be resolved is delivered to nobody. +9. **Every webhook row MUST have an owner principal** — the partner behind a partner-scoped secret key or the user behind a user-scoped secret key (`webhooks.partner_id` / `webhooks.user_id`). Registering a webhook for a quote requires that the owner principal owns the quote (`quote_tickets.partner_id` / `user_id` match); a foreign quote returns the same 404 as a nonexistent one. Deletion is owner-scoped with a uniform 404 for foreign IDs. Delivery matching filters webhooks by the quote's owner, so session-scoped subscriptions cannot receive another tenant's events. Ownerless rows are unrepresentable: migration 056 deletes any pre-existing rows (there were none in production) and a CHECK constraint requires exactly one of `partner_id`/`user_id`, so the delivery matcher has no ownerless branch — one would match every quote and reopen the cross-tenant hole for exactly the rows an attacker could have planted before ownership existed. An event whose quote owner cannot be resolved is delivered to nobody. The account-scoped deposit-event family (`DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED`) follows the same principle with a different owner derivation: subscriptions are user-owned only (registration rejects a partner credential, a quote/session target, and any mix with transaction events), and delivery matches exclusively `webhooks.user_id = `, resolved from `monerium_accounts.vortex_profile_id` through the active `managed_profiles` relationship (`webhook.service.ts findAccountEventWebhooks`, `monerium-b2b/manager-events.ts`). An account without a resolvable controlling manager delivers to nobody. These deliveries go through the durable `webhook_deliveries` outbox (unique per webhook and event, claim-based dispatch with backoff) rather than the in-process retry loop, and a failing endpoint is never auto-deactivated. 10. **Webhook callback URLs MUST NOT reach internal infrastructure (SSRF)** — registration accepts only HTTPS URLs without embedded credentials, rejects IP-literal hosts outside publicly routable space, and resolves the hostname — rejecting it if it resolves to a non-public address (a host that does not resolve yet is allowed, since DNS is often provisioned after integration setup and delivery re-validates anyway). Before every delivery the hostname is re-resolved and every resolved address must be public; redirects are rejected (`redirect: "error"`). Address classification follows the IANA special-purpose registries for both IPv4 and IPv6, so documentation/benchmarking/6to4/site-local ranges are treated as non-public. **Residual risk (accepted):** a resolve-then-connect race remains — the guard and `fetch` resolve independently, so a DNS-rebinding attacker controlling the domain can answer differently for each. Closing it requires pinning the validated address for the connection (preserving Host/SNI) or an egress proxy enforcing destination policy; tracked as follow-up. Exploitation requires an authenticated secret key, and deliveries are POSTs whose response body is never returned to the registrant (blind SSRF). ## Threat Vectors & Mitigations diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index cde352018..ff4b40d10 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -27,6 +27,7 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e 13. **Onboarding provider writes are exactly-once** — 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. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. 15. **The read surface is effective-user scoped and accepts no selectors** — `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` resolve the account strictly from the acting profile (manager delegation via `X-Managed-Profile-Id` under the standard managed-profile authorization with EU corridor + business policy, or the child's own credential); no caller-supplied account, profile, or IBAN identifier is accepted, a foreign manager gets the uniform managed-profile 403, and R09 `unattr:` synthetic deposit rows are never returned (`monerium-b2b-account-read.integration.test.ts`). +16. **Manager deposit events are exactly-once-emitted and manager-only** — `manager-events.ts` emits `DEPOSIT_RECEIVED` when a deposit is minted and `DEPOSIT_CONVERTED` once its confirmed execution is `NOTIFY_CONFIRMATION_DEPTH` blocks below the head (registry P9 reorg guard), guarded by per-deposit emission markers so each event fires once regardless of which component advanced the state and history never replays to late subscribers. Deliveries go only to webhooks owned by the account's controlling manager (server-side-signing invariant 9), through the durable `webhook_deliveries` outbox; R09 `unattr:` rows never produce events (`manager-events.test.ts`). ## Keeper From 216ccc3a38566c50aa08c0da415c6c6486ef4ad8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 09:42:16 +0200 Subject: [PATCH 28/59] fix(api): let deposit-event webhooks register without a quote target The controller's own quoteId/sessionId presence check fired before the service's account-family branch, so the documented deposit-event registration always returned 400. The service owns that validation (transaction events need exactly one target, deposit events none); the new HTTP-level regression test goes through the real route, which the service-level tests bypassed. --- .../src/api/controllers/webhook.controller.ts | 9 ++----- ...erium-b2b-account-read.integration.test.ts | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/controllers/webhook.controller.ts b/apps/api/src/api/controllers/webhook.controller.ts index da6af9d9e..735c7e227 100644 --- a/apps/api/src/api/controllers/webhook.controller.ts +++ b/apps/api/src/api/controllers/webhook.controller.ts @@ -46,13 +46,8 @@ export const registerWebhook = async ( }); } - if (!quoteId && !sessionId) { - throw new APIError({ - message: "Either quoteId or sessionId must be provided", - status: httpStatus.BAD_REQUEST - }); - } - + // quoteId/sessionId requirements are owned by the service: transaction-event + // webhooks need exactly one, deposit-event webhooks must have neither. const webhook = await webhookService.registerWebhook( { events, diff --git a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts index 7777d6c71..aebbb04c5 100644 --- a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts +++ b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts @@ -159,4 +159,31 @@ describe("monerium b2b account read surface", () => { expect(response.status).toBe(404); expect(response.body).toMatchObject({ error: { code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND" } }); }); + + // Regression: the controller used to demand quoteId/sessionId before the service's + // account-family branch could run, making this documented registration impossible. + it("registers a deposit-event webhook over HTTP without a quote or session", async () => { + const { managerHeaders } = await setupMappedChild(); + + const response = await app.request("/v1/webhook", { + body: JSON.stringify({ + events: ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"], + url: "https://manager.example.com/vortex/deposits" + }), + headers: { "Content-Type": "application/json", ...managerHeaders }, + method: "POST" + }); + expect(response.status).toBe(201); + const body = (await response.json()) as { id: string; events: string[]; quoteId: string | null }; + expect(body.events).toEqual(["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"]); + expect(body.quoteId).toBeNull(); + + // The transaction-family requirement still holds at the same HTTP surface. + const legacyWithoutTarget = await app.request("/v1/webhook", { + body: JSON.stringify({ url: "https://manager.example.com/vortex/tx" }), + headers: { "Content-Type": "application/json", ...managerHeaders }, + method: "POST" + }); + expect(legacyWithoutTarget.status).toBe(400); + }); }); From 5b218c25ce1e380c2facc949cccb70c4a079b051 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 09:42:38 +0200 Subject: [PATCH 29/59] fix(api): give the mykobo backend sole ownership of the monerium keeper Both flow-variant backends share one database and one keeper key; two concurrent keepers would race nonces and double-broadcast. Same ownership rule the provider status workers already follow in this block. --- apps/api/src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c3a9168b6..9e2701e32 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -85,17 +85,19 @@ const initializeApp = async () => { new ApiClientEventsRetentionWorker().start(); new RampRecoveryWorker().start(); new UnhandledPaymentWorker().start(); - new MoneriumB2bWorker().start(); new NotificationDispatchWorker().start(); new WebhookOutboxWorker().start(); // Both flow-variant backends share this database and these provider accounts. Give // the replacement backend sole ownership of external status polling so the legacy // grace-period backend does not make every Avenia/Alfredpay request a second time. + // The Monerium keeper holds the same ownership: two keepers against one database + // and one keeper key would race nonces and double-broadcast. if (config.flowVariant === "mykobo") { new KybStatusWorker().start(); new AlfredpayStatusWorker().start(); + new MoneriumB2bWorker().start(); } else { - logger.info("Provider status workers are owned by the mykobo backend"); + logger.info("Provider status workers and the Monerium keeper are owned by the mykobo backend"); } // Start AlfredPay limits refresh loop (daily; falls back to hardcoded if stale) From 038cee62efdca0ff52113fb3ef7bba9b3ce4e947 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 09:47:14 +0200 Subject: [PATCH 30/59] fix(api): make keeper conversions crash-safe and race-free Four review findings on the executor, fixed together because they share the send path: - The swap nonce is persisted before any broadcast (migration 074), and hashless pending rows are now classified against on-chain state (nonce consumption + unclaimed SwapExecuted logs) instead of being silently failed: a crash or DB error between broadcast and the hash update no longer corrupts R04 attribution or double-executes. - Pending-check and execution-row create run under one forwarder-lock acquisition, closing the double-broadcast window between them. - Receipt lookups distinguish TransactionReceiptNotFoundError from RPC failure, so an RPC outage can no longer run the stale clock into a false Failed on a swap that succeeded. - Nonce derivation and broadcasts serialize across processes via a keeper-send advisory lock (the pause-immune poke path included). - The stranding marker now arms for suspended/closed/dormant accounts: the dead-man sweep exists precisely for accounts nobody operates. --- .../src/api/services/monerium-b2b/chain.ts | 6 + .../monerium-b2b/conversion-executor.test.ts | 42 ++- .../monerium-b2b/conversion-executor.ts | 279 ++++++++++++++---- .../074-add-conversion-execution-nonce.ts | 16 + .../moneriumConversionExecution.model.ts | 19 +- 5 files changed, 298 insertions(+), 64 deletions(-) create mode 100644 apps/api/src/database/migrations/074-add-conversion-execution-nonce.ts diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index 6a9c789bd..9c302c23f 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -42,6 +42,12 @@ export const NOTIFY_CONFIRMATION_DEPTH = 32; export const eureTransferEvent = parseAbiItem("event Transfer(address indexed from, address indexed to, uint256 value)"); +// SwapExecuted as a standalone event item for getLogs-based crash recovery (must stay +// in sync with the entry in forwarderAbi below). +export const swapExecutedEvent = parseAbiItem( + "event SwapExecuted(address indexed caller, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded)" +); + export const erc20Abi = [ { inputs: [{ name: "account", type: "address" }], diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 55c3b211f..52b6eac54 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { AllocatableDeposit, allocateUsdcProRata, selectDepositsForExecution } from "./conversion-executor"; +import { AllocatableDeposit, allocateUsdcProRata, classifyHashlessPending, selectDepositsForExecution } from "./conversion-executor"; // R04 attribution (docs/prd/monerium-b2b-implementation-plan.md §3): pro-rata by // amount_raw against eureInRaw, floor division, remainder to the largest deposit. @@ -88,3 +88,43 @@ describe("allocateUsdcProRata", () => { expect(allocateUsdcProRata([deposit("a", 1n * EUR)], 0n, 100n * USDC).size).toBe(0); }); }); + +describe("classifyHashlessPending", () => { + it("fails a row whose send phase was never reached (no persisted nonce)", () => { + expect( + classifyHashlessPending({ latestNonceCount: 0, nonce: null, pendingNonceCount: 0, unclaimedSwapTxHashes: [] }) + ).toEqual({ kind: "fail", reason: "crashed before the transaction was sent" }); + }); + + it("adopts the unclaimed SwapExecuted hash when the nonce was consumed", () => { + expect( + classifyHashlessPending({ latestNonceCount: 8, nonce: 7, pendingNonceCount: 8, unclaimedSwapTxHashes: ["0xlost"] }) + ).toEqual({ kind: "adopt", txHash: "0xlost" }); + }); + + it("fails a consumed nonce with no SwapExecuted (reverted or replaced)", () => { + const result = classifyHashlessPending({ + latestNonceCount: 8, + nonce: 7, + pendingNonceCount: 8, + unclaimedSwapTxHashes: [] + }); + expect(result.kind).toBe("fail"); + }); + + it("waits while the broadcast may still be in the mempool", () => { + expect( + classifyHashlessPending({ latestNonceCount: 7, nonce: 7, pendingNonceCount: 8, unclaimedSwapTxHashes: [] }) + ).toEqual({ kind: "in-flight" }); + }); + + it("fails when the nonce was persisted but the broadcast never reached the mempool", () => { + const result = classifyHashlessPending({ + latestNonceCount: 7, + nonce: 7, + pendingNonceCount: 7, + unclaimedSwapTxHashes: [] + }); + expect(result).toEqual({ kind: "fail", reason: "broadcast never reached the mempool" }); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index 90a9e2a68..a2f7e7bea 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1,12 +1,21 @@ import { Op, Transaction } from "sequelize"; -import { Address, parseEventLogs, TransactionReceipt } from "viem"; +import { Address, parseEventLogs, TransactionReceipt, TransactionReceiptNotFoundError } from "viem"; +import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; -import { erc20Abi, factoryAbi, forwarderAbi, getForwarderImmutables, getKeeperWalletClient, getPublicClient } from "./chain"; +import { + erc20Abi, + factoryAbi, + forwarderAbi, + getForwarderImmutables, + getKeeperWalletClient, + getPublicClient, + swapExecutedEvent +} from "./chain"; import { withForwarderLock } from "./deposit-processor"; /** @@ -34,6 +43,29 @@ const PENDING_TX_STALE_MS = 15 * 60_000; /** How long one cycle waits for the swap receipt before deferring to the next cycle. */ const RECEIPT_TIMEOUT_MS = 3 * 60_000; +/** + * Blocks scanned backwards when recovering a broadcast whose hash was never persisted + * (~6h at 12s blocks — far beyond any realistic crash-to-restart gap; the scan only + * runs for rows whose nonce is already consumed on-chain). + */ +const HASHLESS_RECOVERY_LOOKBACK_BLOCKS = 1800n; + +/** + * Serializes nonce derivation and the broadcasts that consume it across every process + * sharing the database: two concurrent senders would otherwise derive the same pending + * nonce for the single keeper account. Distinct from the per-forwarder lock, which + * scopes per-account database state, not the keeper's global nonce sequence. + */ +async function withKeeperSendLock(fn: () => Promise): Promise { + return sequelize.transaction(async transaction => { + await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { + replacements: { key: "monerium-b2b:keeper-sends" }, + transaction + }); + return fn(); + }); +} + // ------------------------------------------------------------------ R04 allocation math export interface AllocatableDeposit { @@ -193,6 +225,66 @@ async function finalizeExecution( type PreparationResult = { kind: "proceed"; attempt: number } | { kind: "skip"; reason: string }; +export type HashlessPendingClassification = + | { kind: "fail"; reason: string } + | { kind: "in-flight" } + | { kind: "adopt"; txHash: string }; + +/** + * Decides what happened to a pending execution whose tx hash was never persisted (a + * crash or DB error between broadcast and the hash update). Inputs are pure chain + * observations: the keeper account's confirmed/pending nonce counts and any + * SwapExecuted transaction hashes on this forwarder not claimed by another execution. + */ +export function classifyHashlessPending(input: { + nonce: number | null; + latestNonceCount: number; + pendingNonceCount: number; + unclaimedSwapTxHashes: string[]; +}): HashlessPendingClassification { + if (input.nonce === null) { + // The nonce is persisted before any broadcast, so no nonce means the send phase + // was never reached — nothing can be in flight. + return { kind: "fail", reason: "crashed before the transaction was sent" }; + } + if (input.latestNonceCount > input.nonce) { + // The swap nonce was consumed on-chain: either our swap mined (an unclaimed + // SwapExecuted exists — adopt its hash and finalize normally) or the transaction + // reverted/was replaced (no event; the swap did not execute). + const txHash = input.unclaimedSwapTxHashes[0]; + if (txHash) { + return { kind: "adopt", txHash }; + } + return { kind: "fail", reason: "nonce consumed without a SwapExecuted event (swap reverted or replaced)" }; + } + if (input.pendingNonceCount > input.nonce) { + return { kind: "in-flight" }; + } + return { kind: "fail", reason: "broadcast never reached the mempool" }; +} + +/** SwapExecuted tx hashes on this forwarder (recent blocks) not claimed by any execution row. */ +async function findUnclaimedSwapTxHashes(account: MoneriumAccount, transaction: Transaction): Promise { + const client = getPublicClient(); + const latestBlock = await client.getBlockNumber(); + const fromBlock = latestBlock > HASHLESS_RECOVERY_LOOKBACK_BLOCKS ? latestBlock - HASHLESS_RECOVERY_LOOKBACK_BLOCKS : 0n; + const logs = await client.getLogs({ + address: account.forwarderAddress as Address, + event: swapExecutedEvent, + fromBlock + }); + if (logs.length === 0) { + return []; + } + const known = await MoneriumConversionExecution.findAll({ + attributes: ["txHash"], + transaction, + where: { accountId: account.id, txHash: { [Op.ne]: null } } + }); + const claimed = new Set(known.map(row => (row.txHash as string).toLowerCase())); + return logs.map(log => log.transactionHash).filter(hash => !claimed.has(hash.toLowerCase())); +} + /** * Under the forwarder lock: resolve leftover pending executions (crash/timeout * recovery), then decide whether a new execution may start (retry backoff). @@ -205,17 +297,54 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans }); for (const pending of pendings) { if (!pending.txHash) { - // Execution-before-send record with no hash: the process died between commit and - // broadcast, so nothing is in flight — safe to fail and retry. - await pending.update( - { error: "crashed before the transaction was sent", status: MoneriumConversionExecutionStatus.Failed }, - { transaction } + const client = getPublicClient(); + const keeperAddress = getKeeperWalletClient().account.address; + const [latestNonceCount, pendingNonceCount] = + pending.nonce === null + ? [0, 0] + : await Promise.all([ + client.getTransactionCount({ address: keeperAddress, blockTag: "latest" }), + client.getTransactionCount({ address: keeperAddress, blockTag: "pending" }) + ]); + const unclaimedSwapTxHashes = + pending.nonce !== null && latestNonceCount > pending.nonce ? await findUnclaimedSwapTxHashes(account, transaction) : []; + const classification = classifyHashlessPending({ + latestNonceCount, + nonce: pending.nonce, + pendingNonceCount, + unclaimedSwapTxHashes + }); + if (classification.kind === "in-flight") { + return { + kind: "skip", + reason: `execution ${pending.id} broadcast may still be in the mempool (nonce ${pending.nonce})` + }; + } + if (classification.kind === "fail") { + await pending.update( + { error: classification.reason, status: MoneriumConversionExecutionStatus.Failed }, + { transaction } + ); + continue; + } + logger.warn( + `monerium-b2b: recovered lost tx hash ${classification.txHash} for execution ${pending.id} via nonce ${pending.nonce}` ); - continue; + await pending.update({ txHash: classification.txHash }, { transaction }); + // fall through to the receipt path with the adopted hash + } + let receipt: TransactionReceipt | null; + try { + receipt = await getPublicClient().getTransactionReceipt({ hash: pending.txHash as Address }); + } catch (error) { + if (error instanceof TransactionReceiptNotFoundError) { + receipt = null; + } else { + // RPC failure is not evidence of anything — never let it run the stale clock + // toward a false Failed while the swap may have succeeded. + return { kind: "skip", reason: `receipt lookup failed for ${pending.txHash}: ${errorText(error)}` }; + } } - const receipt = await getPublicClient() - .getTransactionReceipt({ hash: pending.txHash as Address }) - .catch(() => null); if (receipt) { await finalizeExecution(pending, receipt, account.forwarderAddress, transaction); } else if (Date.now() - pending.updatedAt.getTime() > PENDING_TX_STALE_MS) { @@ -264,14 +393,14 @@ export async function runConversionExecutor(accountId: string): Promise { if (!account) { return; } - if (account.status === MoneriumAccountStatus.Suspended || account.status === MoneriumAccountStatus.Closed) { - return; - } - if (account.dormantSince) { - // Guardian-paused for dormancy — swapAndForward would revert Paused(). Unpausing is - // manual after partner re-confirmation (registry B5). - return; - } + // Suspended/closed/dormant accounts never swap (dormancy is guardian-paused — + // swapAndForward would revert Paused()), but the stranding marker MUST still arm for + // them: the un-pausable dead-man sweep is the client's escape hatch for exactly the + // accounts nobody is operating any more, and poke() is pause-immune by design. + const convertible = + account.status !== MoneriumAccountStatus.Suspended && + account.status !== MoneriumAccountStatus.Closed && + !account.dormantSince; const client = getPublicClient(); const forwarder = account.forwarderAddress as Address; @@ -289,65 +418,80 @@ export async function runConversionExecutor(accountId: string): Promise { // whether a swap is currently possible. const pokeNeeded = strandedSince === 0n && balance >= minSwapFloor; - if (balance < minSwapAmount) { + if (!convertible || balance < minSwapAmount) { if (pokeNeeded) { await sendPoke(forwarder); } return; } - const preparation = await withForwarderLock(account.forwarderAddress, transaction => - prepareExecutionSlot(account, transaction) - ); - if (preparation.kind === "skip") { - logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${preparation.reason}`); - return; - } - - // Execution-before-send record (plan §3): committed before any broadcast so a crash - // leaves an auditable pending row, never an untracked on-chain swap. - const execution = await withForwarderLock(account.forwarderAddress, transaction => - MoneriumConversionExecution.create( + // Pending-check and execution-row create under ONE lock acquisition: split across two + // transactions, two concurrent executors could both pass the check and both broadcast. + const slot = await withForwarderLock(account.forwarderAddress, async transaction => { + const preparation = await prepareExecutionSlot(account, transaction); + if (preparation.kind === "skip") { + return preparation; + } + // Execution-before-send record (plan §3): committed before any broadcast so a crash + // leaves an auditable pending row, never an untracked on-chain swap. + const execution = await MoneriumConversionExecution.create( { accountId: account.id, destination: account.destination, eureInRaw: (balance > perSwapCap ? perSwapCap : balance).toString() }, { transaction } - ) - ); + ); + return { attempt: preparation.attempt, execution, kind: "proceed" as const }; + }); + if (slot.kind === "skip") { + logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${slot.reason}`); + return; + } + const { attempt, execution } = slot; try { const keeper = getKeeperWalletClient(); - // Explicit nonces: poke + swap are sent back-to-back through the private transport, - // which may not expose a coherent pending pool for nonce derivation. - let nonce = await client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }); + // Simulations run before the send phase so a plain revert fails the row + // immediately (no nonce persisted yet -> the catch below marks it Failed). if (pokeNeeded) { await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); - await keeper.writeContract({ - abi: forwarderAbi, - account: keeper.account, - address: forwarder, - chain: null, - functionName: "poke", - nonce: nonce++ - }); } - await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "swapAndForward" }); - const txHash = await keeper.writeContract({ - abi: forwarderAbi, - account: keeper.account, - address: forwarder, - chain: null, - functionName: "swapAndForward", - nonce + + // Send phase, serialized across processes: explicit nonces because poke + swap go + // back-to-back through the private transport, which may not expose a coherent + // pending pool for derivation. The swap nonce is persisted durably BEFORE any + // broadcast so crash recovery can tell "never sent" from "sent, hash lost". + const txHash = await withKeeperSendLock(async () => { + let nonce = await client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }); + const pokeNonce = pokeNeeded ? nonce++ : null; + await execution.update({ nonce }); + + if (pokeNeeded) { + await keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "poke", + nonce: pokeNonce as number + }); + } + return keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "swapAndForward", + nonce + }); }); await execution.update({ txHash }); @@ -362,11 +506,19 @@ export async function runConversionExecutor(accountId: string): Promise { logger.warn(`monerium-b2b: execution ${execution.id} awaiting receipt after error: ${errorText(error)}`); return; } + if (execution.nonce !== null) { + // The send phase was reached but the outcome (or the hash persist) is unknown; + // leave the row pending — recovery resolves it via nonce consumption. + logger.warn( + `monerium-b2b: execution ${execution.id} broadcast outcome unknown, recovering via nonce: ${errorText(error)}` + ); + return; + } await execution.update({ - error: `attempt ${preparation.attempt}: ${errorText(error)}`, + error: `attempt ${attempt}: ${errorText(error)}`, status: MoneriumConversionExecutionStatus.Failed }); - logger.error(`monerium-b2b: conversion for account ${account.id} failed (attempt ${preparation.attempt}):`, error); + logger.error(`monerium-b2b: conversion for account ${account.id} failed (attempt ${attempt}):`, error); } } @@ -376,13 +528,16 @@ async function sendPoke(forwarder: Address): Promise { const client = getPublicClient(); const keeper = getKeeperWalletClient(); await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); - const hash = await keeper.writeContract({ - abi: forwarderAbi, - account: keeper.account, - address: forwarder, - chain: null, - functionName: "poke" - }); + // Implicit nonce, so the send still serializes with the swap path's derivation. + const hash = await withKeeperSendLock(() => + keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "poke" + }) + ); logger.info(`monerium-b2b: poked forwarder ${forwarder} (${hash})`); } catch (error) { // Best-effort: poke is also permissionless on-chain, so a missed poke only delays diff --git a/apps/api/src/database/migrations/074-add-conversion-execution-nonce.ts b/apps/api/src/database/migrations/074-add-conversion-execution-nonce.ts new file mode 100644 index 000000000..412777828 --- /dev/null +++ b/apps/api/src/database/migrations/074-add-conversion-execution-nonce.ts @@ -0,0 +1,16 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// The keeper persists the swap's transaction nonce BEFORE broadcasting, so crash +// recovery can distinguish "never sent" from "sent but the tx hash was lost" (a crash +// or DB error between broadcast and the hash update) by checking nonce consumption +// on chain instead of silently failing the row and double-executing. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "nonce", { + allowNull: true, + type: DataTypes.INTEGER + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "nonce"); +} diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index 6e88dbc34..3d2f659e3 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -19,6 +19,8 @@ export interface MoneriumConversionExecutionAttributes { usdcNetRaw: string | null; destination: string; txHash: string | null; + /** The swap's transaction nonce, persisted BEFORE broadcast (crash-recovery identity). */ + nonce: number | null; blockNumber: number | null; status: MoneriumConversionExecutionStatus; error: string | null; @@ -28,7 +30,17 @@ export interface MoneriumConversionExecutionAttributes { type MoneriumConversionExecutionCreationAttributes = Optional< MoneriumConversionExecutionAttributes, - "id" | "usdcGrossRaw" | "feeRaw" | "usdcNetRaw" | "txHash" | "blockNumber" | "status" | "error" | "createdAt" | "updatedAt" + | "id" + | "usdcGrossRaw" + | "feeRaw" + | "usdcNetRaw" + | "txHash" + | "nonce" + | "blockNumber" + | "status" + | "error" + | "createdAt" + | "updatedAt" >; class MoneriumConversionExecution @@ -43,6 +55,7 @@ class MoneriumConversionExecution declare usdcNetRaw: string | null; declare destination: string; declare txHash: string | null; + declare nonce: number | null; declare blockNumber: number | null; declare status: MoneriumConversionExecutionStatus; declare error: string | null; @@ -91,6 +104,10 @@ MoneriumConversionExecution.init( primaryKey: true, type: DataTypes.UUID }, + nonce: { + allowNull: true, + type: DataTypes.INTEGER + }, status: { allowNull: false, defaultValue: MoneriumConversionExecutionStatus.Pending, From 10590b76f7de0ecb6534d63ccc5edfa92b7ea8a7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 09:49:12 +0200 Subject: [PATCH 31/59] fix(api): keep webhook-first deposits attributable in the mint watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An order.updated 'processed' delivery without meta.txHash advanced the deposit to Minted before the watcher saw the mint, and the amount-match branch required Pending — so the real mint was recorded as an unattributed duplicate and the deposit never gained the chain identity R04 depends on. Amount matching now covers any open row without recorded chain identity. The scan also lags the head by a 12-block confirmation depth, since the (chain_id, tx_hash, log_index) identity is not reorg-stable, and a hash-matched mint whose on-chain value disagrees with the webhook amount is now an error-level alert. --- .../monerium-b2b/mint-watcher.test.ts | 15 +++++-- .../api/services/monerium-b2b/mint-watcher.ts | 39 ++++++++++++------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts index ca14d9764..7476bfc18 100644 --- a/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts @@ -66,11 +66,20 @@ describe("matchMintLogToDeposit", () => { expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); }); - it("does not amount-match a minted deposit without a hash (hash is required once minted)", () => { - // A webhook-minted order without meta.txHash cannot be safely claimed by amount - // alone once it is already minted — only pending orders amount-match. + it("amount-matches a webhook-minted deposit whose order carried no tx hash", () => { + // Monerium can deliver order state "processed" without meta.txHash before the + // watcher reaches the mint block. Requiring Pending here stranded such rows + // without chain identity and recorded the real mint as an unattributed duplicate. const amount = 100n * 10n ** 18n; const deposits = [candidate({ amountRaw: amount.toString(), id: "minted-no-hash", status: Minted })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)?.id).toBe("minted-no-hash"); + }); + + it("never amount-matches a deposit that already carries a different tx hash", () => { + const amount = 100n * 10n ** 18n; + const deposits = [ + candidate({ amountRaw: amount.toString(), id: "other-tx", status: Minted, txHash: "0xBBBB" }) + ]; expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); }); }); diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts index 8f24aa26d..565d907fa 100644 --- a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts @@ -18,6 +18,10 @@ import { withForwarderLock } from "./deposit-processor"; /** Upper bound on blocks scanned per cycle, so getLogs stays bounded after downtime. */ const MAX_BLOCK_RANGE = 2000n; +// Confirmation lag before a block is scanned: deep enough that Ethereum reorgs past it +// are effectively unheard of, shallow enough to add well under a keeper cycle of delay. +const REORG_SAFETY_DEPTH = 12n; + /** Order-id prefix marking a deposit row created from a mint log with no matching Monerium order. */ export const UNATTRIBUTED_ORDER_PREFIX = "unattr:"; @@ -41,7 +45,10 @@ export type MatchableDeposit = Pick - deposit.txHash === null && - deposit.status === MoneriumFiatDepositStatus.Pending && - BigInt(deposit.amountRaw) === log.valueRaw - ) ?? null - ); + return open.find(deposit => deposit.txHash === null && BigInt(deposit.amountRaw) === log.valueRaw) ?? null; } interface ObservedMint { @@ -106,6 +106,13 @@ async function recordMint( if (match) { const deposit = candidates.find(row => row.id === match.id) as MoneriumFiatDeposit; + if (deposit.txHash !== null && BigInt(deposit.amountRaw) !== mint.valueRaw) { + // Hash-matched but the webhook-reported amount disagrees with the on-chain + // value: detective alert, never a silent overwrite of accounting data. + logger.error( + `monerium-b2b: mint ${mint.txHash}#${mint.logIndex} value ${mint.valueRaw.toString()} disagrees with webhook amount ${deposit.amountRaw} on deposit ${deposit.id}` + ); + } await deposit.update( { blockHash: mint.blockHash, @@ -162,21 +169,25 @@ export async function runMintWatcher(): Promise { const chainId = await getChainId(); const { eure } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); const latest = await client.getBlockNumber(); + // Only scan settled blocks: the (chain_id, tx_hash, log_index) identity is not + // reorg-stable (logIndex and blockNumber change when a dropped tx re-mines), so a + // head-chasing scan could double-record one mint across a shallow reorg. + const safeHead = latest > REORG_SAFETY_DEPTH ? latest - REORG_SAFETY_DEPTH : 0n; const cursorName = `eure-mints:${chainId}`; const cursor = await MoneriumChainCursor.findByPk(cursorName); if (!cursor) { - // Bootstrap: start watching from the current head. Historic mints are covered by - // webhook-recorded orders; back-filling their chain fields is a manual operation. - await MoneriumChainCursor.create({ lastBlock: latest.toString(), name: cursorName }); + // Bootstrap: start watching from the current settled head. Historic mints are + // covered by webhook-recorded orders; back-filling chain fields is manual. + await MoneriumChainCursor.create({ lastBlock: safeHead.toString(), name: cursorName }); return []; } const fromBlock = BigInt(cursor.lastBlock) + 1n; - if (fromBlock > latest) { + if (fromBlock > safeHead) { return []; } - const toBlock = latest - fromBlock + 1n > MAX_BLOCK_RANGE ? fromBlock + MAX_BLOCK_RANGE - 1n : latest; + const toBlock = safeHead - fromBlock + 1n > MAX_BLOCK_RANGE ? fromBlock + MAX_BLOCK_RANGE - 1n : safeHead; const logs = await client.getLogs({ address: eure, From ae70e1de57faadba3b19083bcab2348e844c5fa0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 09:51:12 +0200 Subject: [PATCH 32/59] fix(api): verify the deployed forwarder before mapping an account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin mapping endpoint now reads the clone's destination, fallbackAddress, feeBps, and factory registration from chain (when a read RPC is configured) and rejects any divergence before anything is persisted — a mistyped clone address would otherwise be linked to the client's Monerium profile within a keeper cycle and its config adopted by the R07 monitor as owner-authorized. A feeBps divergence on replay is now also a conflict instead of a silent idempotent match. --- .../admin/moneriumB2b.controller.test.ts | 16 ++++ .../monerium-b2b/account-provisioning.ts | 92 ++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts index 3516878b5..71446df64 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -9,6 +9,7 @@ import User from "../../../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; import { createTestUser } from "../../../test-utils/factories"; import moneriumB2bRoutes from "../../routes/v1/admin/monerium-b2b.route"; +import { forwarderConfigMismatch } from "../../services/monerium-b2b/account-provisioning"; const BASE_PATH = "/v1/admin/monerium-b2b"; const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type": "application/json" }; @@ -184,9 +185,24 @@ describe("monerium b2b account mapping admin route", () => { ); expect(differentSubject.status).toBe(409); + // Same everything, different feeBps: divergence, not a silent idempotent replay. + const differentFee = await post(validBody(managerProfileId, { feeBps: 25 })); + expect(differentFee.status).toBe(409); + expect(await MoneriumAccount.count()).toBe(1); }); + it("compares submitted account data against the deployed clone config", () => { + const expected = { destination: DESTINATION.toLowerCase(), fallbackAddress: FALLBACK.toLowerCase(), feeBps: 0 }; + const matching = { destination: DESTINATION, fallbackAddress: FALLBACK, feeBps: 0, isForwarder: true }; + + expect(forwarderConfigMismatch(expected, matching)).toBeNull(); + expect(forwarderConfigMismatch(expected, { ...matching, isForwarder: false })).toContain("not a clone"); + expect(forwarderConfigMismatch(expected, { ...matching, destination: FALLBACK })).toContain("destination"); + expect(forwarderConfigMismatch(expected, { ...matching, fallbackAddress: DESTINATION })).toContain("fallbackAddress"); + expect(forwarderConfigMismatch(expected, { ...matching, feeBps: 30 })).toContain("feeBps"); + }); + it("rejects invalid input and unknown managers", async () => { const managerProfileId = await createManager(); 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 ab2094e35..cf17876c5 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -1,8 +1,11 @@ +import { type Address, parseAbi } from "viem"; import sequelize from "../../../config/database"; +import { config } from "../../../config/vars"; import KycCase from "../../../models/kycCase.model"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import { type ProvisionManagedProfileResult, provisionManagedProfile } from "../managed-profile-provisioning.service"; +import { getPublicClient } from "./chain"; const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/i; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -45,6 +48,85 @@ function normalizeAddress(value: string, name: string): string { return value.trim().toLowerCase(); } +const forwarderConfigAbi = parseAbi([ + "function destination() view returns (address)", + "function fallbackAddress() view returns (address)", + "function feeBps() view returns (uint16)", + "function FACTORY() view returns (address)" +]); +const factoryRegistryAbi = parseAbi(["function isForwarder(address forwarder) view returns (bool)"]); + +/** Pure comparison of the submitted account data against the deployed clone's config. */ +export function forwarderConfigMismatch( + expected: { destination: string; fallbackAddress: string; feeBps: number }, + onchain: { destination: string; fallbackAddress: string; feeBps: number; isForwarder: boolean } +): string | null { + if (!onchain.isForwarder) { + return "the address is not a clone registered by its factory"; + } + if (onchain.destination.toLowerCase() !== expected.destination) { + return `on-chain destination ${onchain.destination} differs from the submitted value`; + } + if (onchain.fallbackAddress.toLowerCase() !== expected.fallbackAddress) { + return `on-chain fallbackAddress ${onchain.fallbackAddress} differs from the submitted value`; + } + if (onchain.feeBps !== expected.feeBps) { + return `on-chain feeBps ${onchain.feeBps} differs from the submitted ${expected.feeBps}`; + } + return null; +} + +/** + * Verifies the operator-submitted forwarder against the chain before anything is + * persisted: a mistyped or wrong clone address would otherwise be linked to the + * client's Monerium profile within a keeper cycle, and the R07 monitor would adopt + * the wrong clone's destination as owner-authorized. Skipped when no read RPC is + * configured (sandbox / pre-chain environments — the association and config monitors + * remain the detective controls there). + */ +async function verifyForwarderOnChain( + forwarderAddress: string, + destination: string, + fallbackAddress: string, + feeBps: number +): Promise { + if (!config.moneriumB2b.rpcUrl) { + return; + } + const client = getPublicClient(); + const address = forwarderAddress as Address; + let onchain: { destination: string; fallbackAddress: string; feeBps: number; isForwarder: boolean }; + try { + const [onchainDestination, onchainFallback, onchainFeeBps, factory] = await Promise.all([ + client.readContract({ abi: forwarderConfigAbi, address, functionName: "destination" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "fallbackAddress" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "feeBps" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "FACTORY" }) + ]); + const isForwarder = await client.readContract({ + abi: factoryRegistryAbi, + address: factory, + args: [address], + functionName: "isForwarder" + }); + onchain = { destination: onchainDestination, fallbackAddress: onchainFallback, feeBps: onchainFeeBps, isForwarder }; + } catch (error) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + `Could not verify the forwarder on chain (is the address a deployed clone? retry if the RPC was unavailable): ${ + error instanceof Error ? error.message.slice(0, 200) : String(error) + }` + ); + } + const mismatch = forwarderConfigMismatch({ destination, fallbackAddress, feeBps }, onchain); + if (mismatch) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + `Deployed forwarder verification failed: ${mismatch}` + ); + } +} + // Mirrors the whitelabel KYB outcome for a reliance-onboarded corporate: 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 @@ -126,12 +208,14 @@ function accountMatchesInput( childProfileId: string, forwarderAddress: string, destination: string, - fallbackAddress: string + fallbackAddress: string, + feeBps: number ): boolean { return ( account.forwarderAddress.toLowerCase() === forwarderAddress && account.destination.toLowerCase() === destination && account.fallbackAddress.toLowerCase() === fallbackAddress && + account.feeBps === feeBps && (account.vortexProfileId === null || account.vortexProfileId === childProfileId) ); } @@ -158,6 +242,10 @@ export async function provisionMoneriumB2bAccount( throw new MoneriumB2bProvisioningError("MONERIUM_B2B_INVALID_INPUT", "feeBps must be an integer between 0 and 10000"); } + // Before any persistence: a wrong clone address must fail here, not become a mapped + // account whose config the monitors later legitimize. + await verifyForwarderOnChain(forwarderAddress, destination, fallbackAddress, feeBps); + // The pilot reliance scope is KYB'd corporates only, so the child is always a // business entity. Errors (inactive manager, subject/email conflicts) propagate // as ManagedProfileProvisioningError for the controller to map. @@ -174,7 +262,7 @@ export async function provisionMoneriumB2bAccount( const account = await sequelize.transaction(async transaction => { const existing = await MoneriumAccount.findOne({ transaction, where: { profileId: moneriumProfileId } }); if (existing) { - if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, fallbackAddress)) { + if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, fallbackAddress, feeBps)) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", "The Monerium profile is already mapped with different account data" From 50d4af330505a2956c85c8649a672e5bbd07ff32 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 09:54:01 +0200 Subject: [PATCH 33/59] test(api): cover the review's keeper coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order-event inbox processing gets end-to-end DB coverage (create, forward-only advance, regression rejection, delivery dedup, unknown forwarder, hold/release); the outbox gets a concurrent-dispatch test proving the skip-locked claims never double-deliver; and the onboarding financial-operation failure branches are exercised. Doing so surfaced a real wedge: a transient provider failure parked the ledger row in 'unknown' with reconciliation required forever — link/IBAN performs now prove no-side-effect (link is synchronous upstream; IBAN issuance is unique per address) and signal FinancialOperationRejectedError so the next cycle retries cleanly. --- .../monerium-b2b/deposit-processor.test.ts | 108 +++++++++++++++++- .../services/monerium-b2b/onboarding.test.ts | 64 +++++++++++ .../api/services/monerium-b2b/onboarding.ts | 27 ++++- .../webhook/webhook-outbox.service.test.ts | 17 +++ 4 files changed, 210 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts index bd6d95214..05ffedc4b 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts @@ -1,6 +1,15 @@ -import { describe, expect, it } from "bun:test"; -import { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; -import { isForwardTransition, mapOrderStateToDepositStatus, parseIbanEvent, parseOrderEvent } from "./deposit-processor"; +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { + isForwardTransition, + mapOrderStateToDepositStatus, + parseIbanEvent, + parseOrderEvent, + processMoneriumWebhookInbox +} from "./deposit-processor"; const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; @@ -111,3 +120,96 @@ describe("parseIbanEvent", () => { expect(parseIbanEvent("junk")).toBeNull(); }); }); + +describe("order-event inbox processing (end to end)", () => { + const FORWARDER = "0x1111111111111111111111111111111111111111"; + + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + function orderEvent(state: string, overrides: Record = {}) { + return { + data: { + address: FORWARDER, + amount: "100.5", + currency: "eur", + id: "order-1", + kind: "issue", + meta: {}, + state, + ...overrides + }, + timestamp: "2026-08-26T00:00:00Z", + type: "order.updated" + }; + } + + async function createAccount(): Promise { + return MoneriumAccount.create({ + destination: "0x2222222222222222222222222222222222222222", + fallbackAddress: "0x3333333333333333333333333333333333333333", + feeBps: 0, + forwarderAddress: FORWARDER, + profileId: crypto.randomUUID() + }); + } + + it("creates the deposit, advances it forward-only, and dedups deliveries", async () => { + const account = await createAccount(); + + await MoneriumWebhookEvent.create({ eventId: "evt-1", payload: orderEvent("placed") }); + expect(await processMoneriumWebhookInbox()).toBe(1); + + const created = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: "order-1" } }); + expect(created).toMatchObject({ + accountId: account.id, + amountRaw: (1005n * 10n ** 17n).toString(), + status: MoneriumFiatDepositStatus.Pending + }); + + // processed advances to minted and records the mint hash from meta. + await MoneriumWebhookEvent.create({ eventId: "evt-2", payload: orderEvent("processed", { meta: { txHash: "0xmint" } }) }); + await processMoneriumWebhookInbox(); + await created?.reload(); + expect(created?.status).toBe(MoneriumFiatDepositStatus.Minted); + expect(created?.txHash).toBe("0xmint"); + + // A delayed older state must never regress the row. + await MoneriumWebhookEvent.create({ eventId: "evt-3", payload: orderEvent("pending") }); + await processMoneriumWebhookInbox(); + await created?.reload(); + expect(created?.status).toBe(MoneriumFiatDepositStatus.Minted); + + // Replayed deliveries of the same order never create a second row. + await MoneriumWebhookEvent.create({ eventId: "evt-4", payload: orderEvent("processed") }); + await processMoneriumWebhookInbox(); + expect(await MoneriumFiatDeposit.count()).toBe(1); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + }); + + it("acks order events for unknown forwarders without creating deposits", async () => { + await MoneriumWebhookEvent.create({ eventId: "evt-5", payload: orderEvent("placed") }); + expect(await processMoneriumWebhookInbox()).toBe(1); + expect(await MoneriumFiatDeposit.count()).toBe(0); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + }); + + it("holds and releases a compliance-held order without regressions", async () => { + await createAccount(); + await MoneriumWebhookEvent.create({ eventId: "evt-6", payload: orderEvent("placed") }); + await MoneriumWebhookEvent.create({ eventId: "evt-7", payload: orderEvent("held") }); + await processMoneriumWebhookInbox(); + const deposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: "order-1" } }); + expect(deposit?.status).toBe(MoneriumFiatDepositStatus.Held); + + await MoneriumWebhookEvent.create({ eventId: "evt-8", payload: orderEvent("processed") }); + await processMoneriumWebhookInbox(); + await deposit?.reload(); + expect(deposit?.status).toBe(MoneriumFiatDepositStatus.Minted); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts index e6835037c..d2b1b9088 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -172,6 +172,70 @@ describe("monerium b2b onboarding automation", () => { expect(deps.calls.linkAddress).toHaveLength(0); }); + it("retries a failed link next cycle without wedging the ledger row", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + let failNext = true; + const workingLinkAddress = deps.linkAddress.bind(deps); + deps.linkAddress = async (profileId, address, chain, signature) => { + if (failNext) { + failNext = false; + throw new Error("Monerium request failed"); + } + return workingLinkAddress(profileId, address, chain, signature); + }; + + // First cycle: the link call fails with no side effect; the row must not be + // parked in a state that requires manual reconciliation. + await advanceOnboardingAccounts(deps); + const afterFailure = await FinancialOperation.findOne({ where: { phase: "linkAddress" } }); + expect(afterFailure?.status).toBe("failed"); + + // Second cycle: clean retry performs the call again and confirms. + await advanceOnboardingAccounts(deps); + const afterRetry = await FinancialOperation.findOne({ where: { phase: "linkAddress" } }); + expect(afterRetry?.status).toBe("confirmed"); + expect(deps.calls.linkAddress).toHaveLength(1); + }); + + it("reconciles an interrupted link from upstream state instead of re-posting", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + deps.linkAddress = async () => { + // The POST landed upstream but the response was lost mid-flight. + deps.linkedAddresses.add(FORWARDER); + throw new Error("socket hang up"); + }; + + await advanceOnboardingAccounts(deps); + + const operation = await FinancialOperation.findOne({ where: { phase: "linkAddress" } }); + expect(operation?.status).toBe("confirmed"); + expect(deps.calls.signLinkAttestation).toHaveLength(1); + }); + + it("retries a failed iban request next cycle", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + deps.linkedAddresses.add(FORWARDER); + let failNext = true; + const workingRequestIban = deps.requestIban.bind(deps); + deps.requestIban = async (address, chain) => { + if (failNext) { + failNext = false; + throw new Error("Monerium request failed"); + } + return workingRequestIban(address, chain); + }; + + await advanceOnboardingAccounts(deps); + expect((await FinancialOperation.findOne({ where: { phase: "requestIban" } }))?.status).toBe("failed"); + + await advanceOnboardingAccounts(deps); + expect((await FinancialOperation.findOne({ where: { phase: "requestIban" } }))?.status).toBe("confirmed"); + expect(deps.calls.requestIban).toHaveLength(1); + }); + it("does nothing while the whitelabel credentials are not configured", async () => { await createMappedAccount(); config.moneriumB2b.clientId = ""; diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.ts b/apps/api/src/api/services/monerium-b2b/onboarding.ts index b86fe42d0..a0a758686 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.ts @@ -3,7 +3,7 @@ import type { Address } from "viem"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; -import { runFinancialOperation } from "../phases/blocks/core/financial-operation"; +import { FinancialOperationRejectedError, runFinancialOperation } from "../phases/blocks/core/financial-operation"; import { signLinkAttestation } from "./attestor"; import { getChainId } from "./chain"; import { getIbanForAddress, getProfileAddresses, linkAddress, requestIban } from "./whitelabel-client"; @@ -55,7 +55,19 @@ async function ensureLinked(deps: OnboardingDeps, account: MoneriumAccount, chai flow: ONBOARDING_FLOW, perform: async () => { const attestation = await deps.signLinkAttestation(BigInt(chainId), account.forwarderAddress as Address); - await deps.linkAddress(account.profileId, account.forwarderAddress, chainName, attestation.signature); + try { + await deps.linkAddress(account.profileId, account.forwarderAddress, chainName, attestation.signature); + } catch (error) { + // Linking is synchronous upstream: if the address is not linked after a + // failure, the call had no side effect — signal that so the ledger allows a + // clean retry next cycle instead of parking the row in `unknown` forever. + if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress)) { + return { linked: true }; + } + throw new FinancialOperationRejectedError( + `link call failed with no side effect: ${error instanceof Error ? error.message : String(error)}` + ); + } return { linked: true }; }, phase: "linkAddress", @@ -83,7 +95,16 @@ async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainN attemptClass: "provider-iban-request", flow: ONBOARDING_FLOW, perform: async () => { - await deps.requestIban(account.forwarderAddress, chainName); + try { + await deps.requestIban(account.forwarderAddress, chainName); + } catch (error) { + // IBAN issuance is unique per (address, chain), so a repeated request can + // never create a second IBAN — a failed call is safe to retry next cycle. + // If the request did land, the reconcile read adopts the issued IBAN anyway. + throw new FinancialOperationRejectedError( + `iban request failed: ${error instanceof Error ? error.message : String(error)}` + ); + } return { requested: true }; }, phase: "requestIban", diff --git a/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts b/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts index 951f1914e..b312d7b7a 100644 --- a/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts +++ b/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts @@ -128,6 +128,23 @@ describe("webhook delivery outbox", () => { expect(deliverCalls).toHaveLength(0); }); + it("never double-delivers under concurrent dispatch (skip-locked claims)", async () => { + const webhook = await createDepositWebhook(); + for (let i = 0; i < 10; i++) { + await enqueueWebhookDeliveries([webhook], payloadFor(`deposit-received:dep-${i}`)); + } + deliverResults = Array.from({ length: 20 }, () => ({ error: null, ok: true })); + + // Two dispatchers race for the same due backlog; SKIP LOCKED must partition it. + await Promise.all([dispatchDueWebhookDeliveries(), dispatchDueWebhookDeliveries()]); + while ((await dispatchDueWebhookDeliveries()) > 0) { + // drain any remainder + } + + expect(deliverCalls).toHaveLength(10); + expect(await WebhookDelivery.count({ where: { status: WebhookDeliveryStatus.Sent } })).toBe(10); + }); + it("requeues stuck sending rows so a crash cannot strand a delivery", async () => { const webhook = await createDepositWebhook(); await enqueueWebhookDeliveries([webhook], payloadFor("deposit-received:dep-1")); From ff2101f3eb3ee2d72d9e352e37f335ab9a8827b9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 09:57:14 +0200 Subject: [PATCH 34/59] fix(api): harden the review's smaller monerium and outbox findings Per-item isolation for the deposit-event emitter loops and the stranded-balance monitor (one failing item no longer blocks siblings); the Monerium webhook route gets its own 100kb body limit like the Avenia mount instead of the global 20mb; outbox settle failures are logged instead of discarded; SequelizeMeta rename entries cover the 051/052 -> 069/070 renumbering for development databases; the account status endpoint's suspended -> active transition is asserted; and a stale migration reference in the webhook-event model comment is corrected. --- .../admin/moneriumB2b.controller.test.ts | 5 + .../services/monerium-b2b/manager-events.ts | 91 +++++++++++-------- .../api/services/monerium-b2b/monitoring.ts | 48 +++++----- .../webhook/webhook-outbox.service.ts | 5 +- apps/api/src/config/express.ts | 18 ++-- apps/api/src/database/migrator.test.ts | 2 + apps/api/src/database/migrator.ts | 5 + .../src/models/moneriumWebhookEvent.model.ts | 2 +- 8 files changed, 107 insertions(+), 69 deletions(-) diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts index 71446df64..4e15ed584 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -252,6 +252,11 @@ describe("monerium b2b account mapping admin route", () => { const suspended = await patchStatus(account.accountId, "suspended"); expect(suspended.status).toBe(200); + // Re-activation after a suspension keeps the IBAN guard satisfied. + const reactivated = await patchStatus(account.accountId, "active"); + expect(reactivated.status).toBe(200); + expect(await reactivated.json()).toMatchObject({ account: { accountStatus: "active" } }); + expect((await patchStatus(account.accountId, "nonsense")).status).toBe(400); expect((await patchStatus(crypto.randomUUID(), "active")).status).toBe(404); }); 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 9a31c1ce0..a37e50c81 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -75,20 +75,25 @@ async function emitReceivedEvents(): Promise { }); for (const deposit of deposits) { - const account = await MoneriumAccount.findByPk(deposit.accountId); - if (!account) continue; - const managerProfileId = await resolveManagerProfileId(account); - const payload: WebhookPayload = { - eventId: `deposit-received:${deposit.id}`, - eventType: WebhookEventType.DEPOSIT_RECEIVED, - payload: depositPayloadBase(deposit, account), - timestamp: new Date().toISOString() - }; - await enqueueForManager(WebhookEventType.DEPOSIT_RECEIVED, managerProfileId, payload); - // Marked emitted even with zero subscribers: webhooks are forward-looking, a - // later registration must not receive the whole history. A crash between the - // enqueue and this marker is absorbed by the outbox (webhook_id, event_id) dedup. - await deposit.update({ receivedEventAt: new Date() }); + try { + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) continue; + const managerProfileId = await resolveManagerProfileId(account); + const payload: WebhookPayload = { + eventId: `deposit-received:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_RECEIVED, + payload: depositPayloadBase(deposit, account), + timestamp: new Date().toISOString() + }; + await enqueueForManager(WebhookEventType.DEPOSIT_RECEIVED, managerProfileId, payload); + // Marked emitted even with zero subscribers: webhooks are forward-looking, a + // later registration must not receive the whole history. A crash between the + // enqueue and this marker is absorbed by the outbox (webhook_id, event_id) dedup. + await deposit.update({ receivedEventAt: new Date() }); + } catch (error) { + // Per-deposit isolation: one failing deposit must not block its siblings. + logger.error(`monerium-b2b: DEPOSIT_RECEIVED emission failed for deposit ${deposit.id}:`, error); + } } } @@ -108,34 +113,42 @@ async function emitConvertedEvents(deps: ManagerEventDeps): Promise { if (head === null) return; // no read RPC: emit once the chain is configured for (const deposit of deposits) { - const execution = await MoneriumConversionExecution.findByPk(deposit.allocatedExecutionId as string); - if (!execution || execution.status !== MoneriumConversionExecutionStatus.Confirmed) continue; - // Confirmation-depth gate (plan §3, registry P9): only notify once the execution - // block is NOTIFY_CONFIRMATION_DEPTH below the head, so a shallow reorg cannot - // produce a delivered-then-vanished conversion event. - if (execution.blockNumber === null || head < BigInt(execution.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH)) continue; - - const account = await MoneriumAccount.findByPk(deposit.accountId); - if (!account) continue; - const managerProfileId = await resolveManagerProfileId(account); - const payload: WebhookPayload = { - eventId: `deposit-converted:${deposit.id}`, - eventType: WebhookEventType.DEPOSIT_CONVERTED, - payload: { - ...depositPayloadBase(deposit, account), - conversion: { - executionId: execution.id, - txHash: execution.txHash, - usdcNetRaw: execution.usdcNetRaw - } - }, - timestamp: new Date().toISOString() - }; - await enqueueForManager(WebhookEventType.DEPOSIT_CONVERTED, managerProfileId, payload); - await deposit.update({ convertedEventAt: new Date() }); + try { + await emitConvertedEventForDeposit(deposit, head); + } catch (error) { + logger.error(`monerium-b2b: DEPOSIT_CONVERTED emission failed for deposit ${deposit.id}:`, error); + } } } +async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: bigint): Promise { + const execution = await MoneriumConversionExecution.findByPk(deposit.allocatedExecutionId as string); + if (!execution || execution.status !== MoneriumConversionExecutionStatus.Confirmed) return; + // Confirmation-depth gate (plan §3, registry P9): only notify once the execution + // block is NOTIFY_CONFIRMATION_DEPTH below the head, so a shallow reorg cannot + // produce a delivered-then-vanished conversion event. + if (execution.blockNumber === null || head < BigInt(execution.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH)) return; + + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) return; + const managerProfileId = await resolveManagerProfileId(account); + const payload: WebhookPayload = { + eventId: `deposit-converted:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_CONVERTED, + payload: { + ...depositPayloadBase(deposit, account), + conversion: { + executionId: execution.id, + txHash: execution.txHash, + usdcNetRaw: execution.usdcNetRaw + } + }, + timestamp: new Date().toISOString() + }; + await enqueueForManager(WebhookEventType.DEPOSIT_CONVERTED, managerProfileId, payload); + await deposit.update({ convertedEventAt: new Date() }); +} + /** * Emits the manager-facing deposit events into the durable webhook outbox: * DEPOSIT_RECEIVED once a deposit is minted, DEPOSIT_CONVERTED once its allocated diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index b79ea2717..621d20ec3 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -299,27 +299,33 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi ]); for (const account of accounts) { - const forwarder = account.forwarderAddress as Address; - const { eure } = await getForwarderImmutables(forwarder); - const [balance, strandedSince] = await Promise.all([ - client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), - client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }) - ]); - if (balance < minSwapFloor) { - continue; - } - const severity = classifyStranding(strandedSince, triggerDelay, now); - if (severity === "ok") { - continue; - } - const hours = Math.floor((now - Number(strandedSince) * 1000) / 3_600_000); - const message = - `monerium-b2b: stranded EURe on forwarder ${forwarder} (account ${account.id}): balance=${balance}, ` + - `marker armed ${hours}h ago${severity === "error" ? " — past TRIGGER_DELAY, permissionless trigger is live" : ""}`; - if (severity === "error") { - logger.error(message); - } else { - logger.warn(message); + try { + const forwarder = account.forwarderAddress as Address; + const { eure } = await getForwarderImmutables(forwarder); + const [balance, strandedSince] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }) + ]); + if (balance < minSwapFloor) { + continue; + } + const severity = classifyStranding(strandedSince, triggerDelay, now); + if (severity === "ok") { + continue; + } + const hours = Math.floor((now - Number(strandedSince) * 1000) / 3_600_000); + const message = + `monerium-b2b: stranded EURe on forwarder ${forwarder} (account ${account.id}): balance=${balance}, ` + + `marker armed ${hours}h ago${severity === "error" ? " — past TRIGGER_DELAY, permissionless trigger is live" : ""}`; + if (severity === "error") { + logger.error(message); + } else { + logger.warn(message); + } + } catch (error) { + // Per-account isolation like the sibling monitors: one failing read must not + // hide stranding on every other account. + logger.warn(`monerium-b2b: stranded-balance check failed for account ${account.id}:`, error); } } } diff --git a/apps/api/src/api/services/webhook/webhook-outbox.service.ts b/apps/api/src/api/services/webhook/webhook-outbox.service.ts index 9ad664733..5bc0c6444 100644 --- a/apps/api/src/api/services/webhook/webhook-outbox.service.ts +++ b/apps/api/src/api/services/webhook/webhook-outbox.service.ts @@ -91,7 +91,10 @@ export async function dispatchDueWebhookDeliveries(): Promise { await settle(row, result.ok, result.error); } catch (error) { logger.error(`webhook-outbox: dispatch failed for delivery ${row.id}:`, error); - await settle(row, false, error instanceof Error ? error.message : String(error)).catch(() => undefined); + await settle(row, false, error instanceof Error ? error.message : String(error)).catch(settleError => { + // The stuck-sending reconciler requeues the row; still say why settling failed. + logger.error(`webhook-outbox: could not settle delivery ${row.id}:`, settleError); + }); } } return claimed.length; diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index c57ad40c0..ced2ad219 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -63,19 +63,23 @@ app.use(["/v1/brl/kyc/import-token", "/v1/brla/kyc/import-token"], brlaKycImport // not buffer the 20mb the JSON API allows before the signature is even checked. app.use("/v1/webhooks/avenia", bodyParser.raw({ limit: "100kb", type: "*/*" }), aveniaWebhookRoutes); -// parse body params and attach them to req.body +// Same reasoning for the Monerium B2B webhook: the HMAC is computed over the RAW +// request bytes (captured here for the controller), and this unauthenticated route +// gets its own small limit instead of buffering the 20mb the JSON API allows before +// the signature is even checked. Mounted ahead of the global JSON parser, which +// skips bodies that are already parsed. app.use( + "/v1/monerium-b2b/webhook", bodyParser.json({ - limit: REQUEST_BODY_LIMIT, - // The Monerium B2B webhook HMAC is computed over the RAW request bytes; capture - // them before JSON parsing for that route only (monerium-b2b.controller). + limit: "100kb", verify: (req, _res, buf) => { - if (req.url?.startsWith("/v1/monerium-b2b/webhook")) { - (req as typeof req & { rawBody?: Buffer }).rawBody = buf; - } + (req as typeof req & { rawBody?: Buffer }).rawBody = buf; } }) ); + +// parse body params and attach them to req.body +app.use(bodyParser.json({ limit: REQUEST_BODY_LIMIT })); app.use(bodyParser.urlencoded({ extended: true, limit: REQUEST_BODY_LIMIT })); // gzip compression diff --git a/apps/api/src/database/migrator.test.ts b/apps/api/src/database/migrator.test.ts index c70a31260..86df3bcf6 100644 --- a/apps/api/src/database/migrator.test.ts +++ b/apps/api/src/database/migrator.test.ts @@ -7,6 +7,8 @@ import { getExecutedMigrations, getPendingMigrations, revertLastMigration, rever // Old-name/new-name pairs of the migrations renumbered to clear the duplicate-055 prefix. // Must stay in sync with MIGRATION_RENAMES in migrator.ts. const RENAMED = [ + ["051-monerium-b2b-onramp-tables.js", "069-monerium-b2b-onramp-tables.js"], + ["052-monerium-keeper-chain-state.js", "070-monerium-keeper-chain-state.js"], ["055-create-api-credentials.js", "057-create-api-credentials.js"], ["057-create-partner-managed-profiles.js", "058-create-partner-managed-profiles.js"], ["058-add-api-credential-id-to-quote-tickets.js", "059-add-api-credential-id-to-quote-tickets.js"] diff --git a/apps/api/src/database/migrator.ts b/apps/api/src/database/migrator.ts index 3b7bde1f6..f1b611827 100644 --- a/apps/api/src/database/migrator.ts +++ b/apps/api/src/database/migrator.ts @@ -175,6 +175,11 @@ const umzug = new Umzug({ // createTable. This cannot be a migration itself: umzug resolves the pending list before // executing any of them. const MIGRATION_RENAMES: Record = { + // Monerium B2B migrations renumbered 051/052 -> 069/070 when the branch caught up + // with staging (which had claimed 051/052 in the meantime). Only development + // databases ever ran the old names; deployed databases never did. + "051-monerium-b2b-onramp-tables": "069-monerium-b2b-onramp-tables", + "052-monerium-keeper-chain-state": "070-monerium-keeper-chain-state", "055-create-api-credentials": "057-create-api-credentials", "057-create-partner-managed-profiles": "058-create-partner-managed-profiles", "058-add-api-credential-id-to-quote-tickets": "059-add-api-credential-id-to-quote-tickets" diff --git a/apps/api/src/models/moneriumWebhookEvent.model.ts b/apps/api/src/models/moneriumWebhookEvent.model.ts index fa1cc2574..f4d09248c 100644 --- a/apps/api/src/models/moneriumWebhookEvent.model.ts +++ b/apps/api/src/models/moneriumWebhookEvent.model.ts @@ -3,7 +3,7 @@ import sequelize from "../config/database"; // Durable inbox for Monerium B2B webhook deliveries (plan §3, R06): rows are inserted // (dedup on event_id, on conflict do nothing) BEFORE the webhook returns 200 and -// processed asynchronously afterwards. Table created by migration 051. +// processed asynchronously afterwards. Table created by migration 069. export interface MoneriumWebhookEventAttributes { id: string; eventId: string; From 0fc3a67c4da9b8e31e713da8537aff0ebba08448 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 09:58:54 +0200 Subject: [PATCH 35/59] feat(api): bound retention for the outbox and webhook inbox Sent/abandoned delivery rows and long-processed inbox rows are pruned after 30 days (dedup only needs the retry horizon), so neither durable table grows without bound. --- .../monerium-b2b/deposit-processor.ts | 16 +++++++- .../webhook/webhook-outbox.service.test.ts | 38 +++++++++++++++++++ .../webhook/webhook-outbox.service.ts | 17 +++++++++ .../src/api/workers/monerium-b2b.worker.ts | 5 ++- .../src/api/workers/webhook-outbox.worker.ts | 7 +++- 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts index 0d0a95db6..7462da7e9 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -1,4 +1,4 @@ -import { Transaction } from "sequelize"; +import { Op, Transaction } from "sequelize"; import { parseUnits } from "viem"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; @@ -224,3 +224,17 @@ export async function processMoneriumWebhookInbox(): Promise { } return processed; } + +/** Processed inbox rows older than this are pruned; dedup only needs the retry horizon. */ +const PROCESSED_INBOX_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +/** Deletes long-processed inbox rows so the durable inbox stays bounded. */ +export async function pruneProcessedWebhookEvents(): Promise { + const count = await MoneriumWebhookEvent.destroy({ + where: { processedAt: { [Op.lt]: new Date(Date.now() - PROCESSED_INBOX_RETENTION_MS) } } + }); + if (count > 0) { + logger.info(`monerium-b2b: pruned ${count} processed webhook inbox row(s)`); + } + return count; +} diff --git a/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts b/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts index b312d7b7a..ddfd1d7aa 100644 --- a/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts +++ b/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { WebhookEventType, type WebhookPayload } from "@vortexfi/shared"; +import sequelize from "../../../config/database"; import Webhook from "../../../models/webhook.model"; import WebhookDelivery, { WebhookDeliveryStatus } from "../../../models/webhookDelivery.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; @@ -8,6 +9,7 @@ import webhookDeliveryService from "./webhook-delivery.service"; import { dispatchDueWebhookDeliveries, enqueueWebhookDeliveries, + pruneSettledWebhookDeliveries, reconcileStuckWebhookDeliveries } from "./webhook-outbox.service"; @@ -158,3 +160,39 @@ describe("webhook delivery outbox", () => { expect(row?.status).toBe(WebhookDeliveryStatus.Pending); }); }); + +describe("settled delivery retention", () => { + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + it("prunes old sent rows and keeps pending and recent ones", async () => { + const owner = await createTestUser(); + const webhook = await Webhook.create({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: "https://integrator.example.com/hook", + userId: owner.id + }); + const old = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + await WebhookDelivery.bulkCreate([ + { eventId: "old-sent", eventType: "DEPOSIT_RECEIVED", payload: payloadFor("old-sent"), sentAt: old, status: WebhookDeliveryStatus.Sent, webhookId: webhook.id }, + { eventId: "fresh-sent", eventType: "DEPOSIT_RECEIVED", payload: payloadFor("fresh-sent"), sentAt: new Date(), status: WebhookDeliveryStatus.Sent, webhookId: webhook.id }, + { eventId: "old-pending", eventType: "DEPOSIT_RECEIVED", payload: payloadFor("old-pending"), webhookId: webhook.id } + ]); + await sequelize.query("UPDATE webhook_deliveries SET updated_at = :old WHERE event_id IN ('old-sent', 'old-pending')", { + replacements: { old } + }); + + expect(await pruneSettledWebhookDeliveries()).toBe(1); + const remaining = (await WebhookDelivery.findAll()).map(row => row.eventId).sort(); + expect(remaining).toEqual(["fresh-sent", "old-pending"]); + }); +}); diff --git a/apps/api/src/api/services/webhook/webhook-outbox.service.ts b/apps/api/src/api/services/webhook/webhook-outbox.service.ts index 5bc0c6444..cad76e373 100644 --- a/apps/api/src/api/services/webhook/webhook-outbox.service.ts +++ b/apps/api/src/api/services/webhook/webhook-outbox.service.ts @@ -100,6 +100,23 @@ export async function dispatchDueWebhookDeliveries(): Promise { return claimed.length; } +/** Settled rows older than this are pruned; the payload's audit value has expired by then. */ +const SETTLED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +/** Deletes sent/abandoned deliveries past the retention window (bounded table growth). */ +export async function pruneSettledWebhookDeliveries(): Promise { + const count = await WebhookDelivery.destroy({ + where: { + status: { [Op.in]: [WebhookDeliveryStatus.Sent, WebhookDeliveryStatus.Abandoned] }, + updatedAt: { [Op.lt]: new Date(Date.now() - SETTLED_RETENTION_MS) } + } + }); + if (count > 0) { + logger.info(`webhook-outbox: pruned ${count} settled delivery row(s)`); + } + return count; +} + /** * Requeues rows stuck in `sending` (a crash between claim and settle). The attempts * counter was already incremented at claim, so the cap still holds. diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index dca867730..249d674db 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -6,7 +6,7 @@ import MoneriumAccount, { MoneriumAccountStatus } from "../../models/moneriumAcc import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; import { erc20Abi, getForwarderImmutables, getPublicClient, isKeeperChainConfigured } from "../services/monerium-b2b/chain"; import { runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; -import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { processMoneriumWebhookInbox, pruneProcessedWebhookEvents } from "../services/monerium-b2b/deposit-processor"; import { runDormancyGate } from "../services/monerium-b2b/dormancy"; import { emitMoneriumDepositEvents } from "../services/monerium-b2b/manager-events"; import { runMintWatcher } from "../services/monerium-b2b/mint-watcher"; @@ -77,6 +77,9 @@ class MoneriumB2bWorker { // converted event self-gates on the read RPC for its confirmation depth. await emitMoneriumDepositEvents(); + // Bounded retention for the durable inbox (dedup only needs the retry horizon). + await pruneProcessedWebhookEvents(); + // Detection-only monitors (plan D3); internally rate-limited and gated on the // read RPC / API credentials, so this is safe to call every cycle. await runMonitoringPass(); diff --git a/apps/api/src/api/workers/webhook-outbox.worker.ts b/apps/api/src/api/workers/webhook-outbox.worker.ts index f9ba3a23f..168029ec1 100644 --- a/apps/api/src/api/workers/webhook-outbox.worker.ts +++ b/apps/api/src/api/workers/webhook-outbox.worker.ts @@ -1,6 +1,10 @@ import { CronJob } from "cron"; import logger from "../../config/logger"; -import { dispatchDueWebhookDeliveries, reconcileStuckWebhookDeliveries } from "../services/webhook/webhook-outbox.service"; +import { + dispatchDueWebhookDeliveries, + pruneSettledWebhookDeliveries, + reconcileStuckWebhookDeliveries +} from "../services/webhook/webhook-outbox.service"; /** * Dispatches the durable webhook-delivery outbox (account-scoped event family). @@ -46,6 +50,7 @@ class WebhookOutboxWorker { private async reconcileCycle(): Promise { try { await reconcileStuckWebhookDeliveries(); + await pruneSettledWebhookDeliveries(); } catch (error) { logger.error("Error during webhook outbox reconcile cycle:", error); } From a5bd32decddccb8dcf2e681f150a2c24bbb40a1f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 10:00:57 +0200 Subject: [PATCH 36/59] docs(api): correct the attestor invariant and sync keeper spec claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invariant 2 now matches the shipped validator (chainid binding, EIP-191 hash only, the reserved RECOVERY_HASH slot — the raw-keccak variant was removed post-G0), and the keeper/mapping invariants record the new nonce-based crash recovery, single-lock double-send guard, reorg scan lag, pause-immune stranding marker, and provision-time forwarder verification. --- docs/security-spec/05-integrations/monerium-b2b.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index ff4b40d10..56ad39d36 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -13,7 +13,7 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e ## Security Invariants 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(forwarderAddress, linkHash))` where `linkHash` is one of the two fixed hashes of `"I hereby declare that I am the address owner."` (EIP-191 personal hash or raw keccak). `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 Monerium's recovery message (disabled as `bytes32(0)` until registry T1 resolves). `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 over raw bytes, constant-time** — the `webhook-signature` header is verified as HMAC-SHA256 of the RAW request bytes (captured by a body-parser `verify` hook scoped to this route, never a re-serialization of parsed JSON) using `crypto.timingSafeEqual`, with a self-compare on length mismatch so timing does not leak the mismatch position. 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/prd/monerium-b2b-implementation-plan.md) gives e 9. **Deposit identity is the Monerium order id** — `monerium_order_id` is unique; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are stored as 18-decimal base-unit strings converted from the provider decimal, never floats. 10. **Client credentials are env-only and requests are bounded** — whitelabel API credentials come from env, all calls carry an explicit timeout, HTTPS base URLs only, and upstream failures surface as generic 502s without echoing provider response bodies. 11. **KYB submission is a guarded stub** — `submitKybData` throws 501 until the whitelabel KYB mechanism is contractually settled (deferred-decisions registry T3); no speculative identity-data path exists. Pilot corporates do not use it: they are onboarded and approved by Monerium under the partner's KYC reliance, and the admin mapping imports that outcome as an approved `kyc_cases` row. -12. **Account mapping is admin-only, idempotent, and conflict-on-divergence** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) provisions the managed child (business entity, via the managed-profile provisioning service and its manager checks), mirrors the approved KYB, and binds the operator-deployed forwarder. Replaying identical input returns the existing records; any divergence (same Monerium profile with different forwarder/destination/child, same child with a second Monerium profile, a Monerium profile already bound to a different customer entity) is a 409, never an overwrite. A Monerium profile, a forwarder address, and a managed profile can each back at most one account (unique + partial-unique indexes, migrations 069/071). +12. **Account mapping is admin-only, idempotent, and conflict-on-divergence** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) provisions the managed child (business entity, via the managed-profile provisioning service and its manager checks), mirrors the approved KYB, and binds the operator-deployed forwarder. When a read RPC is configured, the submitted forwarder is verified against the chain before anything is persisted (factory `isForwarder` registration plus destination/fallbackAddress/feeBps read-back) so a wrong clone address can never become a mapped account. Replaying identical input returns the existing records; any divergence (same Monerium profile with different forwarder/destination/child/feeBps, same child with a second Monerium profile, a Monerium profile already bound to a different customer entity) is a 409, never an overwrite. A Monerium profile, a forwarder address, and a managed profile can each back at most one account (unique + partial-unique indexes, migrations 069/071). 13. **Onboarding provider writes are exactly-once** — 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. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. 15. **The read surface is effective-user scoped and accepts no selectors** — `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` resolve the account strictly from the acting profile (manager delegation via `X-Managed-Profile-Id` under the standard managed-profile authorization with EU corridor + business policy, or the child's own credential); no caller-supplied account, profile, or IBAN identifier is accepted, a foreign manager gets the uniform managed-profile 403, and R09 `unattr:` synthetic deposit rows are never returned (`monerium-b2b-account-read.integration.test.ts`). @@ -35,10 +35,10 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 1. **Three-way key separation** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`/`swapAndForward()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can move funds: `swapAndForward` only executes the contract-constrained oracle-checked swap to the client's own `destination`; `setGuardianPaused` is protective-only by contract invariant; the attestor signs the fixed link statement. All three are env-only and never logged. 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. -3. **Execution record before send** — a `monerium_conversion_executions` row (status `pending`, `eureInRaw = min(balance, perSwapCap)`, snapshot destination) is durably committed BEFORE any transaction is broadcast, and the tx hash is recorded immediately after send. A crash therefore always leaves an auditable pending row, never an untracked on-chain swap; leftover pendings are resolved next cycle via receipt lookup (finalize) or declared failed (no hash: never sent; stale hash: timed out). +3. **Execution record before send** — a `monerium_conversion_executions` row (status `pending`, `eureInRaw = min(balance, perSwapCap)`, snapshot destination) is durably committed BEFORE any transaction is broadcast — in the same forwarder-lock transaction as the pending-check, so two concurrent executors cannot both pass the check — and the swap's nonce is persisted before the broadcast, the tx hash immediately after. Leftover pendings are resolved next cycle: with a hash via receipt lookup (an RPC failure is distinguished from a genuine not-found and never runs the stale clock); without a hash via nonce classification against the chain (nonce absent: never sent; nonce unconsumed and not pending: never reached the mempool; nonce consumed with an unclaimed SwapExecuted: the lost hash is adopted and finalized; consumed without one: reverted/replaced). A crash or DB error between broadcast and the hash update therefore can never silently fail a mined swap or double-execute. Nonce derivation and broadcasts additionally serialize across processes via a keeper-send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, execution slot check/creation, finalization, R04 allocation) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. -5. **Attribution is snapshot-based and idempotent (R04)** — on confirmation, unallocated minted deposits with mint block ≤ execution block are selected oldest-first up to `eureInRaw`, USDC attribution is pro-rata by `amount_raw` against `eureInRaw` with floor division and remainder to the largest deposit (unit-tested), and `allocated_execution_id` links them. Mint identity is the `(chain_id, tx_hash, log_index)` partial unique index, so watcher re-scans after a crash cannot double-record; non-Monerium EURe inflows become `unattr:`-prefixed deposit rows (R09) and are never presented as customer deposits. -6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). +5. **Attribution is snapshot-based and idempotent (R04)** — on confirmation, unallocated minted deposits with mint block ≤ execution block are selected oldest-first up to `eureInRaw`, USDC attribution is pro-rata by `amount_raw` against `eureInRaw` with floor division and remainder to the largest deposit (unit-tested), and `allocated_execution_id` links them. Mint identity is the `(chain_id, tx_hash, log_index)` partial unique index, so watcher re-scans after a crash cannot double-record, and the watcher scans only blocks at least 12 confirmations below the head because that identity is not reorg-stable; non-Monerium EURe inflows become `unattr:`-prefixed deposit rows (R09) and are never presented as customer deposits, while a hash-matched mint whose on-chain value disagrees with the webhook amount is an error-level alert, never a silent overwrite. +6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. ## Monitoring From c2210dbbdd73a888c5eb9859540222dfe4bf071f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 10:36:05 +0200 Subject: [PATCH 37/59] docs(api): add the monerium b2b read surface and deposit events to the OpenAPI spec The two public GET endpoints and the DEPOSIT_RECEIVED/DEPOSIT_CONVERTED event family were documented in prose but absent from the machine-readable contract that feeds the endpoint catalog and codegen; the webhook registration schema now states the deposit-family rules. --- docs/api/apidog/page-manifest.json | 74 +++--- docs/api/openapi/vortex.openapi.d.ts | 201 ++++++++++++++- docs/api/openapi/vortex.openapi.json | 352 +++++++++++++++++++++++++-- 3 files changed, 567 insertions(+), 60 deletions(-) diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 47f48b95f..bb19fda5f 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -4,6 +4,24 @@ "currentDocumentedPaths": [ "/v1/api-credentials", "/v1/api-credentials/{credentialId}", + "/v1/auth/request-otp", + "/v1/auth/verify-otp", + "/v1/brl/createSubaccount", + "/v1/brl/getKycStatus", + "/v1/brl/getSelfieLivenessUrl", + "/v1/brl/getUploadUrls", + "/v1/brl/getUser", + "/v1/brl/getUserRemainingLimit", + "/v1/brl/kyb/attempt-status", + "/v1/brl/kyb/documents", + "/v1/brl/kyb/documents/{documentId}", + "/v1/brl/kyb/new-level-1/api", + "/v1/brl/kyb/new-level-1/web-sdk", + "/v1/brl/kyb/ubos", + "/v1/brl/kyc/import-token", + "/v1/brl/kyc/record-attempt", + "/v1/brl/newKyc", + "/v1/brl/validatePixKey", "/v1/domestic/alfredpayStatus", "/v1/domestic/createBusinessCustomer", "/v1/domestic/createIndividualCustomer", @@ -23,29 +41,13 @@ "/v1/domestic/submitKybRelatedPersonFile", "/v1/domestic/submitKycFile", "/v1/domestic/submitKycInformation", - "/v1/auth/request-otp", - "/v1/auth/verify-otp", - "/v1/brl/createSubaccount", - "/v1/brl/getKycStatus", - "/v1/brl/getSelfieLivenessUrl", - "/v1/brl/getUploadUrls", - "/v1/brl/getUser", - "/v1/brl/getUserRemainingLimit", - "/v1/brl/kyb/attempt-status", - "/v1/brl/kyb/documents", - "/v1/brl/kyb/documents/{documentId}", - "/v1/brl/kyb/new-level-1/api", - "/v1/brl/kyb/new-level-1/web-sdk", - "/v1/brl/kyb/ubos", - "/v1/brl/kyc/import-token", - "/v1/brl/kyc/record-attempt", - "/v1/brl/newKyc", - "/v1/brl/validatePixKey", "/v1/limits", "/v1/managed-profiles", "/v1/managed-profiles/{profileId}", "/v1/managed-profiles/{profileId}/api-credentials", "/v1/managed-profiles/{profileId}/api-credentials/{credentialId}", + "/v1/monerium-b2b/account", + "/v1/monerium-b2b/deposits", "/v1/onboarding/active-entity", "/v1/onboarding/requirements", "/v1/onboarding/status", @@ -53,9 +55,9 @@ "/v1/quotes", "/v1/quotes/best", "/v1/quotes/{id}", + "/v1/ramp-info", "/v1/ramp/history", "/v1/ramp/history/{walletAddress}", - "/v1/ramp-info", "/v1/ramp/register", "/v1/ramp/start", "/v1/ramp/update", @@ -100,7 +102,7 @@ "Vortex API" ], "metaDescription": "Vortex is a fiat-to-crypto on/off-ramp API supporting BRL, EUR, USD, MXN, COP, and ARS with USDC and USDT payouts across major EVM networks and AssetHub.", - "metaTitle": "Vortex API Overview — Fiat-to-Crypto On/Off-Ramp Gateway" + "metaTitle": "Vortex API Overview \u2014 Fiat-to-Crypto On/Off-Ramp Gateway" }, "slug": "overview", "source": "docs/api/pages/01-overview.md", @@ -119,7 +121,7 @@ "buy USDC API" ], "metaDescription": "Run your first fiat-to-crypto ramp with the Vortex Node.js SDK: install @vortexfi/sdk, create a quote, register the ramp, sign transactions, and track it to completion.", - "metaTitle": "Quick Start — Vortex Node.js SDK (@vortexfi/sdk)" + "metaTitle": "Quick Start \u2014 Vortex Node.js SDK (@vortexfi/sdk)" }, "slug": "quick-start-with-the-sdk", "source": "docs/api/pages/02-quick-start-with-the-sdk.md", @@ -139,7 +141,7 @@ "crypto ramp authentication" ], "metaDescription": "How Vortex authenticates clients with pk_*/sk_* keys or Supabase sessions, including managed-child delegation and secure BRL KYC token import.", - "metaTitle": "Authentication And API Keys — Vortex API" + "metaTitle": "Authentication And API Keys \u2014 Vortex API" }, "slug": "authentication-and-partner-keys", "source": "docs/api/pages/03-authentication-and-partner-keys.md", @@ -157,7 +159,7 @@ "on-ramp process" ], "metaDescription": "The full Vortex ramp lifecycle: create a quote, register with ephemeral accounts, sign and update transactions, settle fiat, start the ramp, and track it to a terminal state.", - "metaTitle": "Ramp Lifecycle — Quote, Register, Sign, Start, Track" + "metaTitle": "Ramp Lifecycle \u2014 Quote, Register, Sign, Start, Track" }, "slug": "ramp-lifecycle", "source": "docs/api/pages/04-ramp-lifecycle.md", @@ -175,7 +177,7 @@ "secure key storage" ], "metaDescription": "Vortex never receives ephemeral secret keys. Learn what your integration must store, for how long, and how to recover in-flight ramps safely.", - "metaTitle": "Ephemeral Key Custody — Vortex Security Model" + "metaTitle": "Ephemeral Key Custody \u2014 Vortex Security Model" }, "slug": "ephemeral-key-custody", "source": "docs/api/pages/05-ephemeral-key-custody.md", @@ -193,7 +195,7 @@ "stablecoin conversion rates" ], "metaDescription": "Create and read Vortex quotes: request shapes for buys and sells, fee breakdowns, best-route selection, expiry handling, and partner pricing.", - "metaTitle": "Quotes And Pricing — Vortex API" + "metaTitle": "Quotes And Pricing \u2014 Vortex API" }, "slug": "quotes-and-pricing", "source": "docs/api/pages/06-quotes-and-pricing.md", @@ -211,7 +213,7 @@ "crypto payment webhooks" ], "metaDescription": "Subscribe to Vortex ramp lifecycle events, verify RSA-PSS webhook signatures, and handle retries and auto-deactivation correctly.", - "metaTitle": "Webhooks — Real-Time Ramp Events And Verification" + "metaTitle": "Webhooks \u2014 Real-Time Ramp Events And Verification" }, "slug": "webhooks", "source": "docs/api/pages/07-webhooks.md", @@ -229,7 +231,7 @@ "white-label ramp" ], "metaDescription": "Embed the hosted Vortex Widget for browser and mobile flows: session creation, quote handoff, theming, and the custody model it handles for you.", - "metaTitle": "Vortex Widget — Hosted Crypto On/Off-Ramp Checkout" + "metaTitle": "Vortex Widget \u2014 Hosted Crypto On/Off-Ramp Checkout" }, "slug": "widget-integration", "source": "docs/api/pages/08-widget-integration.md", @@ -250,7 +252,7 @@ "Brazil Mexico Colombia Argentina crypto" ], "metaDescription": "Corridor requirements for BRL, EUR, USD, MXN, COP, and ARS, including payment rails, KYC prerequisites, BRL token import, accounts, and limits.", - "metaTitle": "Fiat Corridors — PIX, SEPA, ACH, SPEI, CBU" + "metaTitle": "Fiat Corridors \u2014 PIX, SEPA, ACH, SPEI, CBU" }, "slug": "fiat-corridors", "source": "docs/api/pages/09-fiat-corridors.md", @@ -261,7 +263,7 @@ "seo": { "keywords": ["sandbox", "test environment", "crypto API testing", "test API keys", "mock KYC accounts"], "metaDescription": "Test Vortex integrations end-to-end in the sandbox: base URLs, pk_test_/sk_test_ keys, and pre-configured KYC-approved mock accounts.", - "metaTitle": "Sandbox — Test Your Vortex Integration" + "metaTitle": "Sandbox \u2014 Test Your Vortex Integration" }, "slug": "sandbox", "source": "docs/api/pages/10-sandbox.md", @@ -278,7 +280,7 @@ "payment integration launch" ], "metaDescription": "Everything to verify before taking a Vortex integration live: key handling, ephemeral custody, webhook verification, idempotency, and recovery runbooks.", - "metaTitle": "Production Checklist — Go Live With Vortex" + "metaTitle": "Production Checklist \u2014 Go Live With Vortex" }, "slug": "production-checklist", "source": "docs/api/pages/11-production-checklist.md", @@ -295,8 +297,8 @@ "crypto ramp API contract", "machine-readable API docs" ], - "metaDescription": "Build a production-quality Vortex client in any language — with or without an AI coding agent. Covers the full raw-API contract, signing rules, and mandatory client responsibilities.", - "metaTitle": "AI Agent Integration — Build A Vortex Client In Any Language" + "metaDescription": "Build a production-quality Vortex client in any language \u2014 with or without an AI coding agent. Covers the full raw-API contract, signing rules, and mandatory client responsibilities.", + "metaTitle": "AI Agent Integration \u2014 Build A Vortex Client In Any Language" }, "slug": "ai-agent-integration", "source": "docs/api/pages/12-ai-agent-integration.md", @@ -306,8 +308,8 @@ "order": 13, "seo": { "keywords": ["KYB", "business verification", "know your business", "KYB deep link", "business onboarding crypto"], - "metaDescription": "Send business users straight into KYB verification with a single Vortex Widget URL — no quote required. Flow, query parameters, and locking behavior.", - "metaTitle": "KYB Deep Link — Business Verification Without A Quote" + "metaDescription": "Send business users straight into KYB verification with a single Vortex Widget URL \u2014 no quote required. Flow, query parameters, and locking behavior.", + "metaTitle": "KYB Deep Link \u2014 Business Verification Without A Quote" }, "slug": "kyb-deep-link", "source": "docs/api/pages/13-kyb-deep-link.md", @@ -325,7 +327,7 @@ "KYC on behalf" ], "metaDescription": "Create and operate headless Vortex profiles for your own customers: provisioning, delegated KYC/KYB onboarding, child credentials, and ramping on their behalf.", - "metaTitle": "Managed Profiles — Onboard And Ramp For Your Customers" + "metaTitle": "Managed Profiles \u2014 Onboard And Ramp For Your Customers" }, "slug": "managed-profiles", "source": "docs/api/pages/14-managed-profiles.md", @@ -343,7 +345,7 @@ "ramp status polling" ], "metaDescription": "Build a resilient custom Vortex UI with safe quote handling, browser token refresh, wallet checks, resumable payments, start reconciliation, and status polling.", - "metaTitle": "Custom UI Integration Best Practices — Vortex" + "metaTitle": "Custom UI Integration Best Practices \u2014 Vortex" }, "slug": "custom-ui-integration", "source": "docs/api/pages/15-custom-ui-integration.md", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 2ef3faf89..2f0a1cc5e 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -1846,9 +1846,9 @@ export interface paths { content: { "application/json": { events?: string[]; - /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific quote. The quote must have been created with your API key. */ + /** @description (required* one of two for transaction events: quoteId or sessionId; omit both for deposit events): Subscribe to events for a specific quote. The quote must have been created with your API key. */ quoteId?: string; - /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific session */ + /** @description (required* one of two for transaction events: quoteId or sessionId; omit both for deposit events): Subscribe to events for a specific session */ sessionId?: string; /** @description Your HTTPS webhook endpoint URL. No embedded credentials; must resolve to a publicly routable address. */ url: string; @@ -2004,6 +2004,50 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/monerium-b2b/account": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the acting profile's EUR onramp account + * @description Returns the acting profile's business EUR onramp account: status, dedicated IBAN, forwarding contract, and payout configuration. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. + * + * **Auth:** `X-API-Key` or Supabase Bearer. + */ + get: operations["getMoneriumB2bAccount"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/monerium-b2b/deposits": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the acting profile's EUR deposits + * @description Returns the acting profile's EUR deposits newest first, each with its allocated conversion execution once the swap has run. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. + * + * **Auth:** `X-API-Key` or Supabase Bearer. + */ + get: operations["listMoneriumB2bDeposits"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -3319,6 +3363,66 @@ export interface components { /** @description Indicates if the PIX key is valid. */ valid?: boolean; }; + MoneriumB2bAccount: { + accountId: string; + /** Format: date-time */ + createdAt: string; + /** @description The client's payout address on Ethereum. */ + destination: string; + /** + * Format: date-time + * @description Set while the account is dormancy-paused. + */ + dormantSince: string | null; + /** @description The client's self-custodied recovery address. */ + fallbackAddress: string; + feeBps: number; + /** @description The account's on-chain forwarding contract. */ + forwarderAddress: string; + /** @description The account's dedicated IBAN; null until issuance completes. */ + iban: string | null; + /** @enum {string} */ + status: "onboarding" | "active" | "suspended" | "closed"; + }; + MoneriumB2bAccountResponse: { + account: components["schemas"]["MoneriumB2bAccount"]; + }; + MoneriumB2bDeposit: { + /** @description Deposit amount in 18-decimal base units of the deposit currency. */ + amountRaw: string; + /** @description The allocated conversion execution once the swap has run; null while the deposit awaits conversion. */ + conversion: { + executionId: string; + /** + * @description Execution status. + * @enum {string} + */ + status: "pending" | "confirmed" | "failed"; + /** @description The swap-and-forward transaction hash. */ + txHash: string | null; + /** @description Net USDC forwarded for the whole execution in 6-decimal base units. When one execution batches several deposits this is the execution total; per-deposit shares are proportional to amountRaw. */ + usdcNetRaw: string | null; + } | null; + /** Format: date-time */ + createdAt: string; + currency: string; + depositId: string; + /** + * @description Deposit status (forward-only). + * @enum {string} + */ + status: "pending" | "minted" | "held" | "returned"; + /** @description The on-chain mint transaction, when observed. */ + txHash: string | null; + }; + MoneriumB2bDepositsResponse: { + deposits: components["schemas"]["MoneriumB2bDeposit"][]; + pagination: { + limit: number; + offset: number; + total: number; + }; + }; }; responses: { /** @description The selected profile or the authenticated user does not own the requested provider resource. */ @@ -7444,4 +7548,97 @@ export interface operations { }; }; }; + getMoneriumB2bAccount: { + parameters: { + query?: never; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The acting profile's onramp account. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MoneriumB2bAccountResponse"]; + }; + }; + /** @description Missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Managed-profile authorization failed (foreign child, corridor or customer-type policy). */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No account exists for the acting profile. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listMoneriumB2bDeposits: { + parameters: { + query?: { + /** @description Page size (default 20, max 100). */ + limit?: number; + /** @description Rows to skip (default 0). */ + offset?: number; + }; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The acting profile's deposits with conversion status. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MoneriumB2bDepositsResponse"]; + }; + }; + /** @description Missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Managed-profile authorization failed (foreign child, corridor or customer-type policy). */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No account exists for the acting profile. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index cb34238b2..9ae5039a6 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2196,8 +2196,12 @@ }, "type": "array" }, - "manager": { "$ref": "#/components/schemas/ManagedProfileManagerPolicy" }, - "pagination": { "$ref": "#/components/schemas/ManagedProfilePagination" } + "manager": { + "$ref": "#/components/schemas/ManagedProfileManagerPolicy" + }, + "pagination": { + "$ref": "#/components/schemas/ManagedProfilePagination" + } }, "required": ["manager", "managedProfiles", "pagination"], "type": "object" @@ -2322,16 +2326,25 @@ "description": "The authenticated manager's current policy. This policy is manager-scoped and applies to every managed child; corridors and customer types are not grants copied onto each child.", "properties": { "allowedCorridors": { - "items": { "enum": ["AR", "BR", "CO", "EU", "MX", "US"], "type": "string" }, + "items": { + "enum": ["AR", "BR", "CO", "EU", "MX", "US"], + "type": "string" + }, "type": "array", "uniqueItems": true }, "allowedCustomerTypes": { - "items": { "enum": ["individual", "business"], "type": "string" }, + "items": { + "enum": ["individual", "business"], + "type": "string" + }, "type": ["array", "null"], "uniqueItems": true }, - "profileId": { "format": "uuid", "type": "string" } + "profileId": { + "format": "uuid", + "type": "string" + } }, "required": ["profileId", "allowedCorridors", "allowedCustomerTypes"], "type": "object" @@ -2386,6 +2399,145 @@ "required": ["error"], "type": "object" }, + "MoneriumB2bAccount": { + "properties": { + "accountId": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "destination": { + "description": "The client's payout address on Ethereum.", + "type": "string" + }, + "dormantSince": { + "description": "Set while the account is dormancy-paused.", + "format": "date-time", + "type": ["string", "null"] + }, + "fallbackAddress": { + "description": "The client's self-custodied recovery address.", + "type": "string" + }, + "feeBps": { + "type": "integer" + }, + "forwarderAddress": { + "description": "The account's on-chain forwarding contract.", + "type": "string" + }, + "iban": { + "description": "The account's dedicated IBAN; null until issuance completes.", + "type": ["string", "null"] + }, + "status": { + "enum": ["onboarding", "active", "suspended", "closed"], + "type": "string" + } + }, + "required": [ + "accountId", + "createdAt", + "destination", + "dormantSince", + "fallbackAddress", + "feeBps", + "forwarderAddress", + "iban", + "status" + ], + "type": "object" + }, + "MoneriumB2bAccountResponse": { + "properties": { + "account": { + "$ref": "#/components/schemas/MoneriumB2bAccount" + } + }, + "required": ["account"], + "type": "object" + }, + "MoneriumB2bDeposit": { + "properties": { + "amountRaw": { + "description": "Deposit amount in 18-decimal base units of the deposit currency.", + "type": "string" + }, + "conversion": { + "description": "The allocated conversion execution once the swap has run; null while the deposit awaits conversion.", + "properties": { + "executionId": { + "type": "string" + }, + "status": { + "description": "Execution status.", + "enum": ["pending", "confirmed", "failed"], + "type": "string" + }, + "txHash": { + "description": "The swap-and-forward transaction hash.", + "type": ["string", "null"] + }, + "usdcNetRaw": { + "description": "Net USDC forwarded for the whole execution in 6-decimal base units. When one execution batches several deposits this is the execution total; per-deposit shares are proportional to amountRaw.", + "type": ["string", "null"] + } + }, + "required": ["executionId", "status", "txHash", "usdcNetRaw"], + "type": ["object", "null"] + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "currency": { + "type": "string" + }, + "depositId": { + "type": "string" + }, + "status": { + "description": "Deposit status (forward-only).", + "enum": ["pending", "minted", "held", "returned"], + "type": "string" + }, + "txHash": { + "description": "The on-chain mint transaction, when observed.", + "type": ["string", "null"] + } + }, + "required": ["amountRaw", "conversion", "createdAt", "currency", "depositId", "status", "txHash"], + "type": "object" + }, + "MoneriumB2bDepositsResponse": { + "properties": { + "deposits": { + "items": { + "$ref": "#/components/schemas/MoneriumB2bDeposit" + }, + "type": "array" + }, + "pagination": { + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": ["limit", "offset", "total"], + "type": "object" + } + }, + "required": ["deposits", "pagination"], + "type": "object" + }, "Networks": { "description": "Supported blockchain networks.", "enum": ["assethub", "arbitrum", "avalanche", "base", "bsc", "ethereum", "polygon", "moonbeam"], @@ -2539,10 +2691,14 @@ "type": "string" }, "documents": { - "items": { "$ref": "#/components/schemas/OnboardingDocumentRequirement" }, + "items": { + "$ref": "#/components/schemas/OnboardingDocumentRequirement" + }, "type": "array" }, - "flow": { "type": "string" }, + "flow": { + "type": "string" + }, "mode": { "enum": ["api", "hosted", "hybrid"], "type": "string" @@ -2555,9 +2711,13 @@ "enum": ["alfredpay", "avenia"], "type": "string" }, - "requirementsVersion": { "type": "string" }, + "requirementsVersion": { + "type": "string" + }, "steps": { - "items": { "$ref": "#/components/schemas/OnboardingRequirementStep" }, + "items": { + "$ref": "#/components/schemas/OnboardingRequirementStep" + }, "type": "array" } }, @@ -8006,6 +8166,110 @@ "tags": ["Managed Profiles"] } }, + "/v1/monerium-b2b/account": { + "get": { + "deprecated": false, + "description": "Returns the acting profile's business EUR onramp account: status, dedicated IBAN, forwarding contract, and payout configuration. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", + "operationId": "getMoneriumB2bAccount", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoneriumB2bAccountResponse" + } + } + }, + "description": "The acting profile's onramp account." + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Managed-profile authorization failed (foreign child, corridor or customer-type policy)." + }, + "404": { + "description": "No account exists for the acting profile." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get the acting profile's EUR onramp account", + "tags": ["Account Management"] + } + }, + "/v1/monerium-b2b/deposits": { + "get": { + "deprecated": false, + "description": "Returns the acting profile's EUR deposits newest first, each with its allocated conversion execution once the swap has run. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", + "operationId": "listMoneriumB2bDeposits", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "description": "Page size (default 20, max 100).", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Rows to skip (default 0).", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoneriumB2bDepositsResponse" + } + } + }, + "description": "The acting profile's deposits with conversion status." + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Managed-profile authorization failed (foreign child, corridor or customer-type policy)." + }, + "404": { + "description": "No account exists for the acting profile." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "List the acting profile's EUR deposits", + "tags": ["Account Management"] + } + }, "/v1/onboarding/active-entity": { "put": { "description": "Selects the authenticated profile's immutable active customer-entity type. Managed-child delegation is not supported.", @@ -10175,7 +10439,7 @@ }, "/v1/session/create": { "post": { - "description": "Creates a hosted Vortex Widget session and returns the URL to open for the user.\n\nThis single endpoint supports two mutually exclusive request shapes:\n\n- **Fixed quote** (`GetWidgetUrlLocked`) — pass a `quoteId` you created via `POST /v1/quotes`. The widget uses that exact quote and does not refresh it. If the quote expires before the user finishes, they must close the window and start over.\n\n- **Auto-refresh** (`GetWidgetUrlRefresh`) — pass the route parameters (`network`, `rampType`, `inputAmount`, plus `fiat` / `cryptoLocked` / `paymentMethod` as relevant for the direction). The widget creates and refreshes quotes on demand for the user.\n\nUse the example switcher below to see the request shape for each mode. `externalSessionId` is required in both modes and is echoed back in webhook payloads.", + "description": "Creates a hosted Vortex Widget session and returns the URL to open for the user.\n\nThis single endpoint supports two mutually exclusive request shapes:\n\n- **Fixed quote** (`GetWidgetUrlLocked`) \u2014 pass a `quoteId` you created via `POST /v1/quotes`. The widget uses that exact quote and does not refresh it. If the quote expires before the user finishes, they must close the window and start over.\n\n- **Auto-refresh** (`GetWidgetUrlRefresh`) \u2014 pass the route parameters (`network`, `rampType`, `inputAmount`, plus `fiat` / `cryptoLocked` / `paymentMethod` as relevant for the direction). The widget creates and refreshes quotes on demand for the user.\n\nUse the example switcher below to see the request shape for each mode. `externalSessionId` is required in both modes and is echoed back in webhook payloads.", "parameters": [], "requestBody": { "content": { @@ -10331,7 +10595,7 @@ "type": "array" }, "emoji": { - "description": "e.g. 🇩🇪", + "description": "e.g. \ud83c\udde9\ud83c\uddea", "type": "string" }, "name": { @@ -10585,17 +10849,17 @@ "properties": { "events": { "items": { - "description": "(optional): Array of event types to subscribe to. Defaults to all events if not specified. [\"TRANSACTION_CREATED\", \"STATUS_CHANGE\"]", + "description": "(optional): Array of event types to subscribe to. Transaction events [\"TRANSACTION_CREATED\", \"STATUS_CHANGE\"] are the default when omitted. The account-scoped deposit events [\"DEPOSIT_RECEIVED\", \"DEPOSIT_CONVERTED\"] must be requested explicitly, cannot be mixed with transaction events, require a profile-scoped secret credential, and take no quoteId/sessionId.", "type": "string" }, "type": "array" }, "quoteId": { - "description": "(required* one of two: quoteId or sessionId): Subscribe to events for a specific quote. The quote must have been created with your API key.", + "description": "(required* one of two for transaction events: quoteId or sessionId; omit both for deposit events): Subscribe to events for a specific quote. The quote must have been created with your API key.", "type": "string" }, "sessionId": { - "description": "(required* one of two: quoteId or sessionId): Subscribe to events for a specific session", + "description": "(required* one of two for transaction events: quoteId or sessionId; omit both for deposit events): Subscribe to events for a specific session", "type": "string" }, "url": { @@ -10665,22 +10929,44 @@ "headers": {} }, "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Managed-profile selection is unsupported on webhook endpoints.", "headers": {} }, "401": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } + }, "description": "Missing or invalid secret API key.", "headers": {} }, "403": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Direct managed-child credentials are unsupported, or supplied credentials conflict.", "headers": {} } }, - "security": [{ "SecretApiKey": [] }], + "security": [ + { + "SecretApiKey": [] + } + ], "summary": "Register Webhook", "tags": ["Webhooks"] } @@ -10722,22 +11008,44 @@ "headers": {} }, "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Managed-profile selection is unsupported on webhook endpoints.", "headers": {} }, "401": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } + }, "description": "Missing or invalid secret API key.", "headers": {} }, "403": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Direct managed-child credentials are unsupported, or supplied credentials conflict.", "headers": {} } }, - "security": [{ "SecretApiKey": [] }], + "security": [ + { + "SecretApiKey": [] + } + ], "summary": "Delete Webhook", "tags": ["Webhooks"] } From bb76f7533c4f40dfe1c0356418059905556c239f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 10:53:14 +0200 Subject: [PATCH 38/59] docs(api): add a recommended-values decision table to the registry Every open parameter gets a concrete recommendation to accept or overrule before deploy; T1 is rewritten as an open three-way decision, recording the pilot-only permissive-validator option under consideration together with the redeem-path and custody tradeoffs it carries. --- .../prd/monerium-onramp-deferred-decisions.md | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md index 2c5cc6b78..2724afa5b 100644 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -2,7 +2,32 @@ **Purpose:** single place for every parameter and decision we deliberately postponed so implementation can start. Nothing in here blocks coding; each row states the placeholder used in code/spec until decided. Review this file at every phase gate. -**Last updated:** 2026-07-17 +**Last updated:** 2026-08-26 + +## Decision table — recommended values (2026-08-26) + +Consolidated pre-deploy view: every open parameter with a concrete recommendation to +accept or overrule. Rationale and history stay in the detail sections below. + +| # | Item | Placeholder in code | Recommended | Rationale | Status | +|---|---|---|---|---|---| +| B1 | GA `feeBps` | 0 (pilot) | **0 pilot / 25 bps GA starting point** | Covers keeper gas + ops without denting OTC economics; immutable per clone, so set per client at deploy. Commercial call. | Open (business) | +| B2 | Penny-test amount | 5 USDC | **5 USDC** | Cheap, proves contract-originated credit at the destination. | Accept placeholder | +| B3 | Processing SLA wording | "within 1 business hour" | **"converted and forwarded within 4 hours of mint on business days; weekend/holiday mints execute against the last oracle round within the 52 h window and may see wider spreads"** | Matches keeper cadence + P8 weekend policy; 1 h leaves no incident headroom. | Draft for terms (G2) | +| B4 | Pilot client list + volume limits | €1k/client/day | **3–5 clients; €100k/client/day, €250k aggregate/day (paper controls)** | €1k/day is unusable for OTC tickets. Note: limits are contractual only — nothing in the backend enforces daily volume; `perSwapCap` bounds per-swap size, monitoring covers the rest. | Open (business) | +| B5 | Partner liability terms | — | **Tier A defaults from the variant doc**: partner warrants destination correctness, rotation loss borne by the client, dormancy re-activation on written partner confirmation (admin status endpoint) | Already the design the contract assumes. | Draft for partner agreement | +| B6 | Redemption-limitation disclosure | Draft in variant doc §6 | **Use the §6 draft** | Commitment made to Monerium; wording needs G2 review only. | Draft for terms (G2) | +| P1 | `SLIPPAGE_BPS` | 100 | **100** | T6 baseline shows ~14 bps impact at 25k incl. fees; 100 bps absorbs weekend EUR/USD drift inside the P8 window. | Accept placeholder | +| P2 | `MAX_FEE_BPS` | 100 | **100** | 1% ceiling leaves commercial room above the B1 value without weakening the client guarantee. | Accept placeholder | +| P3 | Dead-man sweep delay | 60 days | **60 days** | Long enough that no operational hiccup triggers it, short enough to be a credible client escape hatch. | Accept placeholder | +| P4 | Permissionless trigger delay | 24 h | **24 h** | Keeper cycles every minute; >24 h of silence means the fallback SHOULD be live. | Accept placeholder | +| P5 | Dormancy window | 60 days | **60 days** | Pairs with P3; backend-enforced, adjustable later without redeploy. | Accept placeholder | +| P6 | `minSwapAmount` (floor / operational) | €25 / €25 | **floor €25 (immutable) / operational €250** | Mainnet swap ≈ 400–600k gas; at €25 the keeper's gas can exceed 1% of volume. €250 keeps gas noise negligible for OTC sizes. Must stay ≥ each client's CEX minimum where applicable. | Set at deploy | +| P7 | `perSwapCap` (operational / ceiling) | €10k / €50k | **operational €25k / ceiling €50k** | T6: 25k executes within ~14 bps on the pinned path. Re-run T6 at the deploy block before raising the operational value. | Set at deploy | +| P8 | `MAX_ORACLE_AGE` | 26 h in test configs | **52 h** | Decided 2026-07-17 (observed weekend gaps up to 48 h). Still needs applying to the fork/invariant test configs and the deploy config. | Decided — apply | +| P9 | Notification confirmation depth | 32 blocks | **32 blocks** | Implemented (DEPOSIT_CONVERTED depth gate). | Done — close | +| P10 | Router pin + fee tiers | SwapRouter02, 5 bps hops | **SwapRouter02; re-verify both pools/fee tiers at the deploy block** | G0 output; verification action, not a value. | Action at deploy | +| T1 | `RECOVERY_HASH` / issuer recovery | `bytes32(0)` (disabled) | **Open — see options below** | See the rewritten T1 row: question to Monerium still unsent; a pilot-only permissive validator is under consideration with material tradeoffs. | **OPEN** | ## Business decisions (Marcel / partner) @@ -34,7 +59,7 @@ | # | Item | Owner | Status | |---|---|---|---| -| T1 | **Monerium recovery-burn mechanism for contract addresses**: exact message/hash their recovery flow validates via EIP-1271, so the forwarder can whitelist it (compile-time constant `RECOVERY_HASH`). If unanswered by deploy time: ship without it — fallback-address recovery covers us; issuer backstop becomes best-effort. **Requirement (review r1)**: enable only if the recovery message is parameterless or payout-neutral — a parameterized message validated by our attestor would grant Vortex disposal discretion | Monerium tech team (compliance punted) | **Asked? No — send follow-up** | +| T1 | **Monerium recovery-burn mechanism for contract addresses** — OPEN (2026-08-26). The issuer recovery flow validates a signature against the linked address via EIP-1271; the forwarder currently accepts only the link hash, so `RECOVERY_HASH = bytes32(0)` disables issuer recovery entirely. Options on the table: **(a) ship the pilot as-is** (no issuer recovery; the fallback-address sweep remains the client's recovery path; issuer backstop best-effort only); **(b) obtain the exact recovery message/hash from Monerium and whitelist it** — the preferred end state, but the question has still not been sent; **requirement (review r1)**: enable only if the message is parameterless or payout-neutral, since a parameterized message validated by our attestor would grant Vortex disposal discretion; **(c) pilot-only permissive validator (Marcel, 2026-08-26)**: accept that the pilot may need a less MiCA-clean variant where `isValidSignature` validates arbitrary attestor-signed messages so every Monerium-side flow (recovery included) works. **Recorded tradeoffs for (c)**: Monerium redeem orders also validate via EIP-1271, so a permissive validator reintroduces the redeem-to-arbitrary-IBAN path the constrained design exists to block — an attestor-key compromise becomes a fiat theft path, and the non-custody analysis changes because Vortex gains disposal capability (G2 must re-scope custody/MiCA before this ships; attestor key custody would need hardening, e.g. HSM; plan a migration back to the constrained validator for GA). Decision: Marcel + G2, before mainnet deploy | Monerium tech team (question), Marcel + G2 (pilot variant) | **OPEN — question not sent; pilot variant under consideration** | | T2 | Chainlink EUR/USD weekend behavior → weekend policy | G0 spike | **Answered 2026-07-17**: rounds observed on Sat/Sun (deviation-triggered), gaps up to 48 h; see P8. Weekend policy: execute normally with `MAX_ORACLE_AGE ≥ 52 h` | | T6 | Liquidity baseline (review F12 reproducibility) | G0 spike | **Recorded 2026-07-17, mainnet block 25553101**, QuoterV2 on pinned path EURe→(500)→EURC→(500)→USDC: 1k → 1.14298, 5k → 1.14288, 10k → 1.14278, 25k → 1.14252 USDC/EURe (Chainlink same day 1.14410 — 25k within ~14 bps incl. 2×5 bps fees). Deeper than the 07-10 snapshot; €10k cap comfortable. Re-run at deploy + wire into monitoring (task 6) | | T3 | Corporate KYB mechanism under whitelabel: Monerium-run verification vs KYC-reliance (reliance requires licenses we may not hold) | Monerium MSA negotiation | Open — fold into G1 | @@ -64,7 +89,7 @@ Custody opinion on attestor construction; MiCA exchange/transfer-service scoping - Tier C dropped: self-custodied `fallbackAddress` mandatory for every client (2026-07-17; aligns with Monerium condition). - Target whitelabel API directly, develop against sandbox; no legacy-OAuth interim build (2026-07-17). - Adversarial review runs in parallel with implementation (2026-07-17). -- Attestor-constrained `isValidSignature` (link hash only, attestor key only, bound to contract address); never a general owner key. +- Attestor-constrained `isValidSignature` (link hash only, attestor key only, bound to contract address); never a general owner key. **Under reconsideration for the pilot only** via T1 option (c) — any relaxation goes through the T1 decision with G2, not silently. - Never send raw EURe to a CEX destination; EURe recovery targets are `fallbackAddress` only. - No on-contract redeem validator (F05 stands); redemption path = fallback sweep → client redeems from own address; issuer recovery as break-glass backstop (pending T1). - Fee structure: per-client immutable `feeBps` at init, immutable `MAX_FEE_BPS` + treasury (pilot 0). From cf2df081e21b903d6e3130c5e06d05ab1edc1837 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 14:36:41 +0200 Subject: [PATCH 39/59] docs(api): add the monerium b2b onramp architecture overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One maintained end-to-end picture — onboarding and deposit-to-payout sequences, batching semantics, fee immutability, and the data model — with the security spec, registry, and runbooks as the detail layers. --- docs/README.md | 1 + docs/architecture-monerium-b2b-onramp.md | 141 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 docs/architecture-monerium-b2b-onramp.md diff --git a/docs/README.md b/docs/README.md index f40111798..0e5bdc28e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`adr-0004-sandbox-demo-environment.md`](adr-0004-sandbox-demo-environment.md) | Accepted decision on the seeded sales-demo account in the sandbox environment | | [`architecture-email-notifications.md`](architecture-email-notifications.md) | Current transactional/auth email architecture: queue, dispatch, producers | | [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | +| [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md) | Current end-to-end architecture of the B2B EUR onramp: onboarding, deposit-to-payout, batching, fees, data model | | [`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-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md new file mode 100644 index 000000000..72c57d9c3 --- /dev/null +++ b/docs/architecture-monerium-b2b-onramp.md @@ -0,0 +1,141 @@ +# Monerium B2B Onramp Architecture + +Current end-to-end architecture of the quoteless EUR → USDC onramp for KYB'd corporate +clients. Normative security detail lives in +[`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md); +open parameters and their recommended values in +[`prd/monerium-onramp-deferred-decisions.md`](prd/monerium-onramp-deferred-decisions.md); +operator procedures in [`runbooks/monerium-b2b-onboarding.md`](runbooks/monerium-b2b-onboarding.md). + +## 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 +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 calls `swapAndForward()` on the contract, which swaps EURe → EURC → USDC on +Uniswap v3 (Chainlink-bounded minimum output) and transfers the USDC to the client's +fixed destination wallet, minus the configured fee to the treasury. The flow is +deliberately **not** a ramp: no quote, no `ramp_states` — the account is permanent and +repeatedly funded. Inside Vortex the client is a **managed child profile** under the +partner manager, which is what carries KYB records, API credentials, the read API, and +webhook tenancy. + +## Onboarding sequence (per client) + +1. **Monerium onboards the corporate** to the whitelabel app under the partner's + reliance attestation; the profile arrives `approved`. (Vortex's KYB submission API is + a deliberate 501 stub — registry T3.) +2. **Operator deploys the forwarder clone** via the factory (`cast`, runbook §2) with the + client's `destination`, mandatory self-custodied `fallbackAddress`, and `feeBps`; + generates and verifies the manifest. +3. **Admin mapping** — one idempotent call, `POST /v1/admin/monerium-b2b/accounts`: + provisions the managed child (business `customer_entities` row under the manager), + mirrors the approved KYB into `provider_customers` + `kyc_cases`, verifies the + deployed clone on chain (factory registration, destination/fallback/feeBps + read-back), and creates the `monerium_accounts` row (status `onboarding`) bound to + the child via `vortex_profile_id`. +4. **Keeper automation** (every cycle, exactly-once via the profile-scoped + `financial_operations` ledger): signs the fixed link message with the attestor key and + `POST /addresses` links the forwarder to the Monerium profile, then `POST /ibans` + requests issuance for that address. The `iban.updated` webhook records the IBAN on + the account row. +5. **Penny test** (manual, runbook §7), then activation via + `PATCH /v1/admin/monerium-b2b/accounts/:id/status` (refused until the IBAN exists). + +## Deposit-to-payout sequence + +1. **EUR arrives on the IBAN.** Monerium creates an `issue` order and mints EURe to the + forwarder — no API call involved; incoming payments are passive. +2. **Vortex learns about it on three redundant channels** (all converge on the same + per-forwarder advisory lock): + - **Webhooks** (`order.created`/`order.updated`): HMAC-verified, persisted to the + durable `monerium_webhook_events` inbox *before* the 200, then drained into + `monerium_fiat_deposits` rows (forward-only status lattice, dedup by order id). + This channel carries the order accounting: amount, state, compliance holds. + - **The mint watcher**: scans EURe `Transfer` logs to known forwarders over a + persisted block cursor (12-block reorg lag) and stamps the deposit's on-chain + identity `(chain_id, tx_hash, log_index)`; unmatched inflows become flagged + `unattr:` rows, never customer deposits. + - **A balance check** in the worker as catch-all trigger: any forwarder holding EURe + becomes a conversion candidate even if the other channels lag. +3. **Conversion.** For each candidate account the keeper (one designated backend, the + mykobo flow variant) resolves leftover executions, then creates a + `monerium_conversion_executions` row *before* broadcasting, persists the planned + nonce, and sends `swapAndForward()`. The **contract** does the swap: it takes + `min(balance, perSwapCap)` EURe through the pinned EURe→EURC→USDC pools with a + Chainlink EUR/USD minimum-output bound, deducts `fee = usdcReceived * feeBps / 10000` + to `FEE_RECIPIENT`, and transfers the rest to the client's `destination`. +4. **Finalization + attribution.** The `SwapExecuted` event's amounts are authoritative; + the execution row is confirmed and open minted deposits are allocated to it + oldest-first up to the swapped amount, USDC pro-rata by deposit size (R04). +5. **Partner visibility.** `DEPOSIT_RECEIVED` fires when a deposit is minted and + `DEPOSIT_CONVERTED` once its execution is 32 blocks deep — durably, at-least-once, + through the `webhook_deliveries` outbox to the **manager's** webhook subscription. + Polling alternative: `GET /v1/monerium-b2b/account` and `/deposits` under managed + delegation or the child's own credential. + +## Batching and large deposits + +Batching happens in both directions, automatically: + +- **A large deposit is chunked.** One `swapAndForward()` call converts at most + `perSwapCap`; the remainder stays on the forwarder and the keeper converts it on + subsequent cycles (one execution row per chunk) until the balance is below + `minSwapAmount`. A €120k deposit at a €25k cap becomes five executions a few minutes + apart. The cap is an availability/price-impact parameter, not a safety bound — the + oracle `minOut` is the safety bound. +- **Several small deposits merge.** The contract swaps the balance, not a deposit: two + €5k deposits sitting on the forwarder convert in a single execution, and R04 + attribution splits the USDC back across both deposit rows pro-rata. A deposit that + would only partially fit under the cap is never split across executions — it waits + intact for the next one. + +## Fees + +- **Rate (`feeBps`)**: per-client, fixed at clone initialization, capped by the + implementation-immutable `MAX_FEE_BPS`. It is **immutable post-init by design** — fee + immutability is part of the client guarantee (registry B1). "Dynamically adjusting" + a client's fee therefore means deploying a **new clone** for them with the new rate + and migrating: link the new clone to their profile and move the IBAN's default + destination (`PATCH /ibans`) — a deliberate, monitored migration (the association + monitor treats IBAN moves as an alert condition), not a config flip. +- **Destination (`FEE_RECIPIENT`)**: an immutable baked into the **implementation** + contract at deployment, shared by every clone of that implementation. Changing the + treasury address means deploying a new implementation + factory and using it for new + clones. There is no per-client fee destination and no setter. +- The database mirrors `fee_bps` on `monerium_accounts` for accounting and drift + detection only; the contract value is authoritative, and the config-reconciliation + monitor alarms if they ever disagree. + +## Data model + +New tables (all introduced by this feature; migration numbers in parentheses): + +| Table | Purpose | +|---|---| +| `monerium_accounts` (069, 071) | One row per client account: Monerium profile UUID, IBAN, forwarder/destination/fallback addresses, `fee_bps`, lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | +| `monerium_fiat_deposits` (069, 070, 073) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status, on-chain mint identity, allocation link to its execution, and the two webhook-emission markers | +| `monerium_conversion_executions` (069, 074) | One row per `swapAndForward()`, created before broadcast: EURe in, USDC gross/fee/net from the event, tx hash, planned nonce (crash recovery), status | +| `monerium_webhook_events` (069) | Durable persist-before-200 inbox for Monerium deliveries, dedup by event id, 30-day retention after processing | +| `monerium_chain_cursors` (070) | Persisted block cursors for the mint watcher | +| `webhook_deliveries` (072) | Generic durable outbox for the deposit-event webhook family: one row per (webhook, event), claim-based dispatch with backoff, 30-day retention after settling | + +Rows created in **existing** tables per client: a `profiles` row (`kind = managed`) with +its `managed_profiles` relationship under the partner manager, a business +`customer_entities` row, a `provider_customers` row (`monerium`/`eur`, the Monerium +profile UUID) with an approved `kyb` `kyc_cases` row, `financial_operations` rows for +the exactly-once link/IBAN calls, and — registered by the partner — a user-owned +`webhooks` row subscribed to the deposit events. + +## Failure posture (pointers) + +Webhook deliveries survive crashes (persist-before-200 inbox); provider onboarding calls +are exactly-once (`financial_operations`); a broadcast whose hash was lost is recovered +from the persisted nonce plus unclaimed `SwapExecuted` logs rather than re-sent; all +per-account writes serialize on one advisory lock; and the client always has two exits +that no operator failure can block — the fallback-address sweep and, past the trigger +delay, permissionless swap execution. Full invariants and threat model: +[`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md). From 8c68b5452a86740a6b1cf4d6ebb4f63296dc4bc0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 15:11:55 +0200 Subject: [PATCH 40/59] fix(api): attribute deposits larger than the per-swap cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deposit bigger than perSwapCap could never be selected for any execution (eureIn is capped and only shrinks as the balance drains), so it stayed unallocated forever and head-of-line blocked attribution for every deposit behind it — DEPOSIT_CONVERTED never fired even though the funds converted correctly on chain. An oversized oldest deposit now attaches to the execution that begins converting it, with its pro-rata share clamped to the swapped amount so the allocation still sums exactly. Found while walking a worked example, not by the review. --- .../monerium-b2b/conversion-executor.test.ts | 23 +++++++++++++++++-- .../monerium-b2b/conversion-executor.ts | 23 +++++++++++++++---- docs/architecture-monerium-b2b-onramp.md | 6 +++-- .../05-integrations/monerium-b2b.md | 2 +- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 52b6eac54..6ade112ff 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -25,8 +25,20 @@ describe("selectDepositsForExecution", () => { expect(selectDepositsForExecution(deposits, 60n * EUR)).toEqual([deposits[0]]); }); - it("selects nothing when even the oldest deposit exceeds eureInRaw", () => { - expect(selectDepositsForExecution([deposit("a", 100n * EUR)], 60n * EUR)).toEqual([]); + it("selects an oversized oldest deposit so it can never wedge attribution", () => { + // A deposit larger than perSwapCap can never fit any execution (eureIn only + // shrinks as the balance drains); it must attach to the execution that starts + // converting it instead of blocking itself and every deposit behind it forever. + expect(selectDepositsForExecution([deposit("a", 100n * EUR)], 60n * EUR)).toEqual([deposit("a", 100n * EUR)]); + }); + + it("does not head-of-line block younger deposits behind an oversized one", () => { + const oversized = deposit("big", 120n * EUR); + const younger = deposit("small", 5n * EUR); + // Execution 1 (eureIn = cap): only the oversized deposit attaches. + expect(selectDepositsForExecution([oversized, younger], 100n * EUR)).toEqual([oversized]); + // Execution 2 (remaining balance): the younger deposit gets its own allocation. + expect(selectDepositsForExecution([younger], 25n * EUR)).toEqual([younger]); }); it("handles an exact fit and an empty list", () => { @@ -87,6 +99,13 @@ describe("allocateUsdcProRata", () => { expect(allocateUsdcProRata([], 100n * EUR, 100n * USDC).size).toBe(0); expect(allocateUsdcProRata([deposit("a", 1n * EUR)], 0n, 100n * USDC).size).toBe(0); }); + + it("clamps an oversized sole deposit to the swapped amount and conserves the total", () => { + // 120 EUR deposit, execution swapped only 100 EUR (perSwapCap): the deposit's + // share is the full execution output, never an overshoot of it. + const shares = allocateUsdcProRata([deposit("big", 120n * EUR)], 100n * EUR, 108n * USDC); + expect(shares.get("big")).toBe(108n * USDC); + }); }); describe("classifyHashlessPending", () => { diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index a2f7e7bea..810d2b326 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -76,14 +76,21 @@ export interface AllocatableDeposit { /** * Snapshot selection honoring the per-swap cap: deposits are taken oldest-mint-first * until the next one would push the cumulative amount past eureInRaw (a cap-cut deposit - * stays unallocated and joins the next execution). Callers pass only unallocated minted - * deposits with mint block <= execution block (R04). + * stays unallocated and joins the next execution). One exception: an OVERSIZED oldest + * deposit — alone larger than the swapped amount — is selected anyway, because eureIn + * is capped at perSwapCap and only shrinks as the balance drains, so such a deposit + * could never fit a later execution and would permanently block attribution for every + * deposit behind it. It is attributed to the execution that begins converting it. + * Callers pass only unallocated minted deposits with mint block <= execution block (R04). */ export function selectDepositsForExecution(deposits: AllocatableDeposit[], eureInRaw: bigint): AllocatableDeposit[] { const selected: AllocatableDeposit[] = []; let cumulative = 0n; for (const deposit of deposits) { if (cumulative + deposit.amountRaw > eureInRaw) { + if (selected.length === 0) { + selected.push(deposit); + } break; } cumulative += deposit.amountRaw; @@ -94,8 +101,10 @@ export function selectDepositsForExecution(deposits: AllocatableDeposit[], eureI /** * R04 pro-rata attribution of the execution's net USDC: each deposit gets - * floor(usdcNetRaw * amountRaw / eureInRaw); the remainder (floor dust, plus any value - * from inflows not represented in the selection) goes to the largest deposit (ties: the + * floor(usdcNetRaw * effectiveAmount / eureInRaw), where effectiveAmount is the + * deposit's amount clamped to the EURe this execution actually swapped (only an + * oversized sole deposit ever clamps); the remainder (floor dust, plus any value from + * inflows not represented in the selection) goes to the largest deposit (ties: the * earliest). Sum of shares always equals usdcNetRaw for a non-empty selection. */ export function allocateUsdcProRata( @@ -108,9 +117,13 @@ export function allocateUsdcProRata( return shares; } let allocated = 0n; + let cumulative = 0n; let largest = deposits[0]; for (const deposit of deposits) { - const share = (usdcNetRaw * deposit.amountRaw) / eureInRaw; + const remaining = eureInRaw > cumulative ? eureInRaw - cumulative : 0n; + const effectiveAmount = deposit.amountRaw > remaining ? remaining : deposit.amountRaw; + cumulative += effectiveAmount; + const share = (usdcNetRaw * effectiveAmount) / eureInRaw; shares.set(deposit.id, share); allocated += share; if (deposit.amountRaw > largest.amountRaw) { diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 72c57d9c3..46587be2d 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -90,8 +90,10 @@ Batching happens in both directions, automatically: - **Several small deposits merge.** The contract swaps the balance, not a deposit: two €5k deposits sitting on the forwarder convert in a single execution, and R04 attribution splits the USDC back across both deposit rows pro-rata. A deposit that - would only partially fit under the cap is never split across executions — it waits - intact for the next one. + would only partially fit under the cap waits intact for the next execution — unless + it is alone larger than the cap itself, in which case it attaches to the execution + that begins converting it (it could never fit a later, smaller one), with its share + clamped to the swapped amount. ## Fees diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 56ad39d36..6e5cc1331 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -37,7 +37,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. 3. **Execution record before send** — a `monerium_conversion_executions` row (status `pending`, `eureInRaw = min(balance, perSwapCap)`, snapshot destination) is durably committed BEFORE any transaction is broadcast — in the same forwarder-lock transaction as the pending-check, so two concurrent executors cannot both pass the check — and the swap's nonce is persisted before the broadcast, the tx hash immediately after. Leftover pendings are resolved next cycle: with a hash via receipt lookup (an RPC failure is distinguished from a genuine not-found and never runs the stale clock); without a hash via nonce classification against the chain (nonce absent: never sent; nonce unconsumed and not pending: never reached the mempool; nonce consumed with an unclaimed SwapExecuted: the lost hash is adopted and finalized; consumed without one: reverted/replaced). A crash or DB error between broadcast and the hash update therefore can never silently fail a mined swap or double-execute. Nonce derivation and broadcasts additionally serialize across processes via a keeper-send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, execution slot check/creation, finalization, R04 allocation) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. -5. **Attribution is snapshot-based and idempotent (R04)** — on confirmation, unallocated minted deposits with mint block ≤ execution block are selected oldest-first up to `eureInRaw`, USDC attribution is pro-rata by `amount_raw` against `eureInRaw` with floor division and remainder to the largest deposit (unit-tested), and `allocated_execution_id` links them. Mint identity is the `(chain_id, tx_hash, log_index)` partial unique index, so watcher re-scans after a crash cannot double-record, and the watcher scans only blocks at least 12 confirmations below the head because that identity is not reorg-stable; non-Monerium EURe inflows become `unattr:`-prefixed deposit rows (R09) and are never presented as customer deposits, while a hash-matched mint whose on-chain value disagrees with the webhook amount is an error-level alert, never a silent overwrite. +5. **Attribution is snapshot-based and idempotent (R04)** — on confirmation, unallocated minted deposits with mint block ≤ execution block are selected oldest-first up to `eureInRaw` — with one exception: an oversized oldest deposit (alone larger than the swapped amount, i.e. larger than `perSwapCap`) is attributed to the execution that begins converting it, since it could never fit a later execution and would otherwise permanently wedge attribution for itself and every deposit behind it — USDC attribution is pro-rata by the deposit amount clamped to `eureInRaw`, floor division, remainder to the largest deposit (unit-tested), and `allocated_execution_id` links them. Mint identity is the `(chain_id, tx_hash, log_index)` partial unique index, so watcher re-scans after a crash cannot double-record, and the watcher scans only blocks at least 12 confirmations below the head because that identity is not reorg-stable; non-Monerium EURe inflows become `unattr:`-prefixed deposit rows (R09) and are never presented as customer deposits, while a hash-matched mint whose on-chain value disagrees with the webhook amount is an error-level alert, never a silent overwrite. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. ## Monitoring From ac13fc7f154da964a4ed957716cc754b79b4a860 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 15:11:57 +0200 Subject: [PATCH 41/59] docs(api): register the fee-setter and clone-migration decisions P11 records the guardian-settable feeBps direction (timelocked increases, MAX_FEE_BPS cap) pending the 24h-vs-48h timelock choice; O1 records the backend-enforced migration procedure and why an on-chain timelock cannot gate an IBAN move. --- docs/prd/monerium-onramp-deferred-decisions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md index 2724afa5b..5b2a91277 100644 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -28,6 +28,8 @@ accept or overrule. Rationale and history stay in the detail sections below. | P9 | Notification confirmation depth | 32 blocks | **32 blocks** | Implemented (DEPOSIT_CONVERTED depth gate). | Done — close | | P10 | Router pin + fee tiers | SwapRouter02, 5 bps hops | **SwapRouter02; re-verify both pools/fee tiers at the deploy block** | G0 output; verification action, not a value. | Action at deploy | | T1 | `RECOVERY_HASH` / issuer recovery | `bytes32(0)` (disabled) | **Open — see options below** | See the rewritten T1 row: question to Monerium still unsent; a pilot-only permissive validator is under consideration with material tradeoffs. | **OPEN** | +| P11 | Guardian-settable `feeBps` (contract change) | feeBps immutable post-init | **Add before audit: guardian-only setter, capped by immutable `MAX_FEE_BPS`, increases behind an announced timelock (Marcel prefers 24 h; 48 h would also cover the SEPA weekend window), decreases immediate** | Marcel wants commercial flexibility (2026-08-26). Must land before any clones deploy — a setter lives in the implementation, and adding it later means migrating every clone anyway. G2 must scope the bounded pre-announced fee power. | Open — decide timelock, then implement | +| O1 | Client migration to a new clone (contract upgrades, or fee changes while P11 is unbuilt) | none (no tooling) | **Backend-enforced migration procedure**: admin endpoint that announces the migration (association monitor treats it as expected), waits its own delay, then links the new clone and moves the IBAN (`PATCH /ibans`) — with the invariant that Vortex tooling only ever targets factory clones (`isForwarder` + config read-back). No on-chain timelock is possible: the IBAN move is a Monerium API call the chain never sees; the preventive layer on Monerium's side is G1 item 4 (authorization requirements on `PATCH /ibans`). | Open — build when first needed | ## Business decisions (Marcel / partner) From 6b5d0cce39c5725e208258564cd1e6980db55ec4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 16:13:21 +0200 Subject: [PATCH 42/59] feat(api): add the guardian fee setter with a 24h increase timelock P11 decided (Marcel, 2026-08-26): feeBps becomes guardian-adjustable within the immutable MAX_FEE_BPS. Increases announce on-chain and apply permissionlessly only after FEE_INCREASE_TIMELOCK (24 h), so a client whose SEPA transfer is in flight cannot be swapped under a silently higher fee; decreases and cancel-via-restate are immediate. The invariant suite's config-integrity check now tracks every legal fee transition through a ghost model instead of asserting immutability, and the backend config monitor reconciles guardian fee changes like other authorized transitions instead of alarming. --- .../services/monerium-b2b/monitoring.test.ts | 6 +- .../api/services/monerium-b2b/monitoring.ts | 18 ++-- .../src/VortexForwarder.sol | 54 +++++++++++- .../test/VortexForwarder.invariants.t.sol | 39 +++++++-- .../test/VortexForwarder.t.sol | 82 +++++++++++++++++++ docs/architecture-monerium-b2b-onramp.md | 14 ++-- .../prd/monerium-onramp-deferred-decisions.md | 6 +- .../05-integrations/monerium-b2b.md | 2 +- 8 files changed, 193 insertions(+), 28 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts index 79b108497..c8eaec395 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -156,10 +156,10 @@ describe("detectConfigDrift", () => { }); }); - it("classifies a feeBps change as an error, never a reconciliation", () => { + it("classifies a feeBps change as a guardian-authorized reconciliation (P11)", () => { const drift = detectConfigDrift(base, { ...base, feeBps: 50 }); - expect(drift.errors).toEqual(["immutable feeBps mismatch: db=0 chain=50"]); - expect(drift.ownerAuthorizedUpdates).toEqual({}); + expect(drift.errors).toEqual([]); + expect(drift.ownerAuthorizedUpdates.feeBps).toBe(50); }); }); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 621d20ec3..e6662726f 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -169,20 +169,23 @@ export interface ForwarderConfigRecord { export interface ConfigDriftResult { /** Immutable-config violations — should be impossible; alarm, never reconcile. */ errors: string[]; - /** destination/fallbackAddress drift: owner-authorized by construction (R07) — reconcile the DB. */ - ownerAuthorizedUpdates: Partial>; + /** Authorized on-chain transitions — reconcile the DB: destination/fallbackAddress + * change only via the client's own key (R07), feeBps only via the guardian's + * timelocked setter (P11); both leave an on-chain event trail. */ + ownerAuthorizedUpdates: Partial>; } /** * Classifies drift between the DB config record and on-chain clone state. * destination/fallbackAddress are mutable ONLY by the client's fallbackAddress - * (`onlyFallback`), so any change there is an expected owner-authorized transition - * (re-review R07); feeBps is immutable post-init, so a change there is an incident. + * (`onlyFallback`) and feeBps ONLY by the guardian's timelocked setter (P11), so any + * change in those is an expected authorized transition to reconcile; everything else + * (bytecode, registration) is immutable and a change there is an incident. */ export function detectConfigDrift(db: ForwarderConfigRecord, onchain: ForwarderConfigRecord): ConfigDriftResult { const result: ConfigDriftResult = { errors: [], ownerAuthorizedUpdates: {} }; if (db.feeBps !== onchain.feeBps) { - result.errors.push(`immutable feeBps mismatch: db=${db.feeBps} chain=${onchain.feeBps}`); + result.ownerAuthorizedUpdates.feeBps = onchain.feeBps; } if (db.destination.toLowerCase() !== onchain.destination.toLowerCase()) { result.ownerAuthorizedUpdates.destination = onchain.destination; @@ -412,8 +415,9 @@ export async function runConfigReconciliation(): Promise { logger.error(`monerium-b2b: config violation on forwarder ${forwarder} (account ${account.id}): ${error}`); } if (Object.keys(drift.ownerAuthorizedUpdates).length > 0) { - // Owner-authorized transition (R07): only the client's fallbackAddress can - // change these on-chain — reconcile, do not alarm. + // Authorized transition: destination/fallback change only via the client's + // fallbackAddress (R07), feeBps only via the guardian's timelocked setter + // (P11) — reconcile, do not alarm. await account.update({ ...drift.ownerAuthorizedUpdates, configVersion: account.configVersion + 1 }); logger.warn( `monerium-b2b: reconciled owner-authorized config change on forwarder ${forwarder} (account ${account.id}): ` + diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index f2baa1b56..709214bd5 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -116,7 +116,12 @@ contract VortexForwarder { bool public initialized; address public destination; // client's payout address (may be a CEX deposit address) address public fallbackAddress; // client's self-custodied recovery address (mandatory) - uint16 public feeBps; // immutable post-init (registry B1; pilot = 0) + uint16 public feeBps; // guardian-adjustable within MAX_FEE_BPS; increases timelocked (P11) + + /// @dev P11 fee timelock state: a pending increase and when it may be applied. + /// effectiveAt == 0 means no increase is pending. Decreases never pend. + uint16 public pendingFeeBps; + uint64 public pendingFeeBpsEffectiveAt; bool public clientPaused; // set by fallbackAddress only bool public guardianPaused; // set by guardian only (protective-only; cannot block fallback paths) @@ -130,6 +135,10 @@ contract VortexForwarder { // ----------------------------------------------------------------- events event Initialized(address destination, address fallbackAddress, uint16 feeBps); + event FeeBpsDecreased(uint16 previous, uint16 current); + event FeeBpsIncreaseAnnounced(uint16 current, uint16 pending, uint64 effectiveAt); + event FeeBpsIncreaseApplied(uint16 previous, uint16 current); + event FeeBpsIncreaseCancelled(uint16 pending); event Poked(uint64 strandedSince); event SwapExecuted(address indexed caller, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded); event StrandedEureSwept(address indexed caller, uint256 amount); @@ -156,6 +165,7 @@ contract VortexForwarder { error InsufficientOutput(); error Overspend(); error NotStranded(); + error NoPendingFee(); error DelayNotElapsed(); error TransferFailed(); error Reentrancy(); @@ -401,6 +411,48 @@ contract VortexForwarder { emit GuardianPausedSet(paused); } + /// @dev P11: fee increases take effect only this long after their on-chain + /// announcement, so a client whose SEPA transfer is already in flight under + /// the current fee cannot be minted-and-swapped under a silently higher one. + /// Decreases are immediate — they only ever favor the client. + uint256 public constant FEE_INCREASE_TIMELOCK = 24 hours; + + /// @notice Guardian fee adjustment (P11), always bounded by the immutable + /// MAX_FEE_BPS. A decrease (or re-stating the current value) applies + /// immediately and cancels any pending increase; an increase is announced + /// and becomes applicable only after FEE_INCREASE_TIMELOCK. Announcing + /// again replaces the pending increase and restarts its clock. + function setFeeBps(uint16 newFeeBps) external onlyGuardian { + if (newFeeBps > MAX_FEE_BPS) revert FeeTooHigh(); + if (newFeeBps <= feeBps) { + if (pendingFeeBpsEffectiveAt != 0) { + emit FeeBpsIncreaseCancelled(pendingFeeBps); + pendingFeeBps = 0; + pendingFeeBpsEffectiveAt = 0; + } + if (newFeeBps != feeBps) { + emit FeeBpsDecreased(feeBps, newFeeBps); + feeBps = newFeeBps; + } + } else { + pendingFeeBps = newFeeBps; + pendingFeeBpsEffectiveAt = uint64(block.timestamp + FEE_INCREASE_TIMELOCK); + emit FeeBpsIncreaseAnnounced(feeBps, newFeeBps, pendingFeeBpsEffectiveAt); + } + } + + /// @notice Applies an announced fee increase once its timelock has elapsed. + /// Permissionless: the announcement is the authorization; anyone may + /// finalize it (the keeper does so as part of its cycle if needed). + function applyFeeBps() external { + if (pendingFeeBpsEffectiveAt == 0) revert NoPendingFee(); + if (block.timestamp < pendingFeeBpsEffectiveAt) revert DelayNotElapsed(); + emit FeeBpsIncreaseApplied(feeBps, pendingFeeBps); + feeBps = pendingFeeBps; + pendingFeeBps = 0; + pendingFeeBpsEffectiveAt = 0; + } + // ---------------------------------------------------------------- helpers function _validateConfigAddress(address account) internal view { diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol index 91dbab084..dca035a76 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -62,6 +62,7 @@ contract ForwarderHandler is Test { ); factory.setKeeper(keeper, true); fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, INITIAL_FEE_BPS, bytes32(uint256(1)))); + ghostExpectedFeeBps = INITIAL_FEE_BPS; } // ------------------------------------------------------------- actions @@ -114,6 +115,27 @@ contract ForwarderHandler is Test { fwd.setGuardianPaused(paused); // handler deployed the factory -> handler is guardian } + /// P11 ghost model: what feeBps is allowed to be right now. Decreases apply + /// immediately; increases only after their announced timelock elapses AND + /// someone calls applyFeeBps. + uint16 public ghostExpectedFeeBps; + + function guardianSetFee(uint16 raw) external { + uint16 newFee = raw % 120; // exercise the FeeTooHigh branch too (MAX is 100) + try fwd.setFeeBps(newFee) { + if (newFee <= ghostExpectedFeeBps) { + ghostExpectedFeeBps = newFee; // decrease/cancel: immediate + } + // increase: pending only — ghost updates when applyFee succeeds + } catch {} + } + + function applyFee() external { + try fwd.applyFeeBps() { + ghostExpectedFeeBps = fwd.feeBps(); // apply succeeded past its timelock + } catch {} + } + function clientPause(bool paused) external { vm.prank(fallbackAddr); fwd.setClientPaused(paused); @@ -130,10 +152,11 @@ contract ForwarderHandler is Test { function randoTriesPrivilegedCalls(uint8 selector) external { vm.startPrank(rando); - if (selector % 4 == 0) try fwd.setDestination(rando) {} catch {} - if (selector % 4 == 1) try fwd.setGuardianPaused(true) {} catch {} - if (selector % 4 == 2) try fwd.setFallbackAddress(rando) {} catch {} - if (selector % 4 == 3) try fwd.sweep(address(eure), rando) {} catch {} + if (selector % 5 == 0) try fwd.setDestination(rando) {} catch {} + if (selector % 5 == 1) try fwd.setGuardianPaused(true) {} catch {} + if (selector % 5 == 2) try fwd.setFallbackAddress(rando) {} catch {} + if (selector % 5 == 3) try fwd.sweep(address(eure), rando) {} catch {} + if (selector % 5 == 4) try fwd.setFeeBps(99) {} catch {} vm.stopPrank(); } } @@ -163,9 +186,13 @@ contract VortexForwarderInvariantTest is Test { assertEq(handler.usdc().balanceOf(address(handler.fwd())), 0, "forwarder retained USDC"); } - /// feeBps is immutable post-init; rando privileged-call attempts must never mutate config. + /// Config changes only through their authorized paths: feeBps moves exclusively + /// via the guardian's timelocked setter (P11 ghost model tracks every legal + /// transition — a rando call or an early apply can never move it), and it never + /// exceeds MAX_FEE_BPS; destination/fallback never change without their owner. function invariant_configIntegrity() public view { - assertEq(handler.fwd().feeBps(), handler.INITIAL_FEE_BPS()); + assertEq(handler.fwd().feeBps(), handler.ghostExpectedFeeBps(), "feeBps moved outside the guardian timelock path"); + assertLe(handler.fwd().feeBps(), 100, "feeBps exceeded MAX_FEE_BPS"); assertEq(handler.fwd().destination(), handler.destination()); assertEq(handler.fwd().fallbackAddress(), handler.fallbackAddr()); } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 43728a7b7..392b8d247 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -523,4 +523,86 @@ contract VortexForwarderTest is Test { vm.expectRevert(VortexForwarder.FeeTooHigh.selector); factory.deployForwarder(destination, fallbackAddr, 101, bytes32(uint256(9))); } + + // ------------------------------------------------------- fee timelock (P11) + + function test_setFeeBps_onlyGuardianAndCapped() public { + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotGuardian.selector); + fwd.setFeeBps(10); + + vm.expectRevert(VortexForwarder.FeeTooHigh.selector); + fwd.setFeeBps(101); // above MAX_FEE_BPS, even for the guardian + } + + function test_setFeeBps_increaseIsTimelocked() public { + fwd.setFeeBps(50); + // Announced, not applied: swaps in the window still use the old fee. + assertEq(fwd.feeBps(), 0); + assertEq(fwd.pendingFeeBps(), 50); + assertEq(fwd.pendingFeeBpsEffectiveAt(), uint64(block.timestamp + fwd.FEE_INCREASE_TIMELOCK())); + + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.applyFeeBps(); + + vm.warp(block.timestamp + 24 hours); + vm.prank(rando); // apply is permissionless — the announcement is the authorization + fwd.applyFeeBps(); + assertEq(fwd.feeBps(), 50); + assertEq(fwd.pendingFeeBps(), 0); + assertEq(fwd.pendingFeeBpsEffectiveAt(), 0); + + vm.expectRevert(VortexForwarder.NoPendingFee.selector); + fwd.applyFeeBps(); + } + + function test_setFeeBps_decreaseIsImmediateAndCancelsPending() public { + // Raise to 50 through the timelock first. + fwd.setFeeBps(50); + vm.warp(block.timestamp + 24 hours); + fwd.applyFeeBps(); + + // Announce a further increase, then decrease before it applies: the decrease + // is immediate and the pending increase is cancelled. + fwd.setFeeBps(80); + fwd.setFeeBps(25); + assertEq(fwd.feeBps(), 25); + assertEq(fwd.pendingFeeBpsEffectiveAt(), 0); + vm.warp(block.timestamp + 24 hours); + vm.expectRevert(VortexForwarder.NoPendingFee.selector); + fwd.applyFeeBps(); + } + + function test_setFeeBps_reannounceReplacesAndRestartsClock() public { + fwd.setFeeBps(50); + vm.warp(block.timestamp + 12 hours); + fwd.setFeeBps(80); // replaces the pending 50 and restarts the 24h clock + assertEq(fwd.pendingFeeBps(), 80); + + vm.warp(block.timestamp + 12 hours + 1); // 24h after FIRST announcement only + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.applyFeeBps(); + + vm.warp(block.timestamp + 12 hours); + fwd.applyFeeBps(); + assertEq(fwd.feeBps(), 80); + } + + function test_setFeeBps_restatingCurrentCancelsWithoutChange() public { + fwd.setFeeBps(50); + fwd.setFeeBps(0); // re-state the current value: cancel-only gesture + assertEq(fwd.feeBps(), 0); + assertEq(fwd.pendingFeeBpsEffectiveAt(), 0); + } + + function test_swapDuringPendingIncrease_usesOldFee() public { + fwd.setFeeBps(50); // pending, not applied + _fund(1_000e18); + router.setNextOut(1_140e6); + vm.prank(keeper); + fwd.swapAndForward(); + // Zero fee taken: the announced-but-unapplied increase never touches a swap. + assertEq(usdc.balanceOf(feeRecipient), 0); + assertEq(usdc.balanceOf(destination), 1_140e6); + } } diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 46587be2d..290ef3647 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -97,13 +97,13 @@ Batching happens in both directions, automatically: ## Fees -- **Rate (`feeBps`)**: per-client, fixed at clone initialization, capped by the - implementation-immutable `MAX_FEE_BPS`. It is **immutable post-init by design** — fee - immutability is part of the client guarantee (registry B1). "Dynamically adjusting" - a client's fee therefore means deploying a **new clone** for them with the new rate - and migrating: link the new clone to their profile and move the IBAN's default - destination (`PATCH /ibans`) — a deliberate, monitored migration (the association - monitor treats IBAN moves as an alert condition), not a config flip. +- **Rate (`feeBps`)**: per-client, set at clone initialization and adjustable by the + guardian via `setFeeBps`, always capped by the implementation-immutable + `MAX_FEE_BPS`. Increases are announced on-chain and apply (permissionlessly) only + after the 24 h `FEE_INCREASE_TIMELOCK`, so a client whose SEPA transfer is already + in flight cannot be swapped under a silently higher fee; decreases are immediate + (registry P11). Swaps always use the currently applied fee — an announced increase + never touches a swap inside its window. - **Destination (`FEE_RECIPIENT`)**: an immutable baked into the **implementation** contract at deployment, shared by every clone of that implementation. Changing the treasury address means deploying a new implementation + factory and using it for new diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md index 5b2a91277..3850750f1 100644 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -11,7 +11,7 @@ accept or overrule. Rationale and history stay in the detail sections below. | # | Item | Placeholder in code | Recommended | Rationale | Status | |---|---|---|---|---|---| -| B1 | GA `feeBps` | 0 (pilot) | **0 pilot / 25 bps GA starting point** | Covers keeper gas + ops without denting OTC economics; immutable per clone, so set per client at deploy. Commercial call. | Open (business) | +| B1 | GA `feeBps` | 0 (pilot) | **0 pilot / 25 bps GA starting point** | Covers keeper gas + ops without denting OTC economics; set per client at deploy, later adjustable via the P11 timelocked setter. Commercial call. | Open (business) | | B2 | Penny-test amount | 5 USDC | **5 USDC** | Cheap, proves contract-originated credit at the destination. | Accept placeholder | | B3 | Processing SLA wording | "within 1 business hour" | **"converted and forwarded within 4 hours of mint on business days; weekend/holiday mints execute against the last oracle round within the 52 h window and may see wider spreads"** | Matches keeper cadence + P8 weekend policy; 1 h leaves no incident headroom. | Draft for terms (G2) | | B4 | Pilot client list + volume limits | €1k/client/day | **3–5 clients; €100k/client/day, €250k aggregate/day (paper controls)** | €1k/day is unusable for OTC tickets. Note: limits are contractual only — nothing in the backend enforces daily volume; `perSwapCap` bounds per-swap size, monitoring covers the rest. | Open (business) | @@ -28,7 +28,7 @@ accept or overrule. Rationale and history stay in the detail sections below. | P9 | Notification confirmation depth | 32 blocks | **32 blocks** | Implemented (DEPOSIT_CONVERTED depth gate). | Done — close | | P10 | Router pin + fee tiers | SwapRouter02, 5 bps hops | **SwapRouter02; re-verify both pools/fee tiers at the deploy block** | G0 output; verification action, not a value. | Action at deploy | | T1 | `RECOVERY_HASH` / issuer recovery | `bytes32(0)` (disabled) | **Open — see options below** | See the rewritten T1 row: question to Monerium still unsent; a pilot-only permissive validator is under consideration with material tradeoffs. | **OPEN** | -| P11 | Guardian-settable `feeBps` (contract change) | feeBps immutable post-init | **Add before audit: guardian-only setter, capped by immutable `MAX_FEE_BPS`, increases behind an announced timelock (Marcel prefers 24 h; 48 h would also cover the SEPA weekend window), decreases immediate** | Marcel wants commercial flexibility (2026-08-26). Must land before any clones deploy — a setter lives in the implementation, and adding it later means migrating every clone anyway. G2 must scope the bounded pre-announced fee power. | Open — decide timelock, then implement | +| P11 | Guardian-settable `feeBps` (contract change) | — | **DECIDED + IMPLEMENTED (2026-08-26)**: guardian-only `setFeeBps`, capped by immutable `MAX_FEE_BPS`; increases announce on-chain and apply permissionlessly after `FEE_INCREASE_TIMELOCK = 24 hours` (Marcel's call); decreases (and cancel-via-restate) immediate. Monitoring reconciles guardian fee changes like owner-authorized config (warn + configVersion bump). | G2 must still scope the bounded pre-announced fee power. | Done — G2 scoping outstanding | | O1 | Client migration to a new clone (contract upgrades, or fee changes while P11 is unbuilt) | none (no tooling) | **Backend-enforced migration procedure**: admin endpoint that announces the migration (association monitor treats it as expected), waits its own delay, then links the new clone and moves the IBAN (`PATCH /ibans`) — with the invariant that Vortex tooling only ever targets factory clones (`isForwarder` + config read-back). No on-chain timelock is possible: the IBAN move is a Monerium API call the chain never sees; the preventive layer on Monerium's side is G1 item 4 (authorization requirements on `PATCH /ibans`). | Open — build when first needed | ## Business decisions (Marcel / partner) @@ -94,4 +94,4 @@ Custody opinion on attestor construction; MiCA exchange/transfer-service scoping - Attestor-constrained `isValidSignature` (link hash only, attestor key only, bound to contract address); never a general owner key. **Under reconsideration for the pilot only** via T1 option (c) — any relaxation goes through the T1 decision with G2, not silently. - Never send raw EURe to a CEX destination; EURe recovery targets are `fallbackAddress` only. - No on-contract redeem validator (F05 stands); redemption path = fallback sweep → client redeems from own address; issuer recovery as break-glass backstop (pending T1). -- Fee structure: per-client immutable `feeBps` at init, immutable `MAX_FEE_BPS` + treasury (pilot 0). +- Fee structure: per-client `feeBps` at init, guardian-adjustable via the 24 h-timelocked setter within immutable `MAX_FEE_BPS` (P11, 2026-08-26 — supersedes the original post-init immutability); `FEE_RECIPIENT` treasury immutable per implementation (pilot fee 0). diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 6e5cc1331..3e0bc7305 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -48,7 +48,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on the pinned EURe→EURC→USDC path at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline). Impact above `SLIPPAGE_BPS` at `minSwapAmount` size logs the error-level PAUSE THRESHOLD line; at `perSwapCap` size a warning. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. 3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal). 4. **Association monitor (S1 detective control)** — per active account, re-reads the profile's linked addresses (`GET /addresses?profile=`) and the partner-context IBAN list (`GET /ibans`) and error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. -5. **Config reconciliation (R07)** — re-reads per-clone config and bytecode. `destination`/`fallbackAddress` drift is owner-authorized by construction (`onlyFallback` in the contract): it is reconciled into the DB (with a `configVersion` bump) and logged at warn, never alarmed. `feeBps` drift (immutable post-init), a clone whose bytecode is not the EIP-1167 proxy of the factory's implementation, or a missing `isForwarder` registration are error-level should-be-impossible states. Mirrors the standalone manifest verifier (`contracts/monerium-forwarder/script/verify-manifest.ts`), which is documented as consistency evidence, not a trust root (R01). +5. **Config reconciliation (R07)** — re-reads per-clone config and bytecode. `destination`/`fallbackAddress` drift is owner-authorized by construction (`onlyFallback` in the contract) and `feeBps` drift is guardian-authorized by construction (the P11 timelocked setter: increases announce on-chain and apply permissionlessly only after `FEE_INCREASE_TIMELOCK` = 24 h, decreases immediate, always bounded by the immutable `MAX_FEE_BPS`): both are reconciled into the DB (with a `configVersion` bump) and logged at warn, never alarmed. A clone whose bytecode is not the EIP-1167 proxy of the factory's implementation, or a missing `isForwarder` registration, are error-level should-be-impossible states. Mirrors the standalone manifest verifier (`contracts/monerium-forwarder/script/verify-manifest.ts`), which is documented as consistency evidence, not a trust root (R01). ## Threat Vectors & Mitigations From 5e94f5e47211b43620715286085fc42ac04870e2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 17:18:14 +0200 Subject: [PATCH 43/59] docs(api): expand the onramp architecture doc with diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System map, onboarding and deposit-to-payout sequence diagrams, watcher/cursor mechanics, lifecycle state diagrams, an ER view of the data model, and the monitoring section — plus explicit labeling that the monerium_* tables belong exclusively to the B2B flow (the legacy OAuth integration owns no tables). --- docs/architecture-monerium-b2b-onramp.md | 323 +++++++++++++++++++---- 1 file changed, 270 insertions(+), 53 deletions(-) diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 290ef3647..b6033c821 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -23,59 +23,212 @@ repeatedly funded. Inside Vortex the client is a **managed child profile** under partner manager, which is what carries KYB records, API credentials, the read API, and webhook tenancy. +## System map + +```mermaid +flowchart LR + subgraph Partner["Partner (manager)"] + PAPI[Partner backend] + end + + subgraph Monerium + IBAN["Client IBAN\n(default destination = forwarder)"] + MAPI[Whitelabel API] + MWH[Monerium webhooks] + end + + subgraph Chain["Ethereum mainnet"] + FWD["VortexForwarder clone\n(one per client)"] + FACT[Factory + implementation] + UNI[Uniswap v3\nEURe-EURC-USDC] + LINK[Chainlink EUR/USD] + DEST[Client wallet] + TREAS[Treasury FEE_RECIPIENT] + end + + subgraph Vortex["Vortex API (keeper backend)"] + ADM["Admin API\n/v1/admin/monerium-b2b"] + INBOX[("monerium_webhook_events\n(durable inbox)")] + DP[Deposit processor] + MW[Mint watcher] + CE[Conversion executor] + ONB[Onboarding automation] + MONI[4 detection monitors] + OUTBOX[("webhook_deliveries\n(durable outbox)")] + READ["Read API\n/v1/monerium-b2b/*"] + end + + IBAN -- "SEPA in => EURe minted" --> FWD + MWH -- "order.*, iban.updated (HMAC)" --> INBOX + INBOX --> DP + MW -- "EURe Transfer logs" --> FWD + CE -- "swapAndForward()" --> FWD + FWD --> UNI + FWD -- "minOut check" --> LINK + FWD -- "USDC - fee" --> DEST + FWD -- fee --> TREAS + ONB -- "link address + request IBAN" --> MAPI + MONI -- "association / config reads" --> MAPI + OUTBOX -- "DEPOSIT_RECEIVED / DEPOSIT_CONVERTED" --> PAPI + PAPI -- "poll (delegation)" --> READ +``` + +Trust boundaries worth holding onto: **Monerium controls where EURe mints** (the IBAN's +linked default address — which is why the association monitor exists); **the contract +controls where funds can go** (fixed `destination`, fee to the immutable treasury, +fallback sweep — the keeper can only ever trigger, never redirect); **Vortex controls +timing and accounting**, nothing more. + ## Onboarding sequence (per client) -1. **Monerium onboards the corporate** to the whitelabel app under the partner's - reliance attestation; the profile arrives `approved`. (Vortex's KYB submission API is - a deliberate 501 stub — registry T3.) -2. **Operator deploys the forwarder clone** via the factory (`cast`, runbook §2) with the - client's `destination`, mandatory self-custodied `fallbackAddress`, and `feeBps`; - generates and verifies the manifest. -3. **Admin mapping** — one idempotent call, `POST /v1/admin/monerium-b2b/accounts`: - provisions the managed child (business `customer_entities` row under the manager), - mirrors the approved KYB into `provider_customers` + `kyc_cases`, verifies the - deployed clone on chain (factory registration, destination/fallback/feeBps - read-back), and creates the `monerium_accounts` row (status `onboarding`) bound to - the child via `vortex_profile_id`. -4. **Keeper automation** (every cycle, exactly-once via the profile-scoped - `financial_operations` ledger): signs the fixed link message with the attestor key and - `POST /addresses` links the forwarder to the Monerium profile, then `POST /ibans` - requests issuance for that address. The `iban.updated` webhook records the IBAN on - the account row. -5. **Penny test** (manual, runbook §7), then activation via - `PATCH /v1/admin/monerium-b2b/accounts/:id/status` (refused until the IBAN exists). +```mermaid +sequenceDiagram + participant Op as Operator + participant Adm as Vortex admin API + participant K as Keeper (worker) + participant M as Monerium + participant C as Ethereum + + Note over M: Monerium onboards the corporate under partner reliance - profile "approved" + Op->>C: deployForwarder(destination, fallback, feeBps) via factory + Op->>Adm: POST /v1/admin/monerium-b2b/accounts + Adm->>C: verify clone (isForwarder + config read-back) + Adm->>Adm: managed child + KYB mirror + monerium_accounts row (onboarding) + K->>M: POST /addresses (attestor-signed link) [exactly-once] + K->>M: POST /ibans for the forwarder address [exactly-once] + M-->>K: iban.updated webhook -> IBAN recorded + Op->>M: penny test (simulated/real small SEPA) + Op->>Adm: PATCH .../accounts/:id/status "active" (refused without IBAN) +``` + +Steps in prose: + +1. **Monerium onboards the corporate** under the partner's reliance attestation; the + profile arrives `approved`. (Vortex's KYB submission API is a deliberate 501 stub — + registry T3.) +2. **Operator deploys the forwarder clone** with the client's `destination`, mandatory + self-custodied `fallbackAddress`, and initial `feeBps`; manifest generated and + verified. +3. **Admin mapping** — one idempotent call provisions the managed child, mirrors the + approved KYB into `provider_customers` + `kyc_cases`, verifies the clone on chain, + and creates the account row bound via `vortex_profile_id`. +4. **Keeper automation** links the forwarder (attestor signature) and requests the IBAN, + each exactly-once through the profile-scoped `financial_operations` ledger; the + `iban.updated` webhook records the IBAN. +5. **Penny test**, then activation via the admin status endpoint. ## Deposit-to-payout sequence -1. **EUR arrives on the IBAN.** Monerium creates an `issue` order and mints EURe to the - forwarder — no API call involved; incoming payments are passive. -2. **Vortex learns about it on three redundant channels** (all converge on the same - per-forwarder advisory lock): - - **Webhooks** (`order.created`/`order.updated`): HMAC-verified, persisted to the - durable `monerium_webhook_events` inbox *before* the 200, then drained into - `monerium_fiat_deposits` rows (forward-only status lattice, dedup by order id). - This channel carries the order accounting: amount, state, compliance holds. - - **The mint watcher**: scans EURe `Transfer` logs to known forwarders over a - persisted block cursor (12-block reorg lag) and stamps the deposit's on-chain - identity `(chain_id, tx_hash, log_index)`; unmatched inflows become flagged - `unattr:` rows, never customer deposits. - - **A balance check** in the worker as catch-all trigger: any forwarder holding EURe - becomes a conversion candidate even if the other channels lag. -3. **Conversion.** For each candidate account the keeper (one designated backend, the - mykobo flow variant) resolves leftover executions, then creates a - `monerium_conversion_executions` row *before* broadcasting, persists the planned - nonce, and sends `swapAndForward()`. The **contract** does the swap: it takes - `min(balance, perSwapCap)` EURe through the pinned EURe→EURC→USDC pools with a - Chainlink EUR/USD minimum-output bound, deducts `fee = usdcReceived * feeBps / 10000` - to `FEE_RECIPIENT`, and transfers the rest to the client's `destination`. -4. **Finalization + attribution.** The `SwapExecuted` event's amounts are authoritative; - the execution row is confirmed and open minted deposits are allocated to it - oldest-first up to the swapped amount, USDC pro-rata by deposit size (R04). -5. **Partner visibility.** `DEPOSIT_RECEIVED` fires when a deposit is minted and - `DEPOSIT_CONVERTED` once its execution is 32 blocks deep — durably, at-least-once, - through the `webhook_deliveries` outbox to the **manager's** webhook subscription. - Polling alternative: `GET /v1/monerium-b2b/account` and `/deposits` under managed - delegation or the child's own credential. +```mermaid +sequenceDiagram + participant B as Client's bank + participant M as Monerium + participant F as Forwarder (chain) + participant V as Vortex keeper + participant P as Partner + + B->>M: SEPA transfer to the IBAN + M->>F: mint EURe (automatic, no API call) + M-->>V: order.created / order.updated webhook -> inbox -> deposit row + V->>F: (watcher) sees the Transfer log -> stamps chain identity + V->>V: DEPOSIT_RECEIVED -> outbox -> partner webhook + V->>F: swapAndForward() [execution row committed first] + F->>F: swap min(balance, perSwapCap) via Uniswap, Chainlink minOut + F->>P: USDC - fee to client wallet (fee to treasury) + V->>V: finalize from SwapExecuted event + R04 attribution + Note over V: 32 blocks later + V->>P: DEPOSIT_CONVERTED -> outbox -> partner webhook +``` + +Vortex learns about a mint on **three redundant channels**, which all converge on the +same per-forwarder advisory lock: the **webhooks** carry the order accounting (amount, +order id, compliance holds), the **mint watcher** stamps the on-chain identity that +attribution needs, and a plain **balance check** in the worker makes any funded +forwarder a conversion candidate even if both other channels lag. Any one channel alone +is enough to get funds converted. + +## How the mint watcher walks the chain + +The watcher is a poll-based log scanner with a **persisted cursor** so no block range is +ever skipped or double-processed across restarts: + +```mermaid +flowchart TD + A[cycle start] --> B["safeHead = latest - 12\n(reorg confirmation lag)"] + B --> C{cursor row exists?} + C -- no --> D["bootstrap: create cursor at safeHead\n(history is covered by webhook-recorded orders)"] + C -- yes --> E["fromBlock = cursor + 1\ntoBlock = min(safeHead, fromBlock + 2000)"] + E --> F["getLogs: EURe Transfer -> any known forwarder"] + F --> G["per log, under the forwarder lock:\nmatch to an open deposit (by tx hash, else amount)\nor record a flagged unattr: row"] + G --> H["advance cursor to toBlock\n(only after processing)"] + H --> A +``` + +The mechanics that matter: + +- The cursor row (`monerium_chain_cursors` — one row per watcher and chain) stores + the **last fully processed block**. It advances only *after* every log in the range + was handled, so a crash mid-range means the next cycle re-scans the same range — and + re-scanning is harmless because each mint's identity `(chain_id, tx_hash, log_index)` + is a unique index: already-recorded mints are skipped. +- The scan stops **12 blocks below the head**: that identity is not reorg-stable + (a dropped transaction re-mines with a different block and log index), so only + settled blocks are read. The lag costs ~2.5 minutes of latency on the *chain-identity* + channel only — the webhook channel and balance check are not delayed by it, so + conversion itself is not slowed. +- Ranges are capped at 2,000 blocks per cycle, so after downtime the watcher catches up + in bounded chunks instead of one unbounded `getLogs`. +- On first run there is no cursor: it bootstraps at the current settled head and scans + only forward. Historic mints are already represented by webhook-recorded orders; + back-filling their chain fields is a manual operation. + +## Lifecycles + +```mermaid +stateDiagram-v2 + direction LR + state "Deposit (monerium_fiat_deposits)" as dep { + [*] --> pending + pending --> minted + pending --> held + pending --> returned + held --> minted + held --> returned + minted --> [*] + returned --> [*] + } +``` + +```mermaid +stateDiagram-v2 + direction LR + state "Execution (monerium_conversion_executions)" as exe { + [*] --> pending2 : row committed BEFORE broadcast + pending2 --> confirmed : receipt + SwapExecuted + pending2 --> failed : revert / never sent / stale + confirmed --> [*] + failed --> [*] : retried via a NEW row (backoff) + } +``` + +```mermaid +stateDiagram-v2 + direction LR + state "Account (monerium_accounts)" as acc { + [*] --> onboarding : admin mapping + onboarding --> active : penny test + admin PATCH (needs IBAN) + active --> suspended + suspended --> active + active --> closed + suspended --> closed + } +``` + +Deposit statuses are **forward-only** (a delayed or replayed webhook can never regress a +row), and a hashless pending execution is resolved by nonce classification against the +chain rather than guesswork. The account additionally carries a `dormant_since` marker +(guardian-paused after 60 days without a conversion; conversion stops, the protective +stranding marker still arms). ## Batching and large deposits @@ -95,6 +248,11 @@ Batching happens in both directions, automatically: that begins converting it (it could never fit a later, smaller one), with its share clamped to the swapped amount. +In normal operation merging is rare: the keeper runs every minute, so deposits share an +execution only when they arrive within about a minute of each other or during downtime. +And batching only ever merges deposits of the **same client** — every client has their +own forwarder, so cross-client funds never mix. + ## Fees - **Rate (`feeBps`)**: per-client, set at clone initialization and adjustable by the @@ -108,13 +266,72 @@ Batching happens in both directions, automatically: contract at deployment, shared by every clone of that implementation. Changing the treasury address means deploying a new implementation + factory and using it for new clones. There is no per-client fee destination and no setter. -- The database mirrors `fee_bps` on `monerium_accounts` for accounting and drift - detection only; the contract value is authoritative, and the config-reconciliation - monitor alarms if they ever disagree. +- The database mirrors `fee_bps` on the account row for accounting and drift detection + only; the contract value is authoritative, and the config monitor reconciles + guardian fee changes (warn + version bump) while alarming on anything unauthorized. + +## Monitoring (detection-only) + +Four monitors run from the keeper worker (rate-limited to one pass per ~30 minutes), +read-only — no keys, no transactions: + +1. **Association monitor (the S1 detective control).** Per active account it re-reads + the Monerium-side state — `GET /addresses?profile=` and the IBAN list — and diffs it + against the database record. **Any** divergence is an error-level alert: the + forwarder no longer linked, an extra address linked to the profile, the IBAN moved + or unrecorded. This is the control for the structural risk that Vortex-held + whitelabel credentials can change associations at Monerium: those changes cannot be + prevented client-side, only detected fast. +2. **Executable-depth monitor.** QuoterV2 quotes on the pinned swap path vs Chainlink; + price impact past the slippage bound is an alert before clients feel it. +3. **Stranded-balance monitor.** Forwarders holding EURe with the stranding marker + armed too long — a keeper-outage signal (past the trigger delay, the permissionless + fallback is live; funds are never at risk, conversion is just late). +4. **Config reconciliation.** Re-reads per-clone config and bytecode: client-authorized + changes (destination/fallback) and guardian-authorized changes (feeBps, timelocked) + are reconciled into the DB with a version bump; bytecode or registration drift is a + should-be-impossible incident. + +## Data model — the Monerium B2B tables + +All tables below belong exclusively to this flow (the legacy Monerium OAuth/KYC +integration owns no tables of its own — it writes only `provider_customers` / +`kyc_cases`). Migration numbers in parentheses. -## Data model +```mermaid +erDiagram + profiles ||--o| monerium_accounts : "vortex_profile_id (managed child)" + monerium_accounts ||--o{ monerium_fiat_deposits : "account_id" + monerium_accounts ||--o{ monerium_conversion_executions : "account_id" + monerium_conversion_executions |o--o{ monerium_fiat_deposits : "allocated_execution_id (R04)" + webhooks ||--o{ webhook_deliveries : "webhook_id (deposit events)" -New tables (all introduced by this feature; migration numbers in parentheses): + monerium_accounts { + uuid vortex_profile_id FK + string monerium_profile_id UK + string iban + string forwarder_address UK + string destination + string fallback_address + int fee_bps + enum status + } + monerium_fiat_deposits { + string monerium_order_id UK + decimal amount_raw + enum status + string tx_hash + int log_index + uuid allocated_execution_id FK + } + monerium_conversion_executions { + decimal eure_in_raw + decimal usdc_net_raw + string tx_hash + int nonce + enum status + } +``` | Table | Purpose | |---|---| From 88a66ea300daa719dea74c4314a764405b1520e0 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 25 Aug 2026 12:30:44 -0300 Subject: [PATCH 44/59] feat(shared): add Monerium white-label client --- apps/api/.env.example | 17 + apps/api/src/test-utils/contract-support.ts | 8 + apps/api/src/test-utils/preload.ts | 3 + .../tests/contracts/monerium.contract.test.ts | 316 ++++++++++++++ docs/README.md | 1 + docs/operations-monerium-interface.md | 128 ++++++ docs/operations-testing.md | 6 +- .../security-spec/05-integrations/monerium.md | 68 ++- docs/security-spec/05-integrations/mykobo.md | 2 +- docs/security-spec/README.md | 4 +- packages/shared/src/constants.ts | 3 + packages/shared/src/services/index.ts | 1 + .../shared/src/services/monerium/index.ts | 3 + .../monerium/moneriumApiService.test.ts | 333 +++++++++++++++ .../services/monerium/moneriumApiService.ts | 402 ++++++++++++++++++ .../src/services/monerium/schemas.test.ts | 225 ++++++++++ .../shared/src/services/monerium/schemas.ts | 320 ++++++++++++++ .../shared/src/services/monerium/types.ts | 263 ++++++++++++ 18 files changed, 2094 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/tests/contracts/monerium.contract.test.ts create mode 100644 docs/operations-monerium-interface.md create mode 100644 packages/shared/src/services/monerium/index.ts create mode 100644 packages/shared/src/services/monerium/moneriumApiService.test.ts create mode 100644 packages/shared/src/services/monerium/moneriumApiService.ts create mode 100644 packages/shared/src/services/monerium/schemas.test.ts create mode 100644 packages/shared/src/services/monerium/schemas.ts create mode 100644 packages/shared/src/services/monerium/types.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index b314dd4cf..99ab4f376 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -159,6 +159,9 @@ ALFREDPAY_API_SECRET=your-alfredpay-api-secret MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id MONERIUM_API_URL=https://api.monerium.dev MONERIUM_REDIRECT_URI=http://localhost:5174/dashboard/monerium/callback +# Server-to-server white-label access. Keep this backend-only. +MONERIUM_WHITELABEL_CLIENT_ID=your-monerium-whitelabel-client-id +MONERIUM_WHITELABEL_CLIENT_SECRET=your-monerium-whitelabel-client-secret # BRLA / Avenia # BRLA_BASE_URL= @@ -178,6 +181,20 @@ BRLA_PRIVATE_KEY=your-brla-private-key # ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID= # a KYC submission of that customer # AVENIA_CONTRACT_SUBACCOUNT_ID= # KYC-approved Avenia sandbox subaccount # AVENIA_CONTRACT_COMPANY_SUBACCOUNT_ID= # COMPANY sandbox subaccount with >=1 KYB attempt +# MONERIUM_CONTRACT_PROFILE_ID= # approved white-label sandbox profile +# MONERIUM_CONTRACT_ADDRESS= # address linked to that profile +# MONERIUM_CONTRACT_ADDRESS_CHAIN= # e.g. ethereum +# MONERIUM_CONTRACT_IBAN= # IBAN owned by that profile +# MONERIUM_CONTRACT_ORDER_ID= # existing sandbox order +# MONERIUM_CONTRACT_RUN_ADDRESS_FLOW=1 +# MONERIUM_CONTRACT_ADDRESS_SIGNATURE= # fresh EOA or combined off-chain EIP-1271 hex bytes +# MONERIUM_CONTRACT_RUN_IBAN_FLOW=1 +# MONERIUM_CONTRACT_RUN_ORDER_FLOW=1 +# MONERIUM_CONTRACT_ORDER_REQUEST_JSON= # freshly signed complete POST /orders body +# MONERIUM_CONTRACT_RUN_FILE_UPLOAD=1 +# MONERIUM_CONTRACT_RUN_WEBHOOK_FLOW=1 +# MONERIUM_CONTRACT_WEBHOOK_URL= # must acknowledge subscription.created with HTTP 200 +# MONERIUM_CONTRACT_WEBHOOK_SECRET= # whsec_ + base64-encoded 24-64 random bytes # Local manual flow testing only. Replaces BRLA and AlfredPay mints with an ephemeral # balance wait and pauses offramps before the anchor transfer. Development only. diff --git a/apps/api/src/test-utils/contract-support.ts b/apps/api/src/test-utils/contract-support.ts index ffe6e77d4..1b8662941 100644 --- a/apps/api/src/test-utils/contract-support.ts +++ b/apps/api/src/test-utils/contract-support.ts @@ -22,6 +22,14 @@ export async function runLive(label: string, call: () => Promise): Promise return result; } catch (error) { if (error instanceof ZodError) throw error; + if ( + typeof error === "object" && + error !== null && + "providerContractViolation" in error && + error.providerContractViolation === true + ) { + throw error; + } console.warn(`[contract:live] ${label} inconclusive: ${error instanceof Error ? error.message : String(error)}`); return null; } diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 32546be4d..6596c1df6 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -30,6 +30,9 @@ if (!process.env.RUN_LIVE_TESTS) { process.env.ALFREDPAY_BASE_URL = "http://alfredpay.invalid"; process.env.ALFREDPAY_API_KEY = "test-alfredpay-api-key"; process.env.ALFREDPAY_API_SECRET = "test-alfredpay-api-secret"; + process.env.MONERIUM_API_URL = "http://monerium.invalid"; + process.env.MONERIUM_WHITELABEL_CLIENT_ID = "test-monerium-whitelabel-client-id"; + process.env.MONERIUM_WHITELABEL_CLIENT_SECRET = "test-monerium-whitelabel-client-secret"; // COINGECKO_API_URL is deliberately NOT overridden: priceFeed config tests // assert its default, and the fetch guard blocks real calls anyway. process.env.ALCHEMY_API_KEY = ""; diff --git a/apps/api/src/tests/contracts/monerium.contract.test.ts b/apps/api/src/tests/contracts/monerium.contract.test.ts new file mode 100644 index 000000000..3767fc602 --- /dev/null +++ b/apps/api/src/tests/contracts/monerium.contract.test.ts @@ -0,0 +1,316 @@ +/** + * External API contract: Monerium white-label API v2 (docs/operations-testing.md). + * + * Read-only live checks require MONERIUM_WHITELABEL_CLIENT_ID/MONERIUM_WHITELABEL_CLIENT_SECRET and may use: + * - MONERIUM_CONTRACT_PROFILE_ID + * - MONERIUM_CONTRACT_ADDRESS + * - MONERIUM_CONTRACT_IBAN + * - MONERIUM_CONTRACT_ORDER_ID + * + * Mutating checks are prepared but independently opt-in because they create persistent + * sandbox resources or can move sandbox EURe: + * - MONERIUM_CONTRACT_RUN_ADDRESS_FLOW=1 plus PROFILE_ID, ADDRESS, ADDRESS_CHAIN, ADDRESS_SIGNATURE + * - MONERIUM_CONTRACT_RUN_IBAN_FLOW=1 plus ADDRESS and ADDRESS_CHAIN + * - MONERIUM_CONTRACT_RUN_ORDER_FLOW=1 plus MONERIUM_CONTRACT_ORDER_REQUEST_JSON + * - MONERIUM_CONTRACT_RUN_FILE_UPLOAD=1 + * - MONERIUM_CONTRACT_RUN_WEBHOOK_FLOW=1 plus WEBHOOK_URL and WEBHOOK_SECRET + * + * The address signature is sent unchanged. For a Safe off-chain EIP-1271 flow it must be + * the combined owner-signature bytes assembled externally; there is no extra endpoint. + */ +import { describe, expect, test } from "bun:test"; +import { + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + type MoneriumChain, + MoneriumApiService, + MoneriumContractError, + type MoneriumRedeemOrderRequest, + moneriumAddressSchema, + moneriumIbanSchema, + moneriumListAddressesResponseSchema, + moneriumListIbansResponseSchema, + moneriumListOrdersResponseSchema, + moneriumListProfilesResponseSchema, + moneriumOrderSchema, + moneriumProfileSchema, + moneriumRedeemOrderRequestSchema, + moneriumUploadedFileSchema, + moneriumWebhookEventSchema, + moneriumWebhookSubscriptionSchema, + moneriumListWebhooksResponseSchema +} from "@vortexfi/shared"; +import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; + +const RUN_LIVE = process.env.RUN_LIVE_TESTS === "1"; +const HAS_CREDS = !!( + process.env.MONERIUM_WHITELABEL_CLIENT_ID && process.env.MONERIUM_WHITELABEL_CLIENT_SECRET +); +const PROFILE_ID = process.env.MONERIUM_CONTRACT_PROFILE_ID; +const ADDRESS = process.env.MONERIUM_CONTRACT_ADDRESS; +const ADDRESS_CHAIN = process.env.MONERIUM_CONTRACT_ADDRESS_CHAIN as MoneriumChain | undefined; +const IBAN = process.env.MONERIUM_CONTRACT_IBAN; +const ORDER_ID = process.env.MONERIUM_CONTRACT_ORDER_ID; +const RUN_ADDRESS_FLOW = process.env.MONERIUM_CONTRACT_RUN_ADDRESS_FLOW === "1"; +const RUN_IBAN_FLOW = process.env.MONERIUM_CONTRACT_RUN_IBAN_FLOW === "1"; +const RUN_ORDER_FLOW = process.env.MONERIUM_CONTRACT_RUN_ORDER_FLOW === "1"; +const RUN_FILE_UPLOAD = process.env.MONERIUM_CONTRACT_RUN_FILE_UPLOAD === "1"; +const RUN_WEBHOOK_FLOW = process.env.MONERIUM_CONTRACT_RUN_WEBHOOK_FLOW === "1"; + +const FIXTURE_PROFILE_ID = "123e4567-e89b-42d3-a456-426614174000"; +const FIXTURE_RESOURCE_ID = "223e4567-e89b-42d3-a456-426614174001"; +const FIXTURE_ADDRESS = "0x59cFC408d310697f9D3598e1BE75B0157a072407"; +const FIXTURE_IBAN = "EE521273842688571285"; + +if (RUN_LIVE && !HAS_CREDS) { + console.warn( + "[contract:live] Monerium live half skipped: " + + "MONERIUM_WHITELABEL_CLIENT_ID/MONERIUM_WHITELABEL_CLIENT_SECRET not set" + ); +} + +function assertSandboxMutationTarget(rawUrl = process.env.MONERIUM_API_URL): void { + if (!rawUrl) throw new Error("MONERIUM_API_URL must be set explicitly for mutating contract tests"); + const url = new URL(rawUrl); + if (url.origin !== "https://api.monerium.dev" || (url.pathname !== "/" && url.pathname !== "")) { + throw new Error("Mutating Monerium contract tests require exactly https://api.monerium.dev"); + } +} + +function orderFixture() { + return { + address: FIXTURE_ADDRESS, + amount: "100.00", + chain: "ethereum", + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: FIXTURE_IBAN, standard: "iban" } + }, + currency: "eur", + id: FIXTURE_RESOURCE_ID, + kind: "redeem", + memo: "Powered by Monerium", + meta: { placedAt: "2026-08-25T12:00:00Z" }, + profile: FIXTURE_PROFILE_ID, + state: "placed" + }; +} + +describe("Monerium external API contract - hermetic fixtures", () => { + test("profile, address, and IBAN responses satisfy consumed contracts", () => { + const profile = { + details: { state: "approved" }, + form: { state: "approved" }, + id: FIXTURE_PROFILE_ID, + kind: "personal", + name: "Jane Doe", + state: "approved", + verifications: [{ kind: "idDocument", state: "approved" }] + }; + expect(() => moneriumListProfilesResponseSchema.parse({ profiles: [profile] })).not.toThrow(); + expect(() => moneriumProfileSchema.parse(profile)).not.toThrow(); + expect(() => + moneriumListAddressesResponseSchema.parse({ + addresses: [{ address: FIXTURE_ADDRESS, chains: ["ethereum"], profile: FIXTURE_PROFILE_ID }] + }) + ).not.toThrow(); + expect(() => + moneriumListIbansResponseSchema.parse({ + ibans: [ + { + address: FIXTURE_ADDRESS, + bic: "CBHFLU2LXXX", + chain: "ethereum", + iban: FIXTURE_IBAN, + name: "Jane Doe", + profile: FIXTURE_PROFILE_ID + } + ] + }) + ).not.toThrow(); + }); + + test("redeem request and order response preserve signing semantics", () => { + const timestamp = `${new Date(Date.now() + 60_000).toISOString().slice(0, 16)}Z`; + const request = { + address: FIXTURE_ADDRESS, + amount: "100.00", + chain: "ethereum", + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: FIXTURE_IBAN, standard: "iban" } + }, + currency: "eur", + kind: "redeem", + message: `Send EUR 100.00 to ${FIXTURE_IBAN} at ${timestamp}`, + signature: `0x${"ab".repeat(65)}` + }; + expect(() => moneriumRedeemOrderRequestSchema.parse(request)).not.toThrow(); + expect(() => moneriumListOrdersResponseSchema.parse({ orders: [orderFixture()] })).not.toThrow(); + }); + + test("webhook event fixtures preserve event discriminators", () => { + expect(() => + moneriumWebhookEventSchema.parse({ + data: orderFixture(), + timestamp: "2026-08-25T12:01:00Z", + type: "order.updated" + }) + ).not.toThrow(); + expect(() => + moneriumWebhookEventSchema.parse({ + data: { id: FIXTURE_PROFILE_ID, kind: "personal", state: "approved" }, + timestamp: "2026-08-25T12:01:00Z", + type: "profile.updated" + }) + ).not.toThrow(); + expect(() => + moneriumWebhookEventSchema.parse({ + data: { + address: FIXTURE_ADDRESS, + chain: "ethereum", + iban: "EE52 1273 8426 8857 1285", + profile: FIXTURE_PROFILE_ID, + state: "approved" + }, + timestamp: "2026-08-25T12:01:00Z", + type: "iban.updated" + }) + ).not.toThrow(); + }); + + test("provider contract violations fail instead of becoming inconclusive", async () => { + await expect( + runLive("monerium malformed success", async () => { + throw new MoneriumContractError("GET /profiles"); + }) + ).rejects.toBeInstanceOf(MoneriumContractError); + }); + + test("mutating checks refuse the production API", () => { + expect(() => assertSandboxMutationTarget("https://api.monerium.app")).toThrow(); + expect(() => assertSandboxMutationTarget("https://api.monerium.dev/v2")).toThrow(); + expect(() => assertSandboxMutationTarget("https://api.monerium.dev")).not.toThrow(); + }); +}); + +describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Monerium external API contract - live sandbox", () => { + const api = () => MoneriumApiService.getInstance(); + + test("GET profile collection and fixture profile satisfy API v2 contracts", async () => { + const profiles = await runLive("monerium listProfiles", () => api().listProfiles()); + if (!profiles) return; + const parsed = moneriumListProfilesResponseSchema.parse(profiles); + + const profileId = PROFILE_ID ?? parsed.profiles[0]?.id; + if (!profileId) return; + const profile = await runLive("monerium getProfile", () => api().getProfile(profileId)); + if (profile) moneriumProfileSchema.parse(profile); + }); + + test("GET address, IBAN, and order collections satisfy API v2 contracts", async () => { + const addresses = await runLive("monerium listAddresses", () => api().listAddresses({ profile: PROFILE_ID })); + if (addresses) moneriumListAddressesResponseSchema.parse(addresses); + + const ibans = await runLive("monerium listIbans", () => api().listIbans({ profile: PROFILE_ID })); + if (ibans) moneriumListIbansResponseSchema.parse(ibans); + + const orders = await runLive("monerium listOrders", () => api().listOrders({ profile: PROFILE_ID })); + if (orders) moneriumListOrdersResponseSchema.parse(orders); + }); + + test.skipIf(!ADDRESS)("GET fixture address satisfies the address contract", async () => { + const address = await runLive("monerium getAddress", () => api().getAddress(ADDRESS as string)); + if (address) moneriumAddressSchema.parse(address); + }); + + test.skipIf(!IBAN)("GET fixture IBAN satisfies the IBAN contract", async () => { + const iban = await runLive("monerium getIban", () => api().getIban(IBAN as string)); + if (iban) moneriumIbanSchema.parse(iban); + }); + + test.skipIf(!ORDER_ID)("GET fixture order satisfies the order contract", async () => { + const order = await runLive("monerium getOrder", () => api().getOrder(ORDER_ID as string)); + if (order) moneriumOrderSchema.parse(order); + }); + + test.skipIf(!RUN_ADDRESS_FLOW || !PROFILE_ID || !ADDRESS || !ADDRESS_CHAIN || !process.env.MONERIUM_CONTRACT_ADDRESS_SIGNATURE)( + "POST /addresses accepts the prepared ownership proof", + async () => { + assertSandboxMutationTarget(); + const result = await runLive("monerium linkAddress", () => + api().linkAddress({ + address: ADDRESS as string, + chain: ADDRESS_CHAIN as MoneriumChain, + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID as string, + signature: process.env.MONERIUM_CONTRACT_ADDRESS_SIGNATURE as string + }) + ); + if (result) expect([201, 202]).toContain(result.httpStatus); + } + ); + + test.skipIf(!RUN_IBAN_FLOW || !ADDRESS || !ADDRESS_CHAIN)( + "POST /ibans preserves accepted and already-provisioned semantics", + async () => { + assertSandboxMutationTarget(); + const result = await runLive("monerium requestIban", () => + api().requestIban({ address: ADDRESS as string, chain: ADDRESS_CHAIN as MoneriumChain }) + ); + if (result) expect([202, 304]).toContain(result.httpStatus); + } + ); + + test.skipIf(!RUN_ORDER_FLOW || !process.env.MONERIUM_CONTRACT_ORDER_REQUEST_JSON)( + "POST /orders accepts a freshly signed sandbox redemption", + async () => { + assertSandboxMutationTarget(); + const request = moneriumRedeemOrderRequestSchema.parse( + JSON.parse(process.env.MONERIUM_CONTRACT_ORDER_REQUEST_JSON as string) + ) as MoneriumRedeemOrderRequest; + const result = await runLive("monerium createRedemptionOrder", () => api().createRedemptionOrder(request)); + if (result?.httpStatus === 200) moneriumOrderSchema.parse(result.order); + if (result) expect([200, 202]).toContain(result.httpStatus); + } + ); + + test.skipIf(!RUN_FILE_UPLOAD)("POST /files accepts the documented multipart contract", async () => { + assertSandboxMutationTarget(); + const pdf = new Blob(["%PDF-1.4\n%%EOF\n"], { type: "application/pdf" }); + const uploaded = await runLive("monerium uploadFile", () => api().uploadFile(pdf, "vortex-contract-test.pdf")); + if (uploaded) moneriumUploadedFileSchema.parse(uploaded); + }); + + test.skipIf( + !RUN_WEBHOOK_FLOW || !process.env.MONERIUM_CONTRACT_WEBHOOK_URL || !process.env.MONERIUM_CONTRACT_WEBHOOK_SECRET + )("POST + GET + PATCH /webhooks create and deactivate a subscription", async () => { + assertSandboxMutationTarget(); + let subscriptionId: string | undefined; + try { + const created = await runLive("monerium createWebhook", () => + api().createWebhook({ + secret: process.env.MONERIUM_CONTRACT_WEBHOOK_SECRET as string, + types: ["profile.updated", "profile.error", "iban.updated", "order.created", "order.updated"], + url: process.env.MONERIUM_CONTRACT_WEBHOOK_URL as string + }) + ); + if (!created) return; + subscriptionId = moneriumWebhookSubscriptionSchema.parse(created).id; + + const subscriptions = await runLive("monerium listWebhooks", () => api().listWebhooks()); + if (subscriptions) { + const parsed = moneriumListWebhooksResponseSchema.parse(subscriptions); + expect(parsed.subscriptions.map(subscription => subscription.id)).toContain(subscriptionId); + } + } finally { + if (subscriptionId) { + await runLive("monerium deactivateWebhook", () => api().updateWebhook(subscriptionId as string, { state: "inactive" })); + } + } + }); +}); + +// Monerium is not in contracts.yml until sandbox white-label credentials and fixtures are provisioned. +test.skipIf(!RUN_LIVE || !HAS_CREDS)("live contract coverage actually ran", () => { + assertLiveCoverage(); +}); diff --git a/docs/README.md b/docs/README.md index 0e5bdc28e..c883dca9c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md) | Current end-to-end architecture of the B2B EUR onramp: onboarding, deposit-to-payout, batching, fees, data model | | [`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-interface.md`](operations-monerium-interface.md) | Focused white-label Monerium 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 | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | diff --git a/docs/operations-monerium-interface.md b/docs/operations-monerium-interface.md new file mode 100644 index 000000000..67b77fab7 --- /dev/null +++ b/docs/operations-monerium-interface.md @@ -0,0 +1,128 @@ +# Monerium Interface + +## Monerium White-Label API + +All calls are server-to-server using `client_credentials`; users remain entirely within Vortex. ([Whitelabel: Authentication](https://docs.monerium.com/whitelabel#authentication)) + +The initial Vortex transport is `packages/shared/src/services/monerium/moneriumApiService.ts`. +It establishes the sole Monerium integration baseline but does not by itself re-enable EUR ramp +registration or settlement. It caches client-credential tokens in memory, requests API v2, retries +once after `401`, and applies a 10-second timeout to every call. Credentials use +`MONERIUM_WHITELABEL_CLIENT_ID` and `MONERIUM_WHITELABEL_CLIENT_SECRET`. + +| Operation | Endpoint / sequence | Commentary | Source | +|---|---|---|---| +| Authenticate | `POST /auth/token` | Send `grant_type=client_credentials`, `client_id`, `client_secret`. Token expires after 1 hour. | [Whitelabel: Authentication](https://docs.monerium.com/whitelabel#authentication) | +| Check user status | `GET /profiles/{profileId}` | Returns top-level state: `created`, `incomplete`, `pending`, `approved`, or `rejected`. | [API: Profile](https://docs.monerium.com/api/#tag/profiles/operation/profile) | +| Check KYC/KYB status | `GET /profiles/{profileId}` | Inspect `state`, `details.state`, `form.state`, and each `verifications[].state`. This is the relevant KYC/KYB status interface. | [API: Profile](https://docs.monerium.com/api/#tag/profiles/operation/profile) | +| List/search users | `GET /profiles?state=&kind=` | Only documented filters are `state` and `kind` (`personal`/`corporate`). No email, IBAN, name, or address filter. | [API: Profiles](https://docs.monerium.com/api/#tag/profiles/operation/profiles) | +| Find user by IBAN | `GET /ibans/{iban}` then `GET /profiles/{profileId}` | The IBAN response contains its owning `profile` UUID. | [API: IBAN](https://docs.monerium.com/api/#tag/ibans/operation/iban) | +| Find user by address | `GET /addresses/{address}` then `GET /profiles/{profileId}` | Address response contains the owning `profile` UUID. | [API: Address](https://docs.monerium.com/api/#tag/addresses/operation/address) | +| Get user information | `GET /profiles/{profileId}` | Returns profile identity, type, name, and compliance states. It does **not** expose the submitted personal/corporate details such as email or address. | [API: Profile](https://docs.monerium.com/api/#tag/profiles/operation/profile) | +| Monitor status changes | `profile.updated` webhook | Preferred over polling. `profile.error` is opt-in and reports rejected ingestion fields. | [Whitelabel: Monitor approval](https://docs.monerium.com/whitelabel#5-monitor-approval), [Whitelabel: Event types](https://docs.monerium.com/whitelabel#event-types) | + +## Connected Addresses + +| Operation | Endpoint / sequence | Commentary | Source | +|---|---|---|---| +| List all addresses | `GET /addresses?profile={profileId}` | Returns every address and its connected `chains[]`. Optional `chain` filter. | [API: Addresses](https://docs.monerium.com/api/#tag/addresses/operation/addresses) | +| Inspect one address | `GET /addresses/{address}` | Returns owner profile and connected chains. | [API: Address](https://docs.monerium.com/api/#tag/addresses/operation/address) | +| Connect address | `POST /addresses` | Submit `profile`, `address`, `chain`, fixed message, and ownership signature. | [Whitelabel: Link wallet](https://docs.monerium.com/whitelabel#link-wallet) | +| Ownership message | `I hereby declare that I am the address owner.` | Must be exact. EOA uses a 65-byte signature. Smart wallets use EIP-1271, either off-chain signatures or on-chain approval. | [Whitelabel: Link wallet](https://docs.monerium.com/whitelabel#link-wallet), [Whitelabel: EIP-1271](https://docs.monerium.com/whitelabel#eip-1271) | +| Change address | Link the new address, then `PATCH /ibans/{iban}` | There is no documented address update, reassignment, unlink, or delete endpoint. The old address remains connected. | [API: Addresses](https://docs.monerium.com/api/#tag/addresses), [Whitelabel: Move an IBAN](https://docs.monerium.com/whitelabel#move-an-iban) | +| Determine default address | `GET /ibans?profile={profileId}` | “Default” belongs to the IBAN: its `address` and `chain` are the default mint destination. There is no profile-level default-address field. | [API: IBANs](https://docs.monerium.com/api/#tag/ibans/operation/ibans), [Whitelabel: Incoming payments](https://docs.monerium.com/whitelabel#incoming-payments) | +| Change default destination | `PATCH /ibans/{iban}` with `{address, chain}` | Future incoming payments mint to the new destination. The destination must be linked to the profile. | [Whitelabel: Move an IBAN](https://docs.monerium.com/whitelabel#move-an-iban) | + +### Off-chain EIP-1271 ownership + +Off-chain EIP-1271 uses the same `POST /addresses` operation; there is no additional Vortex or +Monerium endpoint. Wallet owners collect signatures externally over the exact ownership message, +then assemble the contract-specific combined signature bytes. Vortex sends those bytes unchanged +in `signature`. Monerium immediately calls `isValidSignature(messageHash, signature)` and links the +address with `201` when the contract returns the EIP-1271 magic value. + +The public documentation demonstrates Safe's `createMessage`, `SigningMethod.ETH_SIGN`, and +`buildSignatureBytes` flow, but does not specify a generic byte-level `messageHash` derivation for +arbitrary smart wallets. The shared client therefore must not hash, split, recover, reorder, or +otherwise reinterpret the combined signature. Signature assembly remains the wallet owners' or +wallet integration's responsibility. + +For contrast, the on-chain EIP-1271 path submits `"0x"` and returns `202` while Monerium polls for +the on-chain approval. Vortex's intended integration is the immediate off-chain path, while the +client preserves both documented response semantics. + +## On-Ramp: SEPA to EURe + +1. Confirm `GET /profiles/{profileId}` returns `approved`. ([API: Profile](https://docs.monerium.com/api/#tag/profiles/operation/profile)) +2. List or connect an address using `GET/POST /addresses`. ([Whitelabel: Link wallet](https://docs.monerium.com/whitelabel#link-wallet), [API: Addresses](https://docs.monerium.com/api/#tag/addresses/operation/addresses)) +3. Retrieve existing IBAN using `GET /ibans?profile={profileId}`. ([API: IBANs](https://docs.monerium.com/api/#tag/ibans/operation/ibans)) +4. If none exists, call `POST /ibans` with `{address, chain}`. ([Whitelabel: Request IBAN](https://docs.monerium.com/whitelabel#request-iban)) +5. Wait for `iban.updated`; provisioning is asynchronous. ([Whitelabel: Retrieve the IBAN](https://docs.monerium.com/whitelabel#retrieve-the-iban)) +6. Give the IBAN to the user. ([Whitelabel: EUR IBAN](https://docs.monerium.com/whitelabel#eur-iban)) +7. Incoming SEPA funds automatically create an `issue` order and mint EURe to the IBAN’s linked address. ([Whitelabel: Incoming payments](https://docs.monerium.com/whitelabel#incoming-payments)) +8. Monitor `order.created` and `order.updated`. ([Whitelabel: Monitor orders](https://docs.monerium.com/whitelabel#monitor-orders)) + +No API call starts an incoming payment. It is passive. ([Whitelabel: Incoming payments](https://docs.monerium.com/whitelabel#incoming-payments)) + +A sender can override the destination for one payment using memo: ([Whitelabel: Routing with memo](https://docs.monerium.com/whitelabel#routing-with-memo)) + +```text +: +``` + +The address must already be linked to that profile. ([Whitelabel: Routing with memo](https://docs.monerium.com/whitelabel#routing-with-memo)) + +## Off-Ramp: EURe to SEPA + +1. Ensure the source wallet is linked and holds EURe. ([Whitelabel: SEPA payment](https://docs.monerium.com/whitelabel#sepa-payment)) +2. Construct and sign: ([Whitelabel: Signing an order](https://docs.monerium.com/whitelabel#signing-an-order)) + +```text +Send EUR to at +``` + +3. Call `POST /orders` with: ([Whitelabel: SEPA payment](https://docs.monerium.com/whitelabel#sepa-payment)) + - `kind: "redeem"` + - source `address` and `chain` + - `currency: "eur"` and `amount` + - recipient IBAN and individual/company details + - exact `message` and `signature` +4. Monitor `order.updated` until `processed` or `rejected`. ([Whitelabel: Monitor orders](https://docs.monerium.com/whitelabel#monitor-orders)) +5. For amounts of €15,000 or more, first upload supporting evidence using `POST /files` and provide `supportingDocumentId`. ([Whitelabel: SEPA payment](https://docs.monerium.com/whitelabel#sepa-payment)) + +The signed message may contain either the full IBAN or Monerium's deterministic shortened form +(`EE52...1285`, first four and last four characters). The request counterpart always contains the +full normalized IBAN. + +## Quoting + +There is **no documented quote endpoint for standard EUR on-ramp or SEPA off-ramp orders**. ([API: Orders](https://docs.monerium.com/api/#tag/orders), [Swap: Get a quote](https://docs.monerium.com/swap#get-a-quote)) + +Monerium has `GET /swap/{chain}/{sellToken}/{buyToken}` and `POST /swap/accept`, but this is a separate preview token-swap feature, currently documented for sandbox USDC/EURe on Arbitrum Sepolia. It should not be treated as the on/off-ramp quote API. ([Swap: Preview configuration](https://docs.monerium.com/swap#preview-configuration), [Swap: Get a quote](https://docs.monerium.com/swap#get-a-quote), [Swap: Accept the quote](https://docs.monerium.com/swap#accept-the-quote)) + +## Required Webhooks + +Register with `POST /webhooks`: ([Whitelabel: Webhooks](https://docs.monerium.com/whitelabel#webhooks)) + +| Event | Purpose | Source | +|---|---|---| +| `profile.updated` | KYC/KYB status changes | [API: Profile updated webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-profile-updated) | +| `profile.error` | Invalid submitted profile fields | [API: Profile error webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-profile-error) | +| `iban.updated` | IBAN provisioned or moved | [API: IBAN updated webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-iban-updated) | +| `order.created` | Incoming payment detected | [API: Order created webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-order-created) | +| `order.updated` | Payment processed or rejected | [API: Order updated webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-order-updated) | + +The shared client also maps `GET /webhooks` and `PATCH /webhooks/{subscription}` so contract tests +and operations can inspect and deactivate subscriptions. The current API does not document a +webhook delete operation. + +## Contract Tests + +`apps/api/src/tests/contracts/monerium.contract.test.ts` validates the consumed request and +response schemas hermetically on every run. `RUN_LIVE_TESTS=1` enables the prepared sandbox checks. +Read-only checks can list profiles, addresses, IBANs, and orders; fixture IDs enable corresponding +single-resource reads. Every mutating flow has a separate `MONERIUM_CONTRACT_RUN_*` gate because it +creates persistent sandbox state or, for an order, can move sandbox EURe. Monerium is not added to +the nightly workflow until white-label sandbox credentials and known fixtures are provisioned. + +Sources: [White-label guide](https://docs.monerium.com/whitelabel), [API reference](https://docs.monerium.com/api). diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 4ea5fd321..2246480c4 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -278,7 +278,11 @@ live; the status endpoint only hermetically — it needs a real recent transacti order creation/polling, fiat accounts and KYC status live behind pre-provisioned sandbox fixtures, see `.env.example`), Avenia/BRLA (quotes live with credentials only; limits/balances/account-info, pix-key validation, ordinary and hosted-liveness document target creation plus read-back through the consumed document GET schemas, and PIX pay-in ticket creation/listing behind a sandbox subaccount fixture; -payout tickets hermetically only — creating one live would move funds), and the CoinGecko +payout tickets hermetically only — creating one live would move funds), Monerium white-label API v2 +(profiles, linked addresses, IBANs, orders, files, and webhook subscriptions hermetically; prepared +read-only sandbox checks behind white-label credentials and fixture IDs; every persistent or +value-moving call behind its own `MONERIUM_CONTRACT_RUN_*` gate; excluded from `contracts.yml` +until credentials and known fixtures are provisioned), and the CoinGecko `simple/price` feed (schema in `apps/api/src/api/services/priceFeed.schemas.ts` — the price fake patches above the HTTP seam, so its hermetic half is fixture-based). Client methods with no production consumers are deliberately uncovered. Next per the PRD: milestone 5, warn-only diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 1d0212dcb..a760ae256 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -1,8 +1,66 @@ # Monerium Integration -> **Superseded for the B2B onramp:** the whitelabel/attestor/webhook integration is specified in [monerium-b2b.md](./monerium-b2b.md); this file covers only the legacy consumer OAuth onboarding flow. +> **B2B onramp:** the whitelabel attestor/webhook onramp is specified in [monerium-b2b.md](./monerium-b2b.md); it consumes the shared white-label client specified below. This file covers the shared white-label API client and the legacy consumer OAuth onboarding flow. -## What This Does +## White-Label API Client (`@vortexfi/shared`) + +### What This Does + +`@vortexfi/shared` provides a server-to-server Monerium white-label API client authenticated with +the `client_credentials` grant. It maps profile status, linked addresses, IBAN provisioning and +movement, EURe redemption orders, supporting-document uploads, and webhook subscriptions. Users +interact only with Vortex; all Monerium credentials, tokens, and API calls remain backend-only. + +This client establishes the integration baseline for API-managed EU KYC/KYB, wallet ownership, +IBANs, and SEPA/EURe payments. Its only consumer today is the Monerium B2B onramp +([monerium-b2b.md](./monerium-b2b.md)), which wraps it in its own audited orchestration. It is not +connected to a public Vortex route or ramp phase, and EUR ramp registration remains disabled until +that orchestration and its tests are implemented. + +### Security Invariants + +1. White-label credentials MUST use `MONERIUM_WHITELABEL_CLIENT_ID` and `MONERIUM_WHITELABEL_CLIENT_SECRET`, remain backend-only, and never be accepted from caller input. +2. `MONERIUM_API_URL` MUST use HTTPS. Every authenticated call MUST request API v2, encode dynamic path/query values, and use an explicit 10-second timeout. +3. Authentication MUST send form-encoded `client_credentials`. Access tokens MUST be cached only in memory, coalesced across concurrent requests, renewed before expiry, and reacquired at most once after `401`. +4. Client secrets, access tokens, signatures, request bodies, and raw provider response bodies MUST NOT appear in logs, structured errors, or Vortex API responses. Error endpoint fields MUST use route templates rather than customer identifiers. +5. Successful provider responses MUST be validated against consumed wire schemas. Malformed successful responses MUST surface as contract violations, not trusted typed values or provider-availability errors. +6. Profile kinds and states MUST preserve Monerium's documented values. A profile UUID MUST remain bound to the correct Vortex legal entity when orchestration is added. +7. The wallet-ownership message MUST remain exactly `I hereby declare that I am the address owner.` Callers SHOULD obtain it through `buildMoneriumWalletLinkMessage`. +8. EOA signatures and off-chain EIP-1271 combined signature bytes MUST be sent unchanged. Vortex MUST NOT hash, split, recover, reorder, or assemble smart-wallet owner signatures. The wallet integration owns signature assembly; Monerium owns `isValidSignature` verification. +9. Address results MUST preserve `201` immediate success and `202` pending on-chain verification. IBAN creation MUST preserve `202` provisioning and `304` already-provisioned semantics. Order creation MUST preserve `200` placed and `202` pending semantics. +10. SEPA redemption messages MUST bind currency, exact amount, recipient IBAN, and an RFC3339 minute timestamp no more than five minutes old. Only the full normalized IBAN or its deterministic first-four/last-four shortened form is valid. +11. Redemption orders of EUR 15,000 or more MUST include `supportingDocumentId`. Uploads MUST remain PDF/JPEG, at most 5 MB, with filenames no longer than 100 characters. +12. Webhook subscription secrets MUST contain 24-64 random bytes encoded as documented, callback URLs MUST use HTTPS, and event types MUST stay within the consumed Monerium enum. +13. Live contract mutations MUST target exactly `https://api.monerium.dev` and remain independently opt-in. An order contract test MUST NOT run from credentials alone because it can move sandbox EURe. +14. No public route or ramp phase may rely on this client until ownership checks, persistence, webhook verification, idempotency, and end-to-end corridor tests are implemented. The Monerium B2B onramp ([monerium-b2b.md](./monerium-b2b.md)) consumes this client for its provider calls through its own audited orchestration (attestor-signed linking, HMAC-verified webhooks, exactly-once financial operations). + +### Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| White-label credential disclosure | A provider error echoes a secret, token, signature, or profile data | The client never logs bodies and replaces upstream/transport response bodies with a fixed redacted value | +| Token stampede | Concurrent requests receive a delayed `401` and repeatedly request tokens | Token acquisition is coalesced and a rejected token is cleared only if it is still the active cached token | +| Provider hangs | Monerium does not respond | Every provider fetch has an explicit 10-second abort timeout | +| Provider contract drift | Monerium renames a consumed field or changes an enum/status body | Runtime schemas reject malformed successes and the API contract suite exercises the same schemas | +| Smart-wallet proof corruption | Vortex hashes or reassembles Safe owner signatures differently from the wallet contract | Combined off-chain EIP-1271 bytes are opaque; the exact fixed message and hex-byte envelope are validated, then sent unchanged | +| Signed-order substitution | Amount, IBAN, or timestamp differs between the signature and submitted order | Request validation binds the exact documented message to the request fields before transmission | +| Production test mutation | A live contract check links a wallet or submits an order against real money | Every mutation asserts the exact sandbox origin and requires its own explicit run flag | +| Accidental contract-test settlement | A routine live check submits a signed redemption | Every persistent or value-moving sandbox flow has its own explicit `MONERIUM_CONTRACT_RUN_*` gate | + +### Audit Checklist + +- [x] White-label authentication uses form-encoded `client_credentials`; access tokens are coalesced, memory-only, and retried once after `401`. +- [x] White-label requests use API v2, encoded parameters, 10-second abort signals, and redacted structured provider errors. +- [x] Successful profile, address, IBAN, order, file, and webhook responses are validated before return. +- [x] Address linking preserves externally assembled EIP-1271 signature bytes and the documented `201`/`202` distinction. +- [x] IBAN and order methods preserve documented `304`/`202` semantics; signed SEPA messages and the EUR 15,000 evidence threshold are validated before submission. +- [x] Monerium wire schemas have shared unit coverage and an environment-gated API sandbox contract suite; mutating probes are separately opt-in. +- [x] Contract-test mutations refuse production and non-root sandbox URLs. +- [ ] Public white-label onboarding and ramp orchestration are not implemented; ownership, persistence, webhook verification, idempotency, and corridor coverage remain required before exposure. + +## Legacy OAuth Onboarding (dashboard KYC/KYB) + +### What This Does The backend provides authenticated Monerium OAuth authorization-code endpoints for individual KYC and business KYB. It generates OAuth state and PKCE material server-side, exchanges codes directly with Monerium, keeps access and rotating refresh tokens only in backend memory, reads the authenticated Monerium context and API-v2 profile, and mirrors only normalized verification metadata into `provider_customers` and `kyc_cases`. @@ -10,7 +68,7 @@ The endpoints are `POST /v1/monerium/oauth/start`, `POST /v1/monerium/oauth/comp Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR recipient-eligibility provider. This change does not restore the historical Monerium EURe payment rail. EUR ramp registration remains disabled, and the dormant Mykobo settlement path must not be re-enabled until its separate Mykobo-profile gate is reconciled with Monerium identity. -## Security Invariants +### Security Invariants 1. OAuth state and the PKCE verifier MUST be generated with a cryptographically secure random source on the backend. 2. Each OAuth transaction MUST expire after 10 minutes and be bound to the authenticated user, customer entity, customer type, and configured redirect URI. @@ -33,7 +91,7 @@ Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR rec 19. Admin impersonation MUST NOT start or complete Monerium OAuth. `GET /status` remains available so an operator can inspect the target's persisted verification state. 20. Managed-profile selection is unsupported on these legacy routes. `X-Managed-Profile-Id` is ignored and every operation remains scoped to the Supabase-authenticated manager. Managed clients MUST NOT send the selector; the dashboard omits it and disables Monerium actions in child mode. -## Threat Vectors & Mitigations +### Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| @@ -48,7 +106,7 @@ Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR rec | Wrong profile association | A context contains multiple legal profiles | Requested customer type is enforced, the matching default is preferred, and ambiguous matches are rejected | | Different Monerium login | A user ignores the prefilled email and authorizes a different Monerium account or profile | The callback matches `/auth/context.email` to the authenticated Vortex email and rejects replacement of an existing Monerium profile ID | -## Audit Checklist +### Audit Checklist - [x] All three Monerium endpoints require Supabase authentication. - [x] State and PKCE are generated server-side with `crypto.randomBytes`; S256 is used. diff --git a/docs/security-spec/05-integrations/mykobo.md b/docs/security-spec/05-integrations/mykobo.md index c3fda84c0..321dbfd68 100644 --- a/docs/security-spec/05-integrations/mykobo.md +++ b/docs/security-spec/05-integrations/mykobo.md @@ -11,7 +11,7 @@ Monerium now owns EU dashboard KYC/KYB and recipient onboarding eligibility; Myk Mykobo replaces two earlier EUR rails: - The **Stellar SEP-24 EUR off-ramp** (Mykobo anchor reached via Spacewalk) — removed; Stellar/Spacewalk support was fully removed from the platform (migration 028). -- The legacy **Monerium EUR on-ramp** (Monerium EURe minted on Moonbeam) — removed. The new Monerium OAuth onboarding flow is separate and does not restore that settlement path; see `monerium.md`. +- The legacy **Monerium EUR on-ramp** (Monerium EURe minted on Moonbeam) — removed. The white-label reintegration is a new API-managed baseline and does not restore that settlement path by itself; see `monerium.md`. **Provider type:** Both (on-ramp and off-ramp) **Fiat currency:** EUR (Euro, SEPA) diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 31abfde68..bae47ad9a 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -60,7 +60,7 @@ documents win. | Integration Template | `05-integrations/_template.md` | Template for new provider specs | | BRLA | `05-integrations/brla.md` | BRLA anchor for BRL on/off-ramp | | Mykobo | `05-integrations/mykobo.md` | Mykobo EUR on/off-ramp on Base (currently registration-gated) | -| Monerium | `05-integrations/monerium.md` | Server-side OAuth KYC/KYB and verification status mirroring | +| Monerium | `05-integrations/monerium.md` | Server-to-server white-label API client plus the legacy OAuth KYC/KYB and verification status mirroring | | Monerium B2B | `05-integrations/monerium-b2b.md` | Whitelabel onramp: attestor address linking, HMAC webhook + durable inbox, forward-only deposits | | Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | | Binance | `05-integrations/binance.md` | Binance USDT spot price used as the primary USD<>BRL rate source | @@ -109,7 +109,7 @@ Most module specifications use these sections: | **XCM** | Cross-Consensus Messaging — the cross-chain transfer protocol between Polkadot parachains | | **BRLA** | Brazilian Real stablecoin anchor (BRL on/off-ramp) | | **Mykobo** | EUR fiat anchor for SEPA on/off-ramp on Base (settles EURC on Base; currently registration-gated) | -| **Monerium** | European e-money provider used for OAuth-based KYC/KYB verification and EUR profile status. | +| **Monerium** | European e-money provider integrated through the white-label API for KYC/KYB, IBANs, and EUR payments. | | **Alfredpay** | Fiat payment provider supporting multiple currencies | | **Binance** | Crypto exchange whose USDT/fiat spot ticker is the primary USD-to-fiat rate source for currencies with a liquid market (currently BRL via `USDTBRL`) | | **FastForex** | Fiat exchange-rate provider used as the USD-to-fiat rate source for currencies without a Binance market, and the fallback after Binance for those that have one | diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5f2231413..39979d459 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -23,3 +23,6 @@ export const MYKOBO_ACCESS_KEY = getEnvVar("MYKOBO_ACCESS_KEY"); export const MYKOBO_SECRET_KEY = getEnvVar("MYKOBO_SECRET_KEY"); // Optional. Mykobo defaults the fee scope to `.mykobo.app` when omitted. export const MYKOBO_CLIENT_DOMAIN = getEnvVar("MYKOBO_CLIENT_DOMAIN"); + +export const MONERIUM_API_URL = + getEnvVar("MONERIUM_API_URL") || (SANDBOX_ENABLED ? "https://api.monerium.dev" : "https://api.monerium.app"); diff --git a/packages/shared/src/services/index.ts b/packages/shared/src/services/index.ts index 58fa879ed..dcc80336c 100644 --- a/packages/shared/src/services/index.ts +++ b/packages/shared/src/services/index.ts @@ -2,6 +2,7 @@ export * from "../contracts"; export * from "./alfredpay"; export * from "./brla"; export * from "./evm"; +export * from "./monerium"; export * from "./mykobo"; export * from "./nabla"; export * from "./pendulum"; diff --git a/packages/shared/src/services/monerium/index.ts b/packages/shared/src/services/monerium/index.ts new file mode 100644 index 000000000..b41341e19 --- /dev/null +++ b/packages/shared/src/services/monerium/index.ts @@ -0,0 +1,3 @@ +export * from "./moneriumApiService"; +export * from "./schemas"; +export * from "./types"; diff --git a/packages/shared/src/services/monerium/moneriumApiService.test.ts b/packages/shared/src/services/monerium/moneriumApiService.test.ts new file mode 100644 index 000000000..eb134acbc --- /dev/null +++ b/packages/shared/src/services/monerium/moneriumApiService.test.ts @@ -0,0 +1,333 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + buildMoneriumSepaRedemptionMessage, + buildMoneriumWalletLinkMessage, + MoneriumApiError, + MoneriumApiService, + MoneriumContractError, + MONERIUM_REQUEST_TIMEOUT_MS +} from "./moneriumApiService"; +import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE } from "./types"; + +const realFetch = globalThis.fetch; +const PROFILE_ID = "123e4567-e89b-42d3-a456-426614174000"; +const ADDRESS = "0x59cFC408d310697f9D3598e1BE75B0157a072407"; +const IBAN = "EE521273842688571285"; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function service(): MoneriumApiService { + const instance = Object.create(MoneriumApiService.prototype) as MoneriumApiService; + Object.assign(instance, { + baseUrl: "https://api.monerium.dev", + clientId: "client-id", + clientSecret: "client-secret" + }); + return instance; +} + +function tokenResponse(token = "access-token"): Response { + return Response.json({ access_token: token, expires_in: 3600, token_type: "Bearer" }); +} + +function profileListResponse(): Response { + return Response.json({ + profiles: [{ id: PROFILE_ID, kind: "personal", name: "Jane Doe", state: "approved" }] + }); +} + +function redeemRequest() { + const timestamp = new Date(Date.now() + 60_000); + return { + address: ADDRESS, + amount: "100.00", + chain: "ethereum" as const, + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: IBAN, standard: "iban" as const } + }, + currency: "eur" as const, + kind: "redeem" as const, + message: buildMoneriumSepaRedemptionMessage("100.00", IBAN, timestamp), + signature: `0x${"ab".repeat(65)}` + }; +} + +describe("MoneriumApiService authentication and transport", () => { + test("builds the exact documented wallet-link message", () => { + expect(buildMoneriumWalletLinkMessage()).toBe("I hereby declare that I am the address owner."); + }); + + test("uses client_credentials, caches the token, and requests API v2", async () => { + const fetchMock = mock(async () => { + const call = fetchMock.mock.calls.length; + if (call === 1) return tokenResponse(); + return profileListResponse(); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const api = service(); + await api.listProfiles({ kind: "personal", state: "approved" }); + await api.listProfiles(); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const [authUrl, authOptions] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(authUrl).toBe("https://api.monerium.dev/auth/token"); + expect(authOptions.method).toBe("POST"); + expect(String(authOptions.body)).toBe("client_id=client-id&client_secret=client-secret&grant_type=client_credentials"); + expect(authOptions.headers).toEqual({ + Accept: "application/vnd.monerium.api-v2+json", + "Content-Type": "application/x-www-form-urlencoded" + }); + expect(authOptions.signal).toBeInstanceOf(AbortSignal); + + const [profilesUrl, profilesOptions] = fetchMock.mock.calls[1] as unknown as [string, RequestInit]; + expect(profilesUrl).toBe("https://api.monerium.dev/profiles?kind=personal&state=approved"); + expect(profilesOptions.headers).toEqual({ + Accept: "application/vnd.monerium.api-v2+json", + Authorization: "Bearer access-token" + }); + }); + + test("reacquires a token once after a 401", async () => { + const responses = [tokenResponse("token-1"), new Response(null, { status: 401 }), tokenResponse("token-2"), profileListResponse()]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(service().listProfiles()).resolves.toEqual({ + profiles: [{ id: PROFILE_ID, kind: "personal", name: "Jane Doe", state: "approved" }] + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect((fetchMock.mock.calls[1][1] as RequestInit).headers).toEqual( + expect.objectContaining({ Authorization: "Bearer token-1" }) + ); + expect((fetchMock.mock.calls[3][1] as RequestInit).headers).toEqual( + expect.objectContaining({ Authorization: "Bearer token-2" }) + ); + }); + + test("reuses a newer token when a delayed concurrent request returns 401", async () => { + let authCalls = 0; + const pendingTokenOneResponses: Array<(response: Response) => void> = []; + let tokenTwoRequests = 0; + const fetchMock = mock(async (input: string | URL | Request, options?: RequestInit) => { + if (String(input).endsWith("/auth/token")) { + authCalls += 1; + return tokenResponse(`token-${authCalls}`); + } + const authorization = (options?.headers as Record).Authorization; + if (authorization === "Bearer token-1") { + return await new Promise(resolve => pendingTokenOneResponses.push(resolve)); + } + tokenTwoRequests += 1; + return profileListResponse(); + }); + globalThis.fetch = fetchMock as typeof fetch; + const api = service(); + + const first = api.listProfiles(); + const second = api.listProfiles(); + while (pendingTokenOneResponses.length < 2) await new Promise(resolve => setTimeout(resolve, 0)); + + pendingTokenOneResponses[0](new Response(null, { status: 401 })); + while (tokenTwoRequests < 1) await new Promise(resolve => setTimeout(resolve, 0)); + pendingTokenOneResponses[1](new Response(null, { status: 401 })); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(authCalls).toBe(2); + expect(tokenTwoRequests).toBe(2); + }); + + test("rejects malformed successful responses at the provider boundary", async () => { + const responses = [tokenResponse(), Response.json({ profiles: [{ id: PROFILE_ID }] })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + await expect(service().listProfiles()).rejects.toBeInstanceOf(MoneriumContractError); + }); + + test("classifies malformed token JSON and accepted bodies as contract violations", async () => { + globalThis.fetch = mock(async () => new Response("not-json")) as typeof fetch; + await expect(service().listProfiles()).rejects.toBeInstanceOf(MoneriumContractError); + + const responses = [tokenResponse(), Response.json({ code: 202, status: "Pending" }, { status: 202 })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + await expect( + service().linkAddress({ + address: ADDRESS, + chain: "ethereum", + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature: "0x" + }) + ).rejects.toBeInstanceOf(MoneriumContractError); + }); + + test("redacts provider bodies and credentials from HTTP errors", async () => { + const sentinel = "SENTINEL-PROVIDER-PII"; + const responses = [tokenResponse(), new Response(sentinel, { status: 403 })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + + const error = await service() + .getProfile(PROFILE_ID) + .catch(value => value as MoneriumApiError); + expect(error).toBeInstanceOf(MoneriumApiError); + expect(error.status).toBe(403); + expect(error.responseBody).toBe("Sensitive provider response omitted"); + expect(error.endpoint).toBe("/profiles/:profile"); + expect(error.message).not.toContain(sentinel); + expect(error.message).not.toContain("client-secret"); + expect(JSON.stringify(error)).not.toContain(PROFILE_ID); + }); + + test("maps transport failures to status 0", async () => { + globalThis.fetch = mock(async () => { + throw new Error("connection reset"); + }) as typeof fetch; + + const error = await service() + .listProfiles() + .catch(value => value as MoneriumApiError); + expect(error).toBeInstanceOf(MoneriumApiError); + expect(error.status).toBe(0); + expect(MONERIUM_REQUEST_TIMEOUT_MS).toBe(10_000); + }); +}); + +describe("MoneriumApiService resource mappings", () => { + test("encodes address/profile paths and query parameters", async () => { + const responses = [ + tokenResponse(), + Response.json({ address: ADDRESS, chains: ["ethereum"], profile: PROFILE_ID }), + Response.json({ addresses: [] }) + ]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + const api = service(); + + await api.getAddress(`${ADDRESS}/suffix`); + await api.listAddresses({ chain: "ethereum", profile: "profile/id" }); + + expect(String(fetchMock.mock.calls[1][0])).toEndWith(`/addresses/${ADDRESS}%2Fsuffix`); + expect(String(fetchMock.mock.calls[2][0])).toBe( + "https://api.monerium.dev/addresses?chain=ethereum&profile=profile%2Fid" + ); + }); + + test("submits combined EIP-1271 bytes through the normal address endpoint", async () => { + const responses = [tokenResponse(), Response.json({}, { status: 201 })]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + const signature = `0x${"12".repeat(130)}`; + const request = { + address: ADDRESS, + chain: "ethereum" as const, + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature + }; + + await expect(service().linkAddress(request)).resolves.toEqual({ httpStatus: 201 }); + const [url, options] = fetchMock.mock.calls[1] as unknown as [string, RequestInit]; + expect(url).toBe("https://api.monerium.dev/addresses"); + expect(options.method).toBe("POST"); + expect(options.body).toBe(JSON.stringify(request)); + }); + + test("preserves the pending on-chain EIP-1271 status", async () => { + const responses = [tokenResponse(), Response.json({ code: 202, status: "Accepted" }, { status: 202 })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + + await expect( + service().linkAddress({ + address: ADDRESS, + chain: "ethereum", + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature: "0x" + }) + ).resolves.toEqual({ code: 202, httpStatus: 202, status: "Accepted" }); + }); + + test("treats an existing IBAN 304 as a documented result", async () => { + const responses = [tokenResponse(), new Response(null, { status: 304 })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + await expect(service().requestIban({ address: ADDRESS, chain: "ethereum" })).resolves.toEqual({ httpStatus: 304 }); + }); + + test("pins the exact redemption payload and accepted response", async () => { + const responses = [tokenResponse(), Response.json({ code: 202, status: "Accepted" }, { status: 202 })]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + const request = redeemRequest(); + + await expect(service().createRedemptionOrder(request)).resolves.toEqual({ + code: 202, + httpStatus: 202, + status: "Accepted" + }); + expect((fetchMock.mock.calls[1][1] as RequestInit).body).toBe(JSON.stringify(request)); + }); + + test("rejects an order whose body no longer matches its signed message", async () => { + globalThis.fetch = mock(async () => tokenResponse()) as typeof fetch; + await expect( + service().createRedemptionOrder({ ...redeemRequest(), amount: "101.00" }) + ).rejects.toThrow("message must exactly match"); + }); + + test("uploads a file under the documented multipart field without overriding its content type", async () => { + const uploaded = { + hash: "hash", + id: "223e4567-e89b-42d3-a456-426614174001", + meta: { + createdAt: "2026-08-25T12:00:00Z", + updatedAt: "2026-08-25T12:00:00Z", + uploadedBy: PROFILE_ID + }, + name: "evidence.pdf", + size: 4, + type: "application/pdf" + }; + const responses = [tokenResponse(), Response.json(uploaded)]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(service().uploadFile(new Blob(["test"], { type: "application/pdf" }), "evidence.pdf")).resolves.toEqual( + uploaded + ); + const options = fetchMock.mock.calls[1][1] as RequestInit; + expect(options.body).toBeInstanceOf(FormData); + expect((options.body as FormData).get("file")).toBeInstanceOf(File); + expect((options.headers as Record)["Content-Type"]).toBeUndefined(); + }); + + test("maps webhook creation and deactivation without sending unsupported fields", async () => { + const subscription = { + id: "223e4567-e89b-42d3-a456-426614174001", + state: "active", + types: ["profile.updated"], + url: "https://example.com/monerium" + }; + const responses = [ + tokenResponse(), + Response.json(subscription, { status: 201 }), + Response.json({ ...subscription, state: "inactive" }) + ]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + const api = service(); + const request = { + secret: `whsec_${btoa("a".repeat(32))}`, + types: ["profile.updated" as const], + url: "https://example.com/monerium" + }; + + await api.createWebhook(request); + await api.updateWebhook("subscription/id", { state: "inactive" }); + + expect((fetchMock.mock.calls[1][1] as RequestInit).body).toBe(JSON.stringify(request)); + expect(String(fetchMock.mock.calls[2][0])).toEndWith("/webhooks/subscription%2Fid"); + expect((fetchMock.mock.calls[2][1] as RequestInit).body).toBe(JSON.stringify({ state: "inactive" })); + }); +}); diff --git a/packages/shared/src/services/monerium/moneriumApiService.ts b/packages/shared/src/services/monerium/moneriumApiService.ts new file mode 100644 index 000000000..ad22074b9 --- /dev/null +++ b/packages/shared/src/services/monerium/moneriumApiService.ts @@ -0,0 +1,402 @@ +import type { ZodType } from "zod"; +import { MONERIUM_API_URL } from "../.."; +import { ProviderHttpError } from "../providerHttpError"; +import { + moneriumAcceptedResponseSchema, + moneriumAccessTokenResponseSchema, + moneriumAddressSchema, + moneriumCreateWebhookRequestSchema, + moneriumIbanDestinationRequestSchema, + moneriumIbanSchema, + moneriumLinkAddressRequestSchema, + moneriumListAddressesResponseSchema, + moneriumListIbansResponseSchema, + moneriumListOrdersResponseSchema, + moneriumListProfilesResponseSchema, + moneriumListWebhooksResponseSchema, + moneriumOrderSchema, + moneriumProfileSchema, + moneriumRedeemOrderRequestSchema, + moneriumUpdateWebhookRequestSchema, + moneriumUploadedFileSchema, + moneriumWebhookSubscriptionSchema +} from "./schemas"; +import { + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + type MoneriumAddress, + type MoneriumChain, + type MoneriumCreateOrderResult, + type MoneriumCreateWebhookRequest, + type MoneriumIban, + type MoneriumIbanDestinationRequest, + type MoneriumLinkAddressRequest, + type MoneriumLinkAddressResult, + type MoneriumListAddressesResponse, + type MoneriumListIbansResponse, + type MoneriumListOrdersResponse, + type MoneriumListProfilesResponse, + type MoneriumListWebhooksResponse, + type MoneriumOrder, + type MoneriumOrderFilterState, + type MoneriumProfile, + type MoneriumProfileKind, + type MoneriumProfileState, + type MoneriumRedeemOrderRequest, + type MoneriumRequestIbanResult, + type MoneriumUpdateWebhookRequest, + type MoneriumUploadedFile, + type MoneriumWebhookSubscription +} from "./types"; + +const API_V2_MEDIA_TYPE = "application/vnd.monerium.api-v2+json"; +const REDACTED_PROVIDER_RESPONSE = "Sensitive provider response omitted"; +const TOKEN_EXPIRY_SKEW_MS = 30_000; +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; +const ALLOWED_FILE_TYPES = new Set(["application/pdf", "image/jpeg"]); +export const MONERIUM_REQUEST_TIMEOUT_MS = 10_000; + +type HttpMethod = "GET" | "PATCH" | "POST"; + +interface CachedAccessToken { + expiresAt: number; + value: string; +} + +interface MoneriumHttpResult { + body: T; + status: number; +} + +export class MoneriumApiError extends ProviderHttpError { + constructor(params: { status: number; endpoint: string; method: string }) { + super({ ...params, provider: "monerium", responseBody: REDACTED_PROVIDER_RESPONSE }); + } +} + +export class MoneriumContractError extends Error { + public readonly providerContractViolation = true; + + constructor(operation: string) { + super(`Monerium returned an invalid successful response for ${operation}`); + this.name = "MoneriumContractError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** @see https://docs.monerium.com/whitelabel#link-wallet */ +export function buildMoneriumWalletLinkMessage(): typeof MONERIUM_ADDRESS_OWNERSHIP_MESSAGE { + return MONERIUM_ADDRESS_OWNERSHIP_MESSAGE; +} + +/** @see https://docs.monerium.com/whitelabel#signing-an-order */ +export function buildMoneriumSepaRedemptionMessage(amount: string, iban: string, timestamp: Date | string): string { + const minute = timestamp instanceof Date ? `${timestamp.toISOString().slice(0, 16)}Z` : timestamp; + return `Send EUR ${amount} to ${iban} at ${minute}`; +} + +export class MoneriumApiService { + private static instance: MoneriumApiService; + + private readonly baseUrl: string; + + private readonly clientId: string; + + private readonly clientSecret: string; + + private cachedToken: CachedAccessToken | undefined; + + private tokenPromise: Promise | undefined; + + private constructor() { + if (typeof window !== "undefined") { + throw new Error("MoneriumApiService is server-only"); + } + const clientId = process.env.MONERIUM_WHITELABEL_CLIENT_ID; + const clientSecret = process.env.MONERIUM_WHITELABEL_CLIENT_SECRET; + if (!clientId || !clientSecret) { + throw new Error("MONERIUM_WHITELABEL_CLIENT_ID or MONERIUM_WHITELABEL_CLIENT_SECRET not defined"); + } + this.baseUrl = MONERIUM_API_URL.replace(/\/$/, ""); + if (new URL(this.baseUrl).protocol !== "https:") { + throw new Error("MONERIUM_API_URL must use https://"); + } + this.clientId = clientId; + this.clientSecret = clientSecret; + } + + public static getInstance(): MoneriumApiService { + if (!MoneriumApiService.instance) { + MoneriumApiService.instance = new MoneriumApiService(); + } + return MoneriumApiService.instance; + } + + private async acquireToken(): Promise { + const endpoint = "/auth/token"; + const form = new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: "client_credentials" + }); + let response: Response; + try { + response = await fetch(`${this.baseUrl}${endpoint}`, { + body: form, + headers: { + Accept: API_V2_MEDIA_TYPE, + "Content-Type": "application/x-www-form-urlencoded" + }, + method: "POST", + signal: AbortSignal.timeout(MONERIUM_REQUEST_TIMEOUT_MS) + }); + } catch { + throw new MoneriumApiError({ endpoint, method: "POST", status: 0 }); + } + if (!response.ok) { + throw new MoneriumApiError({ endpoint, method: "POST", status: response.status }); + } + + let token; + try { + token = moneriumAccessTokenResponseSchema.parse(await this.readJson(response, endpoint, "POST")); + } catch { + throw new MoneriumContractError("POST /auth/token"); + } + return { + expiresAt: Date.now() + token.expires_in * 1_000, + value: token.access_token + }; + } + + private async getAccessToken(): Promise { + if (this.cachedToken && this.cachedToken.expiresAt - TOKEN_EXPIRY_SKEW_MS > Date.now()) { + return this.cachedToken.value; + } + if (!this.tokenPromise) { + this.tokenPromise = this.acquireToken().finally(() => { + this.tokenPromise = undefined; + }); + } + this.cachedToken = await this.tokenPromise; + return this.cachedToken.value; + } + + private buildUrl(path: string, query?: Record): string { + const url = new URL(`${this.baseUrl}${path}`); + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) url.searchParams.set(key, value); + } + return url.toString(); + } + + private async fetchAuthenticated( + path: string, + method: HttpMethod, + body?: unknown, + query?: Record + ): Promise { + const url = this.buildUrl(path, query); + const serializedBody = body === undefined || body instanceof FormData ? body : JSON.stringify(body); + let token = await this.getAccessToken(); + let response = await this.performFetch(url, path, method, token, serializedBody); + if (response.status === 401) { + if (this.cachedToken?.value === token) this.cachedToken = undefined; + token = await this.getAccessToken(); + response = await this.performFetch(url, path, method, token, serializedBody); + } + return response; + } + + private async performFetch( + url: string, + path: string, + method: HttpMethod, + token: string, + body: BodyInit | undefined + ): Promise { + const headers: Record = { + Accept: API_V2_MEDIA_TYPE, + Authorization: `Bearer ${token}` + }; + if (body !== undefined && !(body instanceof FormData)) headers["Content-Type"] = "application/json"; + + try { + return await fetch(url, { + body, + headers, + method, + signal: AbortSignal.timeout(MONERIUM_REQUEST_TIMEOUT_MS) + }); + } catch { + throw new MoneriumApiError({ endpoint: this.redactEndpoint(path), method, status: 0 }); + } + } + + private async request( + path: string, + method: HttpMethod, + options: { + acceptedStatuses?: number[]; + body?: unknown; + query?: Record; + } = {} + ): Promise> { + const response = await this.fetchAuthenticated(path, method, options.body, options.query); + const acceptedStatuses = options.acceptedStatuses ?? [200]; + if (!acceptedStatuses.includes(response.status)) { + throw new MoneriumApiError({ endpoint: this.redactEndpoint(path), method, status: response.status }); + } + return { + body: (response.status === 304 ? undefined : await this.readJson(response, path, method)) as T, + status: response.status + }; + } + + private async readJson(response: Response, endpoint: string, method: string): Promise { + const text = await response.text(); + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + throw new MoneriumContractError(`${method} ${this.redactEndpoint(endpoint)}`); + } + } + + private redactEndpoint(path: string): string { + return path + .replace(/^\/profiles\/[^/]+$/, "/profiles/:profile") + .replace(/^\/addresses\/[^/]+$/, "/addresses/:address") + .replace(/^\/ibans\/[^/]+$/, "/ibans/:iban") + .replace(/^\/orders\/[^/]+$/, "/orders/:order") + .replace(/^\/webhooks\/[^/]+$/, "/webhooks/:subscription"); + } + + private parseResponse(schema: ZodType, body: unknown, operation: string): T { + const result = schema.safeParse(body); + if (!result.success) throw new MoneriumContractError(operation); + return result.data; + } + + public async listProfiles(filters: { kind?: MoneriumProfileKind; state?: MoneriumProfileState } = {}) { + const response = await this.request("/profiles", "GET", { query: filters }); + return this.parseResponse(moneriumListProfilesResponseSchema, response.body, "GET /profiles"); + } + + public async getProfile(profileId: string): Promise { + const response = await this.request(`/profiles/${encodeURIComponent(profileId)}`, "GET"); + return this.parseResponse(moneriumProfileSchema, response.body, "GET /profiles/:profile"); + } + + public async listAddresses(filters: { chain?: MoneriumChain; profile?: string } = {}) { + const response = await this.request("/addresses", "GET", { query: filters }); + return this.parseResponse(moneriumListAddressesResponseSchema, response.body, "GET /addresses"); + } + + public async getAddress(address: string): Promise { + const response = await this.request(`/addresses/${encodeURIComponent(address)}`, "GET"); + return this.parseResponse(moneriumAddressSchema, response.body, "GET /addresses/:address"); + } + + public async linkAddress(request: MoneriumLinkAddressRequest): Promise { + const body = moneriumLinkAddressRequestSchema.parse(request); + const response = await this.request("/addresses", "POST", { + acceptedStatuses: [201, 202], + body + }); + if (response.status === 201) return { httpStatus: 201 }; + return { + httpStatus: 202, + ...this.parseResponse(moneriumAcceptedResponseSchema, response.body, "POST /addresses") + }; + } + + public async listIbans(filters: { chain?: MoneriumChain; profile?: string } = {}) { + const response = await this.request("/ibans", "GET", { query: filters }); + return this.parseResponse(moneriumListIbansResponseSchema, response.body, "GET /ibans"); + } + + public async getIban(iban: string): Promise { + const response = await this.request(`/ibans/${encodeURIComponent(iban)}`, "GET"); + return this.parseResponse(moneriumIbanSchema, response.body, "GET /ibans/:iban"); + } + + public async requestIban(request: MoneriumIbanDestinationRequest): Promise { + const body = moneriumIbanDestinationRequestSchema.parse(request); + const response = await this.request("/ibans", "POST", { + acceptedStatuses: [202, 304], + body + }); + return { httpStatus: response.status as 202 | 304 }; + } + + public async updateIbanDestination(iban: string, request: MoneriumIbanDestinationRequest): Promise { + const body = moneriumIbanDestinationRequestSchema.parse(request); + await this.request(`/ibans/${encodeURIComponent(iban)}`, "PATCH", { body }); + } + + public async listOrders( + filters: { address?: string; memo?: string; profile?: string; state?: MoneriumOrderFilterState; txHash?: string } = {} + ): Promise { + const response = await this.request("/orders", "GET", { query: filters }); + return this.parseResponse(moneriumListOrdersResponseSchema, response.body, "GET /orders"); + } + + public async getOrder(orderId: string): Promise { + const response = await this.request(`/orders/${encodeURIComponent(orderId)}`, "GET"); + return this.parseResponse(moneriumOrderSchema, response.body, "GET /orders/:order"); + } + + public async createRedemptionOrder(request: MoneriumRedeemOrderRequest): Promise { + const body = moneriumRedeemOrderRequestSchema.parse(request); + const response = await this.request("/orders", "POST", { + acceptedStatuses: [200, 202], + body + }); + if (response.status === 200) { + return { httpStatus: 200, order: this.parseResponse(moneriumOrderSchema, response.body, "POST /orders") }; + } + return { + httpStatus: 202, + ...this.parseResponse(moneriumAcceptedResponseSchema, response.body, "POST /orders") + }; + } + + public async uploadFile(file: Blob, fileName?: string): Promise { + const name = fileName ?? (file instanceof File ? file.name : "upload"); + if (name.length === 0 || name.length > 100) throw new Error("Monerium filenames must contain 1 to 100 characters"); + if (file.size > MAX_FILE_SIZE_BYTES) throw new Error("Monerium files must not exceed 5 MB"); + if (!ALLOWED_FILE_TYPES.has(file.type)) throw new Error("Monerium files must be PDF or JPEG"); + + const form = new FormData(); + form.append("file", file, name); + const response = await this.request("/files", "POST", { body: form }); + return this.parseResponse(moneriumUploadedFileSchema, response.body, "POST /files"); + } + + public async createWebhook(request: MoneriumCreateWebhookRequest): Promise { + const body = moneriumCreateWebhookRequestSchema.parse(request); + const response = await this.request("/webhooks", "POST", { + acceptedStatuses: [201], + body + }); + return this.parseResponse(moneriumWebhookSubscriptionSchema, response.body, "POST /webhooks"); + } + + public async listWebhooks(): Promise { + const response = await this.request("/webhooks", "GET"); + return this.parseResponse(moneriumListWebhooksResponseSchema, response.body, "GET /webhooks"); + } + + public async updateWebhook( + subscriptionId: string, + request: MoneriumUpdateWebhookRequest + ): Promise { + const body = moneriumUpdateWebhookRequestSchema.parse(request); + const response = await this.request( + `/webhooks/${encodeURIComponent(subscriptionId)}`, + "PATCH", + { body } + ); + return this.parseResponse(moneriumWebhookSubscriptionSchema, response.body, "PATCH /webhooks/:subscription"); + } +} diff --git a/packages/shared/src/services/monerium/schemas.test.ts b/packages/shared/src/services/monerium/schemas.test.ts new file mode 100644 index 000000000..f9ea717c0 --- /dev/null +++ b/packages/shared/src/services/monerium/schemas.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from "bun:test"; +import { + moneriumAccessTokenResponseSchema, + moneriumAddressSchema, + moneriumCreateWebhookRequestSchema, + moneriumIbanSchema, + moneriumLinkAddressRequestSchema, + moneriumListOrdersResponseSchema, + moneriumProfileSchema, + moneriumRedeemOrderRequestSchema, + moneriumUploadedFileSchema, + moneriumWebhookEventSchema +} from "./schemas"; +import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE } from "./types"; + +const PROFILE_ID = "123e4567-e89b-42d3-a456-426614174000"; +const RESOURCE_ID = "223e4567-e89b-42d3-a456-426614174001"; +const ADDRESS = "0x59cFC408d310697f9D3598e1BE75B0157a072407"; +const IBAN = "EE521273842688571285"; + +function profile() { + return { + details: { state: "approved" }, + form: { state: "approved" }, + id: PROFILE_ID, + kind: "personal", + name: "Jane Doe", + state: "approved", + unknownProviderField: true, + verifications: [{ kind: "idDocument", state: "approved" }] + }; +} + +function order() { + return { + address: ADDRESS, + amount: "100.00", + chain: "ethereum", + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: IBAN, standard: "iban" } + }, + currency: "eur", + id: RESOURCE_ID, + kind: "redeem", + memo: "Powered by Monerium", + meta: { placedAt: "2026-08-25T12:00:00Z" }, + profile: PROFILE_ID, + state: "placed" + }; +} + +function redeemRequest(amount = "100.00") { + const timestamp = `${new Date(Date.now() + 60_000).toISOString().slice(0, 16)}Z`; + return { + address: ADDRESS, + amount, + chain: "ethereum", + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: IBAN, standard: "iban" } + }, + currency: "eur", + kind: "redeem", + message: `Send EUR ${amount} to ${IBAN} at ${timestamp}`, + signature: `0x${"ab".repeat(65)}` + }; +} + +describe("Monerium profile and token schemas", () => { + test("accept documented responses with unknown fields", () => { + expect( + moneriumAccessTokenResponseSchema.safeParse({ + access_token: "access-token", + expires_in: 3600, + scope: "openid", + token_type: "Bearer" + }).success + ).toBe(true); + expect(moneriumProfileSchema.safeParse(profile()).success).toBe(true); + }); + + test("reject missing compliance state and unknown provider enums", () => { + const missingDetails = profile(); + delete (missingDetails as Record).details; + expect(moneriumProfileSchema.safeParse(missingDetails).success).toBe(false); + expect(moneriumProfileSchema.safeParse({ ...profile(), state: "suspended" }).success).toBe(false); + }); +}); + +describe("Monerium address and IBAN schemas", () => { + test("accepts combined off-chain EIP-1271 signature bytes as opaque hex", () => { + const combinedSignature = `0x${"12".repeat(130)}`; + expect( + moneriumLinkAddressRequestSchema.safeParse({ + address: ADDRESS, + chain: "ethereum", + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature: combinedSignature + }).success + ).toBe(true); + }); + + test("accepts the on-chain EIP-1271 marker but rejects altered messages and odd hex", () => { + const base = { + address: ADDRESS, + chain: "ethereum", + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature: "0x" + }; + expect(moneriumLinkAddressRequestSchema.safeParse(base).success).toBe(true); + expect(moneriumLinkAddressRequestSchema.safeParse({ ...base, message: `${base.message} ` }).success).toBe(false); + expect(moneriumLinkAddressRequestSchema.safeParse({ ...base, signature: "0x123" }).success).toBe(false); + }); + + test("validates linked address and IBAN response identifiers", () => { + expect(moneriumAddressSchema.safeParse({ address: ADDRESS, chains: ["ethereum"], profile: PROFILE_ID }).success).toBe( + true + ); + expect( + moneriumIbanSchema.safeParse({ + address: ADDRESS, + bic: "CBHFLU2LXXX", + chain: "ethereum", + iban: IBAN, + name: "Jane Doe", + profile: PROFILE_ID + }).success + ).toBe(true); + expect(moneriumIbanSchema.safeParse({ address: ADDRESS, iban: "not-an-iban" }).success).toBe(false); + }); +}); + +describe("Monerium order schemas", () => { + test("pins the exact signed SEPA message and decimal amount representation", () => { + const request = redeemRequest(); + expect(moneriumRedeemOrderRequestSchema.safeParse(request).success).toBe(true); + expect( + moneriumRedeemOrderRequestSchema.safeParse({ + ...request, + message: request.message.replace(IBAN, `${IBAN.slice(0, 4)}...${IBAN.slice(-4)}`) + }).success + ).toBe(true); + expect( + moneriumRedeemOrderRequestSchema.safeParse({ ...redeemRequest(), message: `Send EUR 100 to ${IBAN}` }).success + ).toBe(false); + expect(moneriumRedeemOrderRequestSchema.safeParse(redeemRequest("100.001")).success).toBe(false); + }); + + test("requires supporting evidence at the EUR 15,000 threshold", () => { + expect(moneriumRedeemOrderRequestSchema.safeParse(redeemRequest("14999.99")).success).toBe(true); + expect(moneriumRedeemOrderRequestSchema.safeParse(redeemRequest("15000.00")).success).toBe(false); + expect( + moneriumRedeemOrderRequestSchema.safeParse({ + ...redeemRequest("15000.00"), + supportingDocumentId: RESOURCE_ID + }).success + ).toBe(true); + }); + + test("accepts documented order envelopes and rejects provider state drift", () => { + expect(moneriumListOrdersResponseSchema.safeParse({ orders: [order()] }).success).toBe(true); + expect(moneriumListOrdersResponseSchema.safeParse({ orders: [{ ...order(), state: "settled" }] }).success).toBe(false); + }); +}); + +describe("Monerium file and webhook schemas", () => { + test("validates uploaded-file metadata", () => { + expect( + moneriumUploadedFileSchema.safeParse({ + hash: "sha256:abc", + id: RESOURCE_ID, + meta: { + createdAt: "2026-08-25T12:00:00Z", + updatedAt: "2026-08-25T12:00:00Z", + uploadedBy: PROFILE_ID + }, + name: "evidence.pdf", + size: 1234, + type: "application/pdf" + }).success + ).toBe(true); + }); + + test("requires HTTPS and a 24-64 byte webhook secret", () => { + const secret = `whsec_${btoa("a".repeat(32))}`; + expect(moneriumCreateWebhookRequestSchema.safeParse({ secret, url: "https://example.com/monerium" }).success).toBe( + true + ); + expect(moneriumCreateWebhookRequestSchema.safeParse({ secret: "whsec_dGlueQ==", url: "http://example.com" }).success).toBe( + false + ); + }); + + test("accepts partial profile webhook snapshots and rejects unknown event types", () => { + expect( + moneriumWebhookEventSchema.safeParse({ + data: { id: PROFILE_ID, kind: "personal", state: "approved" }, + timestamp: "2026-08-25T12:00:00Z", + type: "profile.updated" + }).success + ).toBe(true); + expect( + moneriumWebhookEventSchema.safeParse({ timestamp: "2026-08-25T12:00:00Z", type: "profile.deleted" }).success + ).toBe(false); + }); + + test("accepts the documented partial iban.updated snapshot", () => { + expect( + moneriumWebhookEventSchema.safeParse({ + data: { + address: ADDRESS, + chain: "ethereum", + iban: "EE52 1273 8426 8857 1285", + profile: PROFILE_ID, + state: "approved" + }, + timestamp: "2026-08-25T12:00:00Z", + type: "iban.updated" + }).success + ).toBe(true); + }); +}); diff --git a/packages/shared/src/services/monerium/schemas.ts b/packages/shared/src/services/monerium/schemas.ts new file mode 100644 index 000000000..7c565859c --- /dev/null +++ b/packages/shared/src/services/monerium/schemas.ts @@ -0,0 +1,320 @@ +import { z } from "zod"; +import { + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + MONERIUM_CHAINS, + MONERIUM_ORDER_FILTER_STATES, + MONERIUM_ORDER_STATES, + MONERIUM_PROFILE_KINDS, + MONERIUM_PROFILE_STATES, + MONERIUM_SECTION_STATES, + MONERIUM_VERIFICATION_KINDS, + MONERIUM_WEBHOOK_TYPES, + type MoneriumAccessTokenResponse, + type MoneriumAddress, + type MoneriumCreateWebhookRequest, + type MoneriumIban, + type MoneriumIbanDestinationRequest, + type MoneriumIbanUpdatedData, + type MoneriumLinkAddressRequest, + type MoneriumListAddressesResponse, + type MoneriumListIbansResponse, + type MoneriumListOrdersResponse, + type MoneriumListProfilesResponse, + type MoneriumListWebhooksResponse, + type MoneriumOrder, + type MoneriumProfile, + type MoneriumProfileSummary, + type MoneriumRedeemOrderRequest, + type MoneriumUpdateWebhookRequest, + type MoneriumUploadedFile, + type MoneriumWebhookEvent, + type MoneriumWebhookSubscription +} from "./types"; + +/** + * Monerium API v2 wire contracts consumed by Vortex. Unknown provider fields pass; + * missing or renamed consumed fields fail. Request schemas additionally pin the + * signing-message and threshold semantics that must agree with the submitted signature. + */ + +const UUID = z.string().uuid(); +const EVM_ADDRESS = z.string().regex(/^0x[0-9a-fA-F]{40}$/); +const HEX_BYTES = z.string().regex(/^0x(?:[0-9a-fA-F]{2})*$/); +const IBAN = z.string().regex(/^[A-Z]{2}[0-9A-Z ]{13,32}$/); +const NORMALIZED_IBAN = z.string().regex(/^[A-Z]{2}[0-9A-Z]{13,32}$/); +const DECIMAL_AMOUNT = z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/); +const COUNTRY_CODE = z.string().regex(/^[A-Z]{2}$/); +const RFC3339_MINUTE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/; + +const moneriumChainSchema = z.enum(MONERIUM_CHAINS); +const moneriumTimestampSchema = z.string().datetime({ offset: true }); + +export const moneriumAccessTokenResponseSchema = z.looseObject({ + access_token: z.string().min(1), + expires_in: z.number().int().positive(), + refresh_token: z.string().min(1).optional(), + token_type: z.string().min(1) +}) satisfies z.ZodType; + +export const moneriumProfileSummarySchema = z.looseObject({ + id: UUID, + kind: z.enum(MONERIUM_PROFILE_KINDS), + name: z.string().min(1), + state: z.enum(MONERIUM_PROFILE_STATES) +}) satisfies z.ZodType; + +export const moneriumProfileSchema = moneriumProfileSummarySchema.extend({ + details: z.looseObject({ state: z.enum(MONERIUM_SECTION_STATES) }), + form: z.looseObject({ state: z.enum(MONERIUM_SECTION_STATES) }), + verifications: z.array( + z.looseObject({ + kind: z.enum(MONERIUM_VERIFICATION_KINDS), + state: z.enum(MONERIUM_SECTION_STATES) + }) + ) +}) satisfies z.ZodType; + +export const moneriumListProfilesResponseSchema = z.looseObject({ + profiles: z.array(moneriumProfileSummarySchema) +}) satisfies z.ZodType; + +export const moneriumAddressSchema = z.looseObject({ + address: EVM_ADDRESS, + chains: z.array(moneriumChainSchema), + profile: UUID +}) satisfies z.ZodType; + +export const moneriumListAddressesResponseSchema = z.looseObject({ + addresses: z.array(moneriumAddressSchema) +}) satisfies z.ZodType; + +export const moneriumLinkAddressRequestSchema = z.looseObject({ + address: EVM_ADDRESS, + chain: moneriumChainSchema, + message: z.literal(MONERIUM_ADDRESS_OWNERSHIP_MESSAGE), + profile: UUID, + signature: HEX_BYTES +}) satisfies z.ZodType; + +export const moneriumAcceptedResponseSchema = z.looseObject({ + code: z.literal(202), + status: z.literal("Accepted") +}); + +export const moneriumIbanSchema = z.looseObject({ + address: EVM_ADDRESS, + bic: z.string().min(8).max(11), + chain: moneriumChainSchema, + iban: IBAN, + name: z.string().min(1), + profile: UUID +}) satisfies z.ZodType; + +export const moneriumListIbansResponseSchema = z.looseObject({ + ibans: z.array(moneriumIbanSchema) +}) satisfies z.ZodType; + +export const moneriumIbanDestinationRequestSchema = z.looseObject({ + address: EVM_ADDRESS, + chain: moneriumChainSchema +}) satisfies z.ZodType; + +const moneriumIbanIdentifierSchema = z.looseObject({ + iban: NORMALIZED_IBAN, + standard: z.literal("iban") +}); + +const moneriumChainIdentifierSchema = z.looseObject({ + address: EVM_ADDRESS, + chain: moneriumChainSchema, + standard: z.literal("chain") +}); + +const moneriumPersonalDetailsSchema = z.looseObject({ + country: COUNTRY_CODE, + firstName: z.string().min(1), + lastName: z.string().min(1) +}); + +const moneriumCorporateDetailsSchema = z.looseObject({ + companyName: z.string().min(1), + country: COUNTRY_CODE +}); + +function amountRequiresSupportingDocument(amount: string): boolean { + const integer = amount.split(".")[0].replace(/^0+(?=\d)/, ""); + return integer.length > 5 || (integer.length === 5 && integer >= "15000"); +} + +export const moneriumRedeemOrderRequestSchema = z + .looseObject({ + address: EVM_ADDRESS, + amount: DECIMAL_AMOUNT, + chain: moneriumChainSchema, + counterpart: z.looseObject({ + details: z.union([moneriumPersonalDetailsSchema, moneriumCorporateDetailsSchema]), + identifier: moneriumIbanIdentifierSchema + }), + currency: z.literal("eur"), + id: UUID.optional(), + kind: z.literal("redeem"), + memo: z.string().min(5).max(140).optional(), + message: z.string().min(1), + referenceNumber: z.string().max(35).optional(), + signature: HEX_BYTES, + supportingDocumentId: UUID.optional() + }) + .superRefine((request, context) => { + const iban = request.counterpart.identifier.iban; + const destinations = [iban, `${iban.slice(0, 4)}...${iban.slice(-4)}`]; + const prefix = destinations + .map(destination => `Send EUR ${request.amount} to ${destination} at `) + .find(value => request.message.startsWith(value)); + const timestamp = prefix ? request.message.slice(prefix.length) : ""; + const timestampMs = Date.parse(timestamp); + if (!prefix || !RFC3339_MINUTE.test(timestamp) || Number.isNaN(timestampMs)) { + context.addIssue({ + code: "custom", + message: "message must exactly match the Monerium SEPA signing format", + path: ["message"] + }); + } else if (timestampMs < Date.now() - 5 * 60 * 1_000) { + context.addIssue({ + code: "custom", + message: "message timestamp must be no more than five minutes in the past", + path: ["message"] + }); + } + if (amountRequiresSupportingDocument(request.amount) && !request.supportingDocumentId) { + context.addIssue({ + code: "custom", + message: "supportingDocumentId is required for amounts of EUR 15,000 or more", + path: ["supportingDocumentId"] + }); + } + }) satisfies z.ZodType; + +const moneriumOrderSchemaInternal = z.looseObject({ + address: EVM_ADDRESS, + amount: DECIMAL_AMOUNT, + chain: moneriumChainSchema, + counterpart: z.looseObject({ + details: z.looseObject({}).optional(), + identifier: z.union([ + moneriumIbanIdentifierSchema, + moneriumChainIdentifierSchema, + z.looseObject({ standard: z.string().min(1) }) + ]) + }), + currency: z.enum(["eur", "usd", "gbp", "isk"]), + id: UUID, + kind: z.enum(["issue", "redeem"]), + memo: z.string(), + meta: z.looseObject({ + placedAt: moneriumTimestampSchema, + processedAt: moneriumTimestampSchema.optional(), + rejectedReason: z.string().optional(), + supportingDocumentId: UUID.optional(), + txHashes: z.array(z.string().min(1)).optional() + }), + profile: UUID, + referenceNumber: z.string().optional(), + state: z.enum(MONERIUM_ORDER_STATES) +}); + +export const moneriumOrderSchema = moneriumOrderSchemaInternal satisfies z.ZodType; + +export const moneriumListOrdersResponseSchema = z.looseObject({ + orders: z.array(moneriumOrderSchema) +}) satisfies z.ZodType; + +export const moneriumOrderFilterStateSchema = z.enum(MONERIUM_ORDER_FILTER_STATES); + +export const moneriumUploadedFileSchema = z.looseObject({ + hash: z.string().min(1), + id: UUID, + meta: z.looseObject({ + createdAt: moneriumTimestampSchema, + updatedAt: moneriumTimestampSchema, + uploadedBy: z.string().min(1) + }), + name: z.string().min(1), + size: z.number().int().nonnegative(), + type: z.string().min(1) +}) satisfies z.ZodType; + +const moneriumWebhookSecretSchema = z + .string() + .regex(/^whsec_[A-Za-z0-9+/]+={0,2}$/) + .refine(secret => { + try { + const byteLength = atob(secret.slice("whsec_".length)).length; + return byteLength >= 24 && byteLength <= 64; + } catch { + return false; + } + }, "webhook secret must contain 24 to 64 base64-encoded random bytes"); + +export const moneriumCreateWebhookRequestSchema = z.looseObject({ + secret: moneriumWebhookSecretSchema, + types: z.array(z.enum(MONERIUM_WEBHOOK_TYPES)).optional(), + url: z.string().url().startsWith("https://") +}) satisfies z.ZodType; + +export const moneriumWebhookSubscriptionSchema = z.looseObject({ + id: UUID, + state: z.enum(["active", "inactive"]), + types: z.array(z.enum(MONERIUM_WEBHOOK_TYPES)), + url: z.string().url() +}) satisfies z.ZodType; + +export const moneriumListWebhooksResponseSchema = z.looseObject({ + subscriptions: z.array(moneriumWebhookSubscriptionSchema) +}) satisfies z.ZodType; + +export const moneriumUpdateWebhookRequestSchema = z + .looseObject({ + state: z.enum(["active", "inactive"]).optional(), + types: z.array(z.enum(MONERIUM_WEBHOOK_TYPES)).optional() + }) + .refine( + request => request.state !== undefined || request.types !== undefined, + "at least one update field is required" + ) satisfies z.ZodType; + +const moneriumWebhookProfileSchema = z.looseObject({ + id: UUID, + kind: z.enum(MONERIUM_PROFILE_KINDS), + state: z.enum(MONERIUM_PROFILE_STATES) +}); + +export const moneriumIbanUpdatedDataSchema = z.looseObject({ + address: EVM_ADDRESS, + bic: z.string().min(8).max(11).optional(), + chain: moneriumChainSchema, + iban: IBAN, + name: z.string().min(1).optional(), + profile: UUID, + state: z.string().min(1).optional() +}) satisfies z.ZodType; + +export const moneriumWebhookEventSchema = z.discriminatedUnion("type", [ + z.looseObject({ timestamp: moneriumTimestampSchema, type: z.literal("subscription.created") }), + z.looseObject({ data: moneriumOrderSchema, timestamp: moneriumTimestampSchema, type: z.literal("order.created") }), + z.looseObject({ data: moneriumOrderSchema, timestamp: moneriumTimestampSchema, type: z.literal("order.updated") }), + z.looseObject({ data: moneriumWebhookProfileSchema, timestamp: moneriumTimestampSchema, type: z.literal("profile.updated") }), + z.looseObject({ + data: z.looseObject({ + errors: z.array(z.looseObject({ field: z.string().min(1), reason: z.string().min(1) })), + id: UUID, + kind: z.enum(MONERIUM_PROFILE_KINDS) + }), + timestamp: moneriumTimestampSchema, + type: z.literal("profile.error") + }), + z.looseObject({ + data: moneriumIbanUpdatedDataSchema, + timestamp: moneriumTimestampSchema, + type: z.literal("iban.updated") + }) +]) satisfies z.ZodType; diff --git a/packages/shared/src/services/monerium/types.ts b/packages/shared/src/services/monerium/types.ts new file mode 100644 index 000000000..6b5cde829 --- /dev/null +++ b/packages/shared/src/services/monerium/types.ts @@ -0,0 +1,263 @@ +export const MONERIUM_PROFILE_STATES = ["created", "incomplete", "pending", "approved", "rejected"] as const; +export type MoneriumProfileState = (typeof MONERIUM_PROFILE_STATES)[number]; + +export const MONERIUM_PROFILE_KINDS = ["personal", "corporate"] as const; +export type MoneriumProfileKind = (typeof MONERIUM_PROFILE_KINDS)[number]; + +export const MONERIUM_SECTION_STATES = ["incomplete", "pending", "approved", "rejected"] as const; +export type MoneriumSectionState = (typeof MONERIUM_SECTION_STATES)[number]; + +export const MONERIUM_VERIFICATION_KINDS = [ + "idDocument", + "facialSimilarity", + "proofOfResidency", + "sourceOfFunds", + "corporateName", + "corporateAddress", + "registrationNumber", + "dateOfRegistration", + "beneficialOwnership", + "powerOfAttorney" +] as const; +export type MoneriumVerificationKind = (typeof MONERIUM_VERIFICATION_KINDS)[number]; + +export const MONERIUM_CHAINS = [ + "ethereum", + "gnosis", + "polygon", + "arbitrum", + "linea", + "base", + "noble", + "sepolia", + "chiado", + "amoy", + "arbitrumsepolia", + "lineasepolia", + "basesepolia", + "grand" +] as const; +export type MoneriumChain = (typeof MONERIUM_CHAINS)[number]; + +export const MONERIUM_ORDER_STATES = ["placed", "pending", "processed", "rejected"] as const; +export type MoneriumOrderState = (typeof MONERIUM_ORDER_STATES)[number]; + +export const MONERIUM_ORDER_FILTER_STATES = ["pending", "processed", "rejected"] as const; +export type MoneriumOrderFilterState = (typeof MONERIUM_ORDER_FILTER_STATES)[number]; + +export const MONERIUM_WEBHOOK_TYPES = [ + "iban.updated", + "order.created", + "order.updated", + "profile.error", + "profile.updated" +] as const; +export type MoneriumWebhookType = (typeof MONERIUM_WEBHOOK_TYPES)[number]; + +export const MONERIUM_ADDRESS_OWNERSHIP_MESSAGE = "I hereby declare that I am the address owner."; + +export interface MoneriumAccessTokenResponse { + access_token: string; + expires_in: number; + refresh_token?: string; + token_type: string; +} + +export interface MoneriumProfileSummary { + id: string; + kind: MoneriumProfileKind; + name: string; + state: MoneriumProfileState; +} + +export interface MoneriumProfile extends MoneriumProfileSummary { + details: { state: MoneriumSectionState }; + form: { state: MoneriumSectionState }; + verifications: Array<{ kind: MoneriumVerificationKind; state: MoneriumSectionState }>; +} + +export interface MoneriumListProfilesResponse { + profiles: MoneriumProfileSummary[]; +} + +export interface MoneriumAddress { + address: string; + chains: MoneriumChain[]; + profile: string; +} + +export interface MoneriumListAddressesResponse { + addresses: MoneriumAddress[]; +} + +export interface MoneriumLinkAddressRequest { + address: string; + chain: MoneriumChain; + message: typeof MONERIUM_ADDRESS_OWNERSHIP_MESSAGE; + profile: string; + /** EOA signature, combined off-chain EIP-1271 signature bytes, or `0x` for on-chain EIP-1271 approval. */ + signature: string; +} + +export interface MoneriumAcceptedResponse { + code: 202; + status: "Accepted"; +} + +export type MoneriumLinkAddressResult = { httpStatus: 201 } | ({ httpStatus: 202 } & MoneriumAcceptedResponse); + +export interface MoneriumIban { + address: string; + bic: string; + chain: MoneriumChain; + iban: string; + name: string; + profile: string; +} + +export interface MoneriumIbanUpdatedData { + address: string; + bic?: string; + chain: MoneriumChain; + iban: string; + name?: string; + profile: string; + state?: string; +} + +export interface MoneriumListIbansResponse { + ibans: MoneriumIban[]; +} + +export interface MoneriumIbanDestinationRequest { + address: string; + chain: MoneriumChain; +} + +export type MoneriumRequestIbanResult = { httpStatus: 202 | 304 }; + +export interface MoneriumIbanIdentifier { + iban: string; + standard: "iban"; +} + +export interface MoneriumChainIdentifier { + address: string; + chain: MoneriumChain; + standard: "chain"; +} + +export interface MoneriumPersonalCounterpartDetails { + country: string; + firstName: string; + lastName: string; +} + +export interface MoneriumCorporateCounterpartDetails { + companyName: string; + country: string; +} + +export interface MoneriumRedeemOrderRequest { + address: string; + amount: string; + chain: MoneriumChain; + counterpart: { + details: MoneriumPersonalCounterpartDetails | MoneriumCorporateCounterpartDetails; + identifier: MoneriumIbanIdentifier; + }; + currency: "eur"; + id?: string; + kind: "redeem"; + memo?: string; + message: string; + referenceNumber?: string; + signature: string; + supportingDocumentId?: string; +} + +export interface MoneriumOrder { + address: string; + amount: string; + chain: MoneriumChain; + counterpart: { + details?: Record; + identifier: MoneriumIbanIdentifier | MoneriumChainIdentifier | ({ standard: string } & Record); + }; + currency: "eur" | "usd" | "gbp" | "isk"; + id: string; + kind: "issue" | "redeem"; + memo: string; + meta: { + placedAt: string; + processedAt?: string; + rejectedReason?: string; + supportingDocumentId?: string; + txHashes?: string[]; + }; + profile: string; + referenceNumber?: string; + state: MoneriumOrderState; +} + +export interface MoneriumListOrdersResponse { + orders: MoneriumOrder[]; +} + +export type MoneriumCreateOrderResult = + | { httpStatus: 200; order: MoneriumOrder } + | ({ httpStatus: 202 } & MoneriumAcceptedResponse); + +export interface MoneriumUploadedFile { + hash: string; + id: string; + meta: { + createdAt: string; + updatedAt: string; + uploadedBy: string; + }; + name: string; + size: number; + type: string; +} + +export interface MoneriumCreateWebhookRequest { + secret: string; + types?: MoneriumWebhookType[]; + url: string; +} + +export interface MoneriumWebhookSubscription { + id: string; + state: "active" | "inactive"; + types: MoneriumWebhookType[]; + url: string; +} + +export interface MoneriumListWebhooksResponse { + subscriptions: MoneriumWebhookSubscription[]; +} + +export interface MoneriumUpdateWebhookRequest { + state?: "active" | "inactive"; + types?: MoneriumWebhookType[]; +} + +export type MoneriumWebhookEvent = + | { timestamp: string; type: "subscription.created" } + | { data: MoneriumOrder; timestamp: string; type: "order.created" | "order.updated" } + | { + data: Pick; + timestamp: string; + type: "profile.updated"; + } + | { + data: { + errors: Array<{ field: string; reason: string }>; + id: string; + kind: MoneriumProfileKind; + }; + timestamp: string; + type: "profile.error"; + } + | { data: MoneriumIbanUpdatedData; timestamp: string; type: "iban.updated" }; From 0f5a983ef6a7b5b82e746c43b9aafa44292ae659 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 25 Aug 2026 13:28:06 -0300 Subject: [PATCH 45/59] docs(shared): document Monerium profile lifecycle --- docs/operations-monerium-interface.md | 21 +++++++++++++++++++ .../security-spec/05-integrations/monerium.md | 13 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/operations-monerium-interface.md b/docs/operations-monerium-interface.md index 67b77fab7..7b7db2856 100644 --- a/docs/operations-monerium-interface.md +++ b/docs/operations-monerium-interface.md @@ -21,6 +21,27 @@ once after `401`, and applies a 10-second timeout to every call. Credentials use | Get user information | `GET /profiles/{profileId}` | Returns profile identity, type, name, and compliance states. It does **not** expose the submitted personal/corporate details such as email or address. | [API: Profile](https://docs.monerium.com/api/#tag/profiles/operation/profile) | | Monitor status changes | `profile.updated` webhook | Preferred over polling. `profile.error` is opt-in and reports rejected ingestion fields. | [Whitelabel: Monitor approval](https://docs.monerium.com/whitelabel#5-monitor-approval), [Whitelabel: Event types](https://docs.monerium.com/whitelabel#event-types) | +## KYC/KYB Profile Lifecycle + +Monerium does not expose a separate KYC/KYB case or attempt ID. The profile UUID created by +`POST /profiles` is the durable workflow identity; its `kind` is immutable, and details, form data, +and verifications are sections of that same profile. Vortex therefore mirrors one `kyc_cases` row +per Monerium `provider_customers` row and leaves `provider_case_id` unset. Repeated submissions and +status changes update that row rather than creating a new local case. + +| Profile state | Meaning and next action | +|---|---| +| `created` | No data has been submitted. Submit the required profile sections using the same profile UUID. | +| `incomplete` | The profile is resumable. Inspect section states and `profile.error`, correct or add the requested data, and resubmit the affected `/share`, `/details`, `/form`, or `/verifications` operation against the same profile UUID. | +| `pending` | Monerium is reviewing the profile. Further submissions are blocked; wait for `profile.updated` to move it to `approved`, `rejected`, or back to `incomplete`. Do not create another profile or retry blindly. | +| `approved` | KYC/KYB is complete and Monerium services are available. Further section updates return `409`. | +| `rejected` | Final compliance rejection. Do not retry or create a replacement profile unless Monerium explicitly authorizes a new onboarding. | + +The current shared client implements profile reads but not `POST /profiles` or the onboarding +`POST`/`PATCH` operations above. This lifecycle is the required behavior when that orchestration is +added. Externally imported profiles enter Vortex directly as `approved` and do not execute these +submission steps locally. + ## Connected Addresses | Operation | Endpoint / sequence | Commentary | Source | diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index a760ae256..fe75b6b24 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -17,6 +17,19 @@ IBANs, and SEPA/EURe payments. Its only consumer today is the Monerium B2B onram connected to a public Vortex route or ramp phase, and EUR ramp registration remains disabled until that orchestration and its tests are implemented. +### Externally Imported Profiles + +- Externally imported Monerium profiles MUST come from a trusted party and MUST bind the correct + Monerium profile UUID to the correct Vortex legal entity. The import mechanism is not yet defined; + no caller-controlled profile adoption may be exposed until it is. +- Every imported profile MUST create a `provider_customers` row and a linked `kyc_cases` row in the + `approved` state, regardless of whether Vortex performed the KYC/KYB flow. +- Profile-scoped persistence MUST remain limited to the Monerium profile identifier and compliance + status. Addresses and IBANs remain provider-authoritative; any selected values needed by a ramp + belong in quote or ramp state, not a permanent Monerium profile table. +- A Monerium mint address MUST belong exclusively to one Monerium profile and MUST never be shared + across profiles. Incoming deposits are attributed to that profile through its dedicated address. + ### Security Invariants 1. White-label credentials MUST use `MONERIUM_WHITELABEL_CLIENT_ID` and `MONERIUM_WHITELABEL_CLIENT_SECRET`, remain backend-only, and never be accepted from caller input. From 9a58d13ca6c74cc5dea36501b77a6e9bb5929121 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 18:21:41 +0200 Subject: [PATCH 46/59] docs(api): add message-change notice to the G1 approval package The forwarder whitelists the exact link/recovery message hashes, so an unannounced message change on Monerium's side fail-closes onboarding of new clients; an advance-notice obligation makes that a planned implementation update instead of a surprise outage. --- docs/prd/monerium-onramp-deferred-decisions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md index 3850750f1..c79c0c708 100644 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -80,6 +80,7 @@ All currently Telegram-only. Consolidate into MSA or side letter: 6. SEPA recall / fraud loss allocation after conversion+forwarding (pre-existing; unresolved). 7. Per-IBAN suspension capability for incident response (pre-existing; unresolved). 8. T3 corporate KYB mechanism. +9. Advance notice of any change to the EIP-1271 ownership/link message (and, once T1 resolves, the recovery message): the forwarder whitelists their exact hashes, so an unannounced change fail-closes new onboarding (2026-08-26). ## G2 — legal review scope (unchanged, not started) From ff60268f8099047c05ca7e0801b89f5b2ca0ac31 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 18:24:20 +0200 Subject: [PATCH 47/59] refactor(api): migrate monerium b2b onto the shared white-label client The shared MoneriumApiService is now the single Monerium transport: the B2B module's internal whitelabel client is deleted and onboarding and monitoring call the shared client through a narrow adapter, keeping the dependency-injection seams intact. Credentials consolidate on MONERIUM_WHITELABEL_CLIENT_ID/SECRET and MONERIUM_API_URL (one whitelabel app = one credential set); MONERIUM_B2B_* keeps only the chain/keeper settings. The attestor's link message now aliases the shared constant so the signed and transmitted bytes cannot drift. --- apps/api/.env.example | 3 +- .../src/api/services/monerium-b2b/attestor.ts | 5 +- .../api/services/monerium-b2b/monerium-api.ts | 65 +++++ .../api/services/monerium-b2b/monitoring.ts | 4 +- .../services/monerium-b2b/onboarding.test.ts | 14 +- .../api/services/monerium-b2b/onboarding.ts | 24 +- .../monerium-b2b/whitelabel-client.ts | 235 ------------------ apps/api/src/config/vars.ts | 11 +- docs/operations-monerium-interface.md | 12 +- docs/runbooks/monerium-b2b-incident.md | 2 +- docs/runbooks/monerium-b2b-onboarding.md | 3 +- .../05-integrations/monerium-b2b.md | 16 +- 12 files changed, 118 insertions(+), 276 deletions(-) create mode 100644 apps/api/src/api/services/monerium-b2b/monerium-api.ts delete mode 100644 apps/api/src/api/services/monerium-b2b/whitelabel-client.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 99ab4f376..30d8735ff 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -159,7 +159,8 @@ ALFREDPAY_API_SECRET=your-alfredpay-api-secret MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id MONERIUM_API_URL=https://api.monerium.dev MONERIUM_REDIRECT_URI=http://localhost:5174/dashboard/monerium/callback -# Server-to-server white-label access. Keep this backend-only. +# Server-to-server white-label access (shared client; also the Monerium B2B onramp +# credentials). Keep this backend-only. MONERIUM_WHITELABEL_CLIENT_ID=your-monerium-whitelabel-client-id MONERIUM_WHITELABEL_CLIENT_SECRET=your-monerium-whitelabel-client-secret diff --git a/apps/api/src/api/services/monerium-b2b/attestor.ts b/apps/api/src/api/services/monerium-b2b/attestor.ts index 8ffe1fcf6..ff1c14242 100644 --- a/apps/api/src/api/services/monerium-b2b/attestor.ts +++ b/apps/api/src/api/services/monerium-b2b/attestor.ts @@ -1,3 +1,4 @@ +import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE } from "@vortexfi/shared"; import { Address, encodePacked, Hex, keccak256, serializeSignature, stringToBytes } from "viem"; import { privateKeyToAccount, sign } from "viem/accounts"; import { config } from "../../../config/vars"; @@ -17,7 +18,9 @@ import { config } from "../../../config/vars"; * move funds (security-spec/05-integrations/monerium-b2b.md). */ -export const LINK_MESSAGE = "I hereby declare that I am the address owner."; +// The shared white-label client sends this exact message with POST /addresses; the +// signature below must cover the same bytes, so both come from the one shared constant. +export const LINK_MESSAGE = MONERIUM_ADDRESS_OWNERSHIP_MESSAGE; export interface LinkAttestation { boundHash: Hex; diff --git a/apps/api/src/api/services/monerium-b2b/monerium-api.ts b/apps/api/src/api/services/monerium-b2b/monerium-api.ts new file mode 100644 index 000000000..8e20cc460 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monerium-api.ts @@ -0,0 +1,65 @@ +import { + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + MoneriumApiService, + type MoneriumChain, + type MoneriumIban +} from "@vortexfi/shared"; + +/** + * Narrow view of the shared Monerium white-label client (`@vortexfi/shared` + * `MoneriumApiService`) — the single Monerium transport in the repo. Only the + * operations the B2B onramp needs; auth, timeouts, wire-schema validation, and + * response redaction live in the shared client + * (docs/security-spec/05-integrations/monerium.md). + */ + +/** The shared client reads these directly; callers gate on this before touching it. */ +export function isWhitelabelConfigured(): boolean { + return Boolean(process.env.MONERIUM_WHITELABEL_CLIENT_ID && process.env.MONERIUM_WHITELABEL_CLIENT_SECRET); +} + +/** + * POST /addresses — links a forwarder address to a profile using the attestor's + * EIP-1271-verifiable signature over the fixed link message (see ./attestor.ts). + * The signature bytes pass through unchanged (shared-client invariant 8). + */ +export async function linkAddress( + profileId: string, + address: string, + chain: MoneriumChain, + signature: string +): Promise { + return MoneriumApiService.getInstance().linkAddress({ + address, + chain, + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: profileId, + signature + }); +} + +/** POST /ibans — requests IBAN issuance for a linked address (202 provisioning, 304 already issued). */ +export async function requestIban(address: string, chain: MoneriumChain): Promise { + return MoneriumApiService.getInstance().requestIban({ address, chain }); +} + +/** GET /ibans — all IBANs visible to the partner context (association monitor + lookups). */ +export async function listIbans(): Promise { + return (await MoneriumApiService.getInstance().listIbans()).ibans; +} + +/** GET /ibans — the IBAN issued for an address, or null if none yet. */ +export async function getIbanForAddress(address: string): Promise { + const ibans = await listIbans(); + return ibans.find(entry => entry.address.toLowerCase() === address.toLowerCase()) ?? null; +} + +/** + * GET /addresses?profile={id} — the addresses linked to a profile. Used by the + * association monitor (S1 detective control): any address linked to a client profile + * beyond the forwarder is an alert condition. + */ +export async function getProfileAddresses(profileId: string): Promise { + const response = await MoneriumApiService.getInstance().listAddresses({ profile: profileId }); + return response.addresses.map(entry => entry.address); +} diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index e6662726f..e0cba66e2 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -4,7 +4,7 @@ import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import { erc20Abi, factoryAbi, forwarderAbi, getChainId, getForwarderImmutables, getPublicClient } from "./chain"; -import { getProfileAddresses, listIbans } from "./whitelabel-client"; +import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; /** * Monitoring pass for the Monerium B2B onramp (implementation plan D3 / phase 3), run @@ -461,7 +461,7 @@ export async function runMonitoringPass(now: number = Date.now()): Promise await guarded("stranded-balance monitor", () => runStrandedBalanceMonitor(now)); await guarded("config reconciliation", runConfigReconciliation); } - if (config.moneriumB2b.clientId && config.moneriumB2b.clientSecret) { + if (isWhitelabelConfigured()) { await guarded("association monitor", runAssociationMonitor); } } diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts index d2b1b9088..47fcf51ba 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -92,9 +92,8 @@ describe("monerium b2b onboarding automation", () => { beforeEach(async () => { await resetTestDatabase(); config.moneriumB2b.attestorPrivateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; - config.moneriumB2b.clientId = "test-client-id"; - config.moneriumB2b.clientSecret = "test-client-secret"; config.moneriumB2b.rpcUrl = "http://rpc.invalid"; + // MONERIUM_WHITELABEL_CLIENT_ID/SECRET come from test-utils/preload.ts. }); afterAll(() => { @@ -238,11 +237,16 @@ describe("monerium b2b onboarding automation", () => { it("does nothing while the whitelabel credentials are not configured", async () => { await createMappedAccount(); - config.moneriumB2b.clientId = ""; + const savedClientId = process.env.MONERIUM_WHITELABEL_CLIENT_ID; + process.env.MONERIUM_WHITELABEL_CLIENT_ID = ""; const deps = fakeDeps(); - expect(await advanceOnboardingAccounts(deps)).toBe(0); - expect(deps.calls.getProfileAddresses).toBe(0); + try { + expect(await advanceOnboardingAccounts(deps)).toBe(0); + expect(deps.calls.getProfileAddresses).toBe(0); + } finally { + process.env.MONERIUM_WHITELABEL_CLIENT_ID = savedClientId; + } }); }); diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.ts b/apps/api/src/api/services/monerium-b2b/onboarding.ts index a0a758686..eb42be807 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.ts @@ -1,3 +1,4 @@ +import type { MoneriumChain } from "@vortexfi/shared"; import { Op } from "sequelize"; import type { Address } from "viem"; import logger from "../../../config/logger"; @@ -6,13 +7,13 @@ import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/monerium import { FinancialOperationRejectedError, runFinancialOperation } from "../phases/blocks/core/financial-operation"; import { signLinkAttestation } from "./attestor"; import { getChainId } from "./chain"; -import { getIbanForAddress, getProfileAddresses, linkAddress, requestIban } from "./whitelabel-client"; +import { getIbanForAddress, getProfileAddresses, isWhitelabelConfigured, linkAddress, requestIban } from "./monerium-api"; const ONBOARDING_FLOW = { id: "monerium-b2b-onboarding", version: 1 } as const; // Monerium's chain identifiers for the chains the forwarder deploys to // (docs.monerium.com chain values; the attestation binds the numeric chain id). -const MONERIUM_CHAIN_NAMES: Record = { +const MONERIUM_CHAIN_NAMES: Record = { 1: "ethereum", 11155111: "sepolia" }; @@ -21,8 +22,8 @@ export interface OnboardingDeps { getChainId(): Promise; getIbanForAddress(address: string): Promise<{ iban: string } | null>; getProfileAddresses(profileId: string): Promise; - linkAddress(profileId: string, address: string, chain: string, signature: string): Promise; - requestIban(address: string, chain: string): Promise; + linkAddress(profileId: string, address: string, chain: MoneriumChain, signature: string): Promise; + requestIban(address: string, chain: MoneriumChain): Promise; signLinkAttestation(chainId: bigint, forwarderAddress: Address): Promise<{ signature: string }>; } @@ -36,8 +37,8 @@ const defaultDeps: OnboardingDeps = { }; export function isOnboardingConfigured(): boolean { - const { attestorPrivateKey, clientId, clientSecret, rpcUrl } = config.moneriumB2b; - return Boolean(attestorPrivateKey && clientId && clientSecret && rpcUrl); + const { attestorPrivateKey, rpcUrl } = config.moneriumB2b; + return Boolean(attestorPrivateKey && rpcUrl && isWhitelabelConfigured()); } let configWarned = false; @@ -48,7 +49,12 @@ async function isForwarderLinked(deps: OnboardingDeps, moneriumProfileId: string return addresses.some(address => address.toLowerCase() === forwarderKey); } -async function ensureLinked(deps: OnboardingDeps, account: MoneriumAccount, chainId: number, chainName: string): Promise { +async function ensureLinked( + deps: OnboardingDeps, + account: MoneriumAccount, + chainId: number, + chainName: MoneriumChain +): Promise { if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress)) return; await runFinancialOperation({ attemptClass: "provider-address-link", @@ -84,7 +90,7 @@ async function ensureLinked(deps: OnboardingDeps, account: MoneriumAccount, chai }); } -async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainName: string): Promise { +async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainName: MoneriumChain): Promise { if (account.iban) return; const issued = await deps.getIbanForAddress(account.forwarderAddress); if (issued) { @@ -131,7 +137,7 @@ export async function advanceOnboardingAccounts(deps: OnboardingDeps = defaultDe if (!configWarned) { configWarned = true; logger.warn( - "monerium-b2b: onboarding automation disabled — requires MONERIUM_B2B_CLIENT_ID/SECRET, MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, and MONERIUM_B2B_RPC_URL" + "monerium-b2b: onboarding automation disabled — requires MONERIUM_WHITELABEL_CLIENT_ID/SECRET, MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, and MONERIUM_B2B_RPC_URL" ); } return 0; diff --git a/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts b/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts deleted file mode 100644 index e36fc5935..000000000 --- a/apps/api/src/api/services/monerium-b2b/whitelabel-client.ts +++ /dev/null @@ -1,235 +0,0 @@ -import httpStatus from "http-status"; -import { config } from "../../../config/vars"; -import { APIError } from "../../errors/api-error"; -import { LINK_MESSAGE } from "./attestor"; - -/** - * Thin Monerium whitelabel API client (client-credentials flow, sandbox-first). - * Endpoints per docs.monerium.com/api: POST /auth/token, POST /profiles, - * POST /addresses, POST /ibans, GET /ibans, GET /orders/{orderId}. - * Only the endpoints the B2B onramp needs — no speculative surface. - */ - -const FETCH_TIMEOUT_MS = 10_000; -const TOKEN_EXPIRY_SKEW_MS = 30_000; -const API_V2_ACCEPT = "application/vnd.monerium.api-v2+json"; - -export type MoneriumProfileKind = "personal" | "corporate"; - -export interface WhitelabelProfile { - id: string; - kind: MoneriumProfileKind; - state: string; -} - -export interface WhitelabelIban { - iban: string; - bic?: string; - address: string; - chain: string; -} - -export interface WhitelabelOrder { - id: string; - state: string; - kind?: string; - amount?: string; - currency?: string; - address?: string; -} - -interface CachedToken { - accessToken: string; - expiresAt: number; -} - -let cachedToken: CachedToken | null = null; -let tokenRequest: Promise | null = null; - -function upstreamError(_internalMessage: string): APIError { - return new APIError({ message: "Monerium request failed", status: httpStatus.BAD_GATEWAY }); -} - -async function fetchToken(): Promise { - const { apiUrl, clientId, clientSecret } = config.moneriumB2b; - let response: Response; - try { - response = await fetch(`${apiUrl}/auth/token`, { - body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: "client_credentials" }), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - method: "POST", - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) - }); - } catch { - throw upstreamError("Monerium token request timed out or failed"); - } - if (!response.ok) { - throw upstreamError(`Monerium token endpoint returned HTTP ${response.status}`); - } - const token = (await response.json().catch(() => null)) as { access_token?: unknown; expires_in?: unknown } | null; - if (!token || typeof token.access_token !== "string" || typeof token.expires_in !== "number" || token.expires_in <= 0) { - throw upstreamError("Monerium returned an invalid token response"); - } - return { accessToken: token.access_token, expiresAt: Date.now() + token.expires_in * 1000 }; -} - -async function getAccessToken(forceRefresh = false): Promise { - if (!config.moneriumB2b.clientId || !config.moneriumB2b.clientSecret) { - throw new APIError({ - message: "Monerium B2B client credentials are not configured", - status: httpStatus.SERVICE_UNAVAILABLE - }); - } - if (!forceRefresh && cachedToken && cachedToken.expiresAt - TOKEN_EXPIRY_SKEW_MS > Date.now()) { - return cachedToken.accessToken; - } - if (!tokenRequest) { - tokenRequest = fetchToken() - .then(token => { - cachedToken = token; - return token; - }) - .finally(() => { - tokenRequest = null; - }); - } - return (await tokenRequest).accessToken; -} - -async function request(path: string, init: { body?: unknown; method: "GET" | "POST" }): Promise { - const doFetch = async (accessToken: string): Promise => { - try { - return await fetch(`${config.moneriumB2b.apiUrl}${path}`, { - body: init.body === undefined ? undefined : JSON.stringify(init.body), - headers: { - Accept: API_V2_ACCEPT, - Authorization: `Bearer ${accessToken}`, - ...(init.body === undefined ? {} : { "Content-Type": "application/json" }) - }, - method: init.method, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) - }); - } catch { - throw upstreamError("Monerium request timed out or failed"); - } - }; - - let response = await doFetch(await getAccessToken()); - if (response.status === 401) { - // Client-credentials tokens are not refreshable — a 401 means expired/revoked; mint a new one once. - response = await doFetch(await getAccessToken(true)); - } - if (!response.ok) { - throw upstreamError(`Monerium returned HTTP ${response.status} for ${init.method} ${path}`); - } - if (response.status === 204) return null; - try { - return await response.json(); - } catch { - throw upstreamError("Monerium returned invalid JSON"); - } -} - -/** POST /profiles — partner-created profile (whitelabel). */ -export async function createProfile(kind: MoneriumProfileKind): Promise { - const profile = (await request("/profiles", { body: { kind }, method: "POST" })) as Record; - if (!profile || typeof profile.id !== "string" || profile.kind !== kind || typeof profile.state !== "string") { - throw upstreamError("Monerium returned an invalid profile"); - } - return { id: profile.id, kind, state: profile.state }; -} - -/** - * Corporate KYB submission for a whitelabel profile. - * - * Deliberately a stub: the KYB mechanism under whitelabel (Monerium-run verification vs - * KYC-reliance) is pending the MSA negotiation — deferred-decisions registry item T3. - * Do not build a speculative payload shape against it. - */ -export async function submitKybData(_profileId: string, _data: unknown): Promise { - throw new APIError({ - message: "Monerium B2B KYB submission is not implemented (pending registry item T3)", - status: httpStatus.NOT_IMPLEMENTED - }); -} - -/** - * POST /addresses — links a forwarder address to a profile using the attestor's - * EIP-1271-verifiable signature over the fixed link message (see ./attestor.ts). - */ -export async function linkAddress(profileId: string, address: string, chain: string, signature: string): Promise { - return request("/addresses", { - body: { address, chain, message: LINK_MESSAGE, profile: profileId, signature }, - method: "POST" - }); -} - -/** POST /ibans — requests IBAN issuance for a linked address. */ -export async function requestIban(address: string, chain: string): Promise { - return request("/ibans", { body: { address, chain }, method: "POST" }); -} - -/** GET /ibans — all IBANs visible to the partner context (association monitor + lookups). */ -export async function listIbans(): Promise { - const response = (await request("/ibans", { method: "GET" })) as { ibans?: unknown } | unknown[] | null; - const entries = Array.isArray(response) ? response : Array.isArray(response?.ibans) ? response.ibans : []; - const ibans: WhitelabelIban[] = []; - for (const entry of entries as Record[]) { - if (typeof entry?.iban === "string" && typeof entry.address === "string") { - ibans.push({ - address: entry.address, - bic: typeof entry.bic === "string" ? entry.bic : undefined, - chain: typeof entry.chain === "string" ? entry.chain : "", - iban: entry.iban - }); - } - } - return ibans; -} - -/** GET /ibans — returns the IBAN issued for an address, or null if none yet. */ -export async function getIbanForAddress(address: string): Promise { - const ibans = await listIbans(); - return ibans.find(entry => entry.address.toLowerCase() === address.toLowerCase()) ?? null; -} - -/** - * GET /addresses?profile={id} — the addresses linked to a profile. Used by the - * association monitor (S1 detective control): any address linked to a client profile - * beyond the forwarder is an alert condition. - */ -export async function getProfileAddresses(profileId: string): Promise { - const response = (await request(`/addresses?profile=${encodeURIComponent(profileId)}`, { method: "GET" })) as - | { addresses?: unknown } - | unknown[] - | null; - const entries = Array.isArray(response) ? response : Array.isArray(response?.addresses) ? response.addresses : []; - const addresses: string[] = []; - for (const entry of entries as Record[]) { - if (typeof entry?.address === "string") { - addresses.push(entry.address); - } - } - return addresses; -} - -/** GET /orders/{orderId} */ -export async function getOrder(orderId: string): Promise { - const order = (await request(`/orders/${encodeURIComponent(orderId)}`, { method: "GET" })) as Record; - if (!order || typeof order.id !== "string" || typeof order.state !== "string") { - throw upstreamError("Monerium returned an invalid order"); - } - return { - address: typeof order.address === "string" ? order.address : undefined, - amount: typeof order.amount === "string" ? order.amount : undefined, - currency: typeof order.currency === "string" ? order.currency : undefined, - id: order.id, - kind: typeof order.kind === "string" ? order.kind : undefined, - state: order.state - }; -} - -export function resetMoneriumB2bClientForTests(): void { - cachedToken = null; - tokenRequest = null; -} diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 5d5f5255e..71b313d00 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -223,10 +223,7 @@ interface Config { // B2B whitelabel onramp integration (docs/prd/monerium-b2b-implementation-plan.md §3). // Separate credential set from the legacy consumer OAuth integration above. moneriumB2b: { - apiUrl: string; attestorPrivateKey: string | undefined; - clientId: string; - clientSecret: string; guardianPrivateKey: string | undefined; keeperPrivateKey: string | undefined; privateRpcUrl: string | undefined; @@ -336,12 +333,10 @@ export const config: Config = { redirectUri: process.env.MONERIUM_REDIRECT_URI || "http://localhost:5174/monerium/callback" }, moneriumB2b: { - // Sandbox by default: the B2B build is developed against api.monerium.dev until the - // MSA is signed (locked scope decision 2026-07-17). - apiUrl: process.env.MONERIUM_B2B_API_URL || "https://api.monerium.dev", + // Whitelabel API credentials and base URL live with the shared client + // (MONERIUM_WHITELABEL_CLIENT_ID/SECRET, MONERIUM_API_URL — @vortexfi/shared); + // this block keeps only the chain/keeper-specific settings. attestorPrivateKey: process.env.MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, - clientId: process.env.MONERIUM_B2B_CLIENT_ID || "", - clientSecret: process.env.MONERIUM_B2B_CLIENT_SECRET || "", // Dormancy-gate pause key (guardian on the factory/forwarders). Distinct from the // keeper and attestor keys by design; unset = log-only mode for the dormancy gate. guardianPrivateKey: process.env.MONERIUM_B2B_GUARDIAN_PRIVATE_KEY, diff --git a/docs/operations-monerium-interface.md b/docs/operations-monerium-interface.md index 7b7db2856..4f039c730 100644 --- a/docs/operations-monerium-interface.md +++ b/docs/operations-monerium-interface.md @@ -4,11 +4,13 @@ All calls are server-to-server using `client_credentials`; users remain entirely within Vortex. ([Whitelabel: Authentication](https://docs.monerium.com/whitelabel#authentication)) -The initial Vortex transport is `packages/shared/src/services/monerium/moneriumApiService.ts`. -It establishes the sole Monerium integration baseline but does not by itself re-enable EUR ramp -registration or settlement. It caches client-credential tokens in memory, requests API v2, retries -once after `401`, and applies a 10-second timeout to every call. Credentials use -`MONERIUM_WHITELABEL_CLIENT_ID` and `MONERIUM_WHITELABEL_CLIENT_SECRET`. +The Vortex transport is `packages/shared/src/services/monerium/moneriumApiService.ts` — the single +Monerium transport in the repo; the Monerium B2B onramp consumes it through the narrow adapter +`apps/api/src/api/services/monerium-b2b/monerium-api.ts`. It establishes the sole Monerium +integration baseline but does not by itself re-enable EUR ramp registration or settlement. It +caches client-credential tokens in memory, requests API v2, retries once after `401`, and applies +a 10-second timeout to every call. Credentials use `MONERIUM_WHITELABEL_CLIENT_ID` and +`MONERIUM_WHITELABEL_CLIENT_SECRET`. | Operation | Endpoint / sequence | Commentary | Source | |---|---|---|---| diff --git a/docs/runbooks/monerium-b2b-incident.md b/docs/runbooks/monerium-b2b-incident.md index 4ab340797..cb3db5861 100644 --- a/docs/runbooks/monerium-b2b-incident.md +++ b/docs/runbooks/monerium-b2b-incident.md @@ -94,7 +94,7 @@ The monitors live in `apps/api/src/api/services/monerium-b2b/monitoring.ts` (wor |---|---|---| | `PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Executable depth below even minimum-size swaps (PRD §7.4 pause threshold); swaps would revert on minOut | Engage global pause (§1); investigate pool state (LP exit, depeg); consider lowering `perSwapCap`; re-run the T6 quote methodology before unpausing | | `executable depth below perSwapCap` | Cap-sized swaps would revert; availability, not fund risk | Lower `perSwapCap` (§1) or accept keeper retries; watch for escalation to pause threshold | -| `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB record (IBAN moved, address linked) — the S1 detective control | Treat as potential whitelabel-credential compromise: confirm with Monerium whether the change was authorized; if not: global pause, rotate `MONERIUM_B2B_CLIENT_SECRET`, ask for IBAN suspension (§2), full incident | +| `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB record (IBAN moved, address linked) — the S1 detective control | Treat as potential whitelabel-credential compromise: confirm with Monerium whether the change was authorized; if not: global pause, rotate `MONERIUM_WHITELABEL_CLIENT_SECRET`, ask for IBAN suspension (§2), full incident | | `stranded EURe on forwarder` (warn ≥12h) | Keeper is not converting | Check worker liveness, RPC health, keeper gas balance, oracle staleness (swaps revert on `StalePrice`) | | `stranded EURe ... past TRIGGER_DELAY` | ≥ TRIGGER_DELAY (registry P4) — permissionless trigger now live; SLA long since broken | Escalate keeper outage; anyone can call `swapAndForward()` now, which is acceptable (same policy applies); communicate delay to client | | `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on factory` | Should-be-impossible state (immutable feeBps changed, wrong code) | Full incident: global pause, run the manifest verifier, compare against the published manifest history | diff --git a/docs/runbooks/monerium-b2b-onboarding.md b/docs/runbooks/monerium-b2b-onboarding.md index 4511c0b08..6d2e3af0c 100644 --- a/docs/runbooks/monerium-b2b-onboarding.md +++ b/docs/runbooks/monerium-b2b-onboarding.md @@ -5,7 +5,8 @@ One pass per client. Spec: `docs/prd/monerium-b2b-implementation-plan.md`; API c shapes below are the sandbox-validated ones from registry item T4 (2026-07-17). Prerequisites: guardian key funded on the target chain; `MONERIUM_B2B_*` env set -(API creds, attestor key, RPC); partner paperwork complete; the client company +(attestor key, RPC, webhook secret) plus the whitelabel API credentials +`MONERIUM_WHITELABEL_CLIENT_ID/SECRET`; partner paperwork complete; the client company onboarded and KYB-approved on Monerium's side (partner KYC reliance) with its Monerium profile UUID at hand; the partner configured as a managed-profile manager (`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 3e0bc7305..2f55bc258 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -7,8 +7,8 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e **Provider type:** on-ramp (EUR → USDC) **Fiat currencies:** EUR **Chains involved:** Ethereum (forwarder contracts, EURe/USDC) -**Modules:** `monerium-b2b/whitelabel-client.ts`, `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `monerium-b2b/account-provisioning.ts`, `monerium-b2b/onboarding.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`), `controllers/admin/moneriumB2b.controller.ts` (POST `/v1/admin/monerium-b2b/accounts`, ADMIN_SECRET); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts`; monitoring: `monerium-b2b/monitoring.ts` -**API auth method:** OAuth client credentials (`MONERIUM_B2B_CLIENT_ID`/`MONERIUM_B2B_CLIENT_SECRET`) against `MONERIUM_B2B_API_URL` (sandbox `api.monerium.dev` by default); inbound webhooks authenticated by HMAC-SHA256 (`MONERIUM_B2B_WEBHOOK_SECRET`) +**Modules:** `monerium-b2b/monerium-api.ts` (narrow adapter over the shared white-label client, [monerium.md](./monerium.md)), `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `monerium-b2b/account-provisioning.ts`, `monerium-b2b/onboarding.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`), `controllers/admin/moneriumB2b.controller.ts` (POST `/v1/admin/monerium-b2b/accounts`, ADMIN_SECRET); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts`; monitoring: `monerium-b2b/monitoring.ts` +**API auth method:** OAuth client credentials through the shared `MoneriumApiService` (`MONERIUM_WHITELABEL_CLIENT_ID`/`MONERIUM_WHITELABEL_CLIENT_SECRET`) against `MONERIUM_API_URL` (defaults to sandbox `api.monerium.dev` while `SANDBOX_ENABLED`, `api.monerium.app` otherwise); inbound webhooks authenticated by HMAC-SHA256 (`MONERIUM_B2B_WEBHOOK_SECRET`) ## Security Invariants @@ -21,8 +21,8 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e 7. **Deposit status transitions are forward-only** — `pending → {minted, held, returned}`, `held → {minted, returned}`; `minted` and `returned` are terminal. Out-of-order or replayed webhook events can never regress a deposit status; regressive transitions are logged and ignored. Guarded by `isForwardTransition` (unit-tested). 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 is the Monerium order id** — `monerium_order_id` is unique; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are stored as 18-decimal base-unit strings converted from the provider decimal, never floats. -10. **Client credentials are env-only and requests are bounded** — whitelabel API credentials come from env, all calls carry an explicit timeout, HTTPS base URLs only, and upstream failures surface as generic 502s without echoing provider response bodies. -11. **KYB submission is a guarded stub** — `submitKybData` throws 501 until the whitelabel KYB mechanism is contractually settled (deferred-decisions registry T3); no speculative identity-data path exists. Pilot corporates do not use it: 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. +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 (deferred-decisions registry T3), so no identity-data submission code path exists in the B2B module or the shared client. Pilot corporates do not need one: they are onboarded and approved by Monerium under the partner's KYC reliance, and the admin mapping imports that outcome as an approved `kyc_cases` row. 12. **Account mapping is admin-only, idempotent, and conflict-on-divergence** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) provisions the managed child (business entity, via the managed-profile provisioning service and its manager checks), mirrors the approved KYB, and binds the operator-deployed forwarder. When a read RPC is configured, the submitted forwarder is verified against the chain before anything is persisted (factory `isForwarder` registration plus destination/fallbackAddress/feeBps read-back) so a wrong clone address can never become a mapped account. Replaying identical input returns the existing records; any divergence (same Monerium profile with different forwarder/destination/child/feeBps, same child with a second Monerium profile, a Monerium profile already bound to a different customer entity) is a 409, never an overwrite. A Monerium profile, a forwarder address, and a managed profile can each back at most one account (unique + partial-unique indexes, migrations 069/071). 13. **Onboarding provider writes are exactly-once** — 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. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. @@ -62,12 +62,12 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | **Concurrent processors corrupt attribution** | Two instances process events for one account simultaneously | Transaction-scoped Postgres advisory lock per forwarder address serializes all per-account writes | | **Lost webhook between receipt and processing** | Process crashes after 200 but before the deposit write | Insert-before-200 durable inbox; unprocessed rows are retried on the next run | | **Poison inbox row blocks processing** | A malformed payload throws forever | Non-order/unrecognized payloads are marked processed and skipped; genuine failures are logged per-row and do not block other rows | -| **API credential compromise** | Whitelabel client id/secret leak | Env-only storage; sandbox credentials are segregated from production (`MONERIUM_B2B_*` set is distinct from legacy `MONERIUM_*`); rotate at Monerium | +| **API credential compromise** | Whitelabel client id/secret leak | Env-only storage; the whitelabel credential set (`MONERIUM_WHITELABEL_*`) is distinct from the legacy OAuth auth-code client (`MONERIUM_CLIENT_ID`); rotate at Monerium; the S1 association monitor detects unauthorized use | | **Provider unavailability** | Monerium API down | Client calls have explicit timeouts and surface 502; webhook inbox is unaffected (processing is local) | ## Audit Checklist -- [ ] `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY`, `MONERIUM_B2B_CLIENT_SECRET`, `MONERIUM_B2B_WEBHOOK_SECRET` loaded from env only; grep confirms no logging of their values +- [ ] `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY`, `MONERIUM_WHITELABEL_CLIENT_SECRET`, `MONERIUM_B2B_WEBHOOK_SECRET` loaded from env only; grep confirms no logging of their values - [ ] Attestor signs only the bound link hash (`attestor.ts` has no arbitrary-hash signing entry point) - [ ] `attestor.test.ts` pins the signature layout against `VortexForwarder.isValidSignature` (65 bytes, v in 27/28, low-s, bound to forwarder address) - [ ] Webhook HMAC verified over raw captured bytes (`config/express.ts` verify hook), constant-time compare @@ -77,8 +77,8 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] `monerium_order_id` unique constraint present; mint-log partial unique index present (migration 069) - [ ] `monerium_accounts.vortex_profile_id` partial unique index present (migration 071); admin mapping rejects divergence with 409 (`moneriumB2b.controller.test.ts`) - [ ] Onboarding link/IBAN calls wrapped in profile-scoped `financial_operations`; replay never repeats a provider write (`onboarding.test.ts`) -- [ ] `submitKybData` still returns 501 unless registry item T3 has been resolved and this spec updated -- [ ] HTTPS enforced for provider base URLs; timeouts configured on every provider call +- [ ] No KYB submission code path exists unless registry item T3 has been resolved and this spec updated +- [ ] HTTPS enforcement, timeouts, and wire-schema validation on every provider call are delivered by the shared client ([monerium.md](./monerium.md)); `monerium-api.ts` adds no transport of its own - [ ] Sandbox-verification TODOs resolved before production: exact `webhook-signature` digest encoding, delivery id field, upstream order-state vocabulary, EIP-191 vs raw link-hash variant (registry T4) - [ ] Keeper, guardian, and attestor private keys are three distinct keys in production; none logged - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) From e2d6c37b9432a6548760649dcc5b9daaa9635e86 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 18:24:28 +0200 Subject: [PATCH 48/59] docs(api): refresh the generated openapi types The committed vortex.openapi.d.ts predated a path reordering in vortex.openapi.json, so docs:api:check failed on a clean tree; this is the pinned generator's output for the unchanged spec. --- docs/api/openapi/vortex.openapi.d.ts | 394 +++++++++++++-------------- 1 file changed, 197 insertions(+), 197 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 2f0a1cc5e..c93e2121e 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -948,6 +948,50 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/monerium-b2b/account": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the acting profile's EUR onramp account + * @description Returns the acting profile's business EUR onramp account: status, dedicated IBAN, forwarding contract, and payout configuration. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. + * + * **Auth:** `X-API-Key` or Supabase Bearer. + */ + get: operations["getMoneriumB2bAccount"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/monerium-b2b/deposits": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the acting profile's EUR deposits + * @description Returns the acting profile's EUR deposits newest first, each with its allocated conversion execution once the swap has run. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. + * + * **Auth:** `X-API-Key` or Supabase Bearer. + */ + get: operations["listMoneriumB2bDeposits"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/onboarding/active-entity": { parameters: { query?: never; @@ -2004,50 +2048,6 @@ export interface paths { patch?: never; trace?: never; }; - "/v1/monerium-b2b/account": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get the acting profile's EUR onramp account - * @description Returns the acting profile's business EUR onramp account: status, dedicated IBAN, forwarding contract, and payout configuration. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. - * - * **Auth:** `X-API-Key` or Supabase Bearer. - */ - get: operations["getMoneriumB2bAccount"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/monerium-b2b/deposits": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * List the acting profile's EUR deposits - * @description Returns the acting profile's EUR deposits newest first, each with its allocated conversion execution once the swap has run. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. - * - * **Auth:** `X-API-Key` or Supabase Bearer. - */ - get: operations["listMoneriumB2bDeposits"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; } export type webhooks = Record; export interface components { @@ -2810,6 +2810,66 @@ export interface components { status: number; }; }; + MoneriumB2bAccount: { + accountId: string; + /** Format: date-time */ + createdAt: string; + /** @description The client's payout address on Ethereum. */ + destination: string; + /** + * Format: date-time + * @description Set while the account is dormancy-paused. + */ + dormantSince: string | null; + /** @description The client's self-custodied recovery address. */ + fallbackAddress: string; + feeBps: number; + /** @description The account's on-chain forwarding contract. */ + forwarderAddress: string; + /** @description The account's dedicated IBAN; null until issuance completes. */ + iban: string | null; + /** @enum {string} */ + status: "onboarding" | "active" | "suspended" | "closed"; + }; + MoneriumB2bAccountResponse: { + account: components["schemas"]["MoneriumB2bAccount"]; + }; + MoneriumB2bDeposit: { + /** @description Deposit amount in 18-decimal base units of the deposit currency. */ + amountRaw: string; + /** @description The allocated conversion execution once the swap has run; null while the deposit awaits conversion. */ + conversion: { + executionId: string; + /** + * @description Execution status. + * @enum {string} + */ + status: "pending" | "confirmed" | "failed"; + /** @description The swap-and-forward transaction hash. */ + txHash: string | null; + /** @description Net USDC forwarded for the whole execution in 6-decimal base units. When one execution batches several deposits this is the execution total; per-deposit shares are proportional to amountRaw. */ + usdcNetRaw: string | null; + } | null; + /** Format: date-time */ + createdAt: string; + currency: string; + depositId: string; + /** + * @description Deposit status (forward-only). + * @enum {string} + */ + status: "pending" | "minted" | "held" | "returned"; + /** @description The on-chain mint transaction, when observed. */ + txHash: string | null; + }; + MoneriumB2bDepositsResponse: { + deposits: components["schemas"]["MoneriumB2bDeposit"][]; + pagination: { + limit: number; + offset: number; + total: number; + }; + }; /** * @description Supported blockchain networks. * @enum {string} @@ -3363,66 +3423,6 @@ export interface components { /** @description Indicates if the PIX key is valid. */ valid?: boolean; }; - MoneriumB2bAccount: { - accountId: string; - /** Format: date-time */ - createdAt: string; - /** @description The client's payout address on Ethereum. */ - destination: string; - /** - * Format: date-time - * @description Set while the account is dormancy-paused. - */ - dormantSince: string | null; - /** @description The client's self-custodied recovery address. */ - fallbackAddress: string; - feeBps: number; - /** @description The account's on-chain forwarding contract. */ - forwarderAddress: string; - /** @description The account's dedicated IBAN; null until issuance completes. */ - iban: string | null; - /** @enum {string} */ - status: "onboarding" | "active" | "suspended" | "closed"; - }; - MoneriumB2bAccountResponse: { - account: components["schemas"]["MoneriumB2bAccount"]; - }; - MoneriumB2bDeposit: { - /** @description Deposit amount in 18-decimal base units of the deposit currency. */ - amountRaw: string; - /** @description The allocated conversion execution once the swap has run; null while the deposit awaits conversion. */ - conversion: { - executionId: string; - /** - * @description Execution status. - * @enum {string} - */ - status: "pending" | "confirmed" | "failed"; - /** @description The swap-and-forward transaction hash. */ - txHash: string | null; - /** @description Net USDC forwarded for the whole execution in 6-decimal base units. When one execution batches several deposits this is the execution total; per-deposit shares are proportional to amountRaw. */ - usdcNetRaw: string | null; - } | null; - /** Format: date-time */ - createdAt: string; - currency: string; - depositId: string; - /** - * @description Deposit status (forward-only). - * @enum {string} - */ - status: "pending" | "minted" | "held" | "returned"; - /** @description The on-chain mint transaction, when observed. */ - txHash: string | null; - }; - MoneriumB2bDepositsResponse: { - deposits: components["schemas"]["MoneriumB2bDeposit"][]; - pagination: { - limit: number; - offset: number; - total: number; - }; - }; }; responses: { /** @description The selected profile or the authenticated user does not own the requested provider resource. */ @@ -6490,6 +6490,99 @@ export interface operations { }; }; }; + getMoneriumB2bAccount: { + parameters: { + query?: never; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The acting profile's onramp account. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MoneriumB2bAccountResponse"]; + }; + }; + /** @description Missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Managed-profile authorization failed (foreign child, corridor or customer-type policy). */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No account exists for the acting profile. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listMoneriumB2bDeposits: { + parameters: { + query?: { + /** @description Page size (default 20, max 100). */ + limit?: number; + /** @description Rows to skip (default 0). */ + offset?: number; + }; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The acting profile's deposits with conversion status. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MoneriumB2bDepositsResponse"]; + }; + }; + /** @description Missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Managed-profile authorization failed (foreign child, corridor or customer-type policy). */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No account exists for the acting profile. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; selectActiveCustomerEntity: { parameters: { query?: never; @@ -7548,97 +7641,4 @@ export interface operations { }; }; }; - getMoneriumB2bAccount: { - parameters: { - query?: never; - header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ - "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The acting profile's onramp account. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MoneriumB2bAccountResponse"]; - }; - }; - /** @description Missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Managed-profile authorization failed (foreign child, corridor or customer-type policy). */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description No account exists for the acting profile. */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - listMoneriumB2bDeposits: { - parameters: { - query?: { - /** @description Page size (default 20, max 100). */ - limit?: number; - /** @description Rows to skip (default 0). */ - offset?: number; - }; - header?: { - /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ - "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The acting profile's deposits with conversion status. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MoneriumB2bDepositsResponse"]; - }; - }; - /** @description Missing or invalid credentials. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Managed-profile authorization failed (foreign child, corridor or customer-type policy). */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description No account exists for the acting profile. */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; } From 91c9463a2381964159f351d1d478eaaf6f666c22 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 18:27:32 +0200 Subject: [PATCH 49/59] =?UTF-8?q?docs(api):=20resolve=20T1=20=E2=80=94=20r?= =?UTF-8?q?ecovery=20validates=20the=20same=20ownership=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monerium confirmed (verbally, 2026-08-26) that issuer recovery presents the same 'I hereby declare that I am the address owner.' message as linking, whose hash the forwarder already whitelists: recovery works with the constrained validator as built, RECOVERY_HASH stays zero, and the pilot permissive-validator option is withdrawn. Written confirmation folds into G1 item 3. Also adds the O2 registry row: the immutable FEE_RECIPIENT treasury address and guardian key custody are deploy-time decisions. --- docs/prd/monerium-onramp-deferred-decisions.md | 5 +++-- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md index c79c0c708..0acf74c2d 100644 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ b/docs/prd/monerium-onramp-deferred-decisions.md @@ -27,8 +27,9 @@ accept or overrule. Rationale and history stay in the detail sections below. | P8 | `MAX_ORACLE_AGE` | 26 h in test configs | **52 h** | Decided 2026-07-17 (observed weekend gaps up to 48 h). Still needs applying to the fork/invariant test configs and the deploy config. | Decided — apply | | P9 | Notification confirmation depth | 32 blocks | **32 blocks** | Implemented (DEPOSIT_CONVERTED depth gate). | Done — close | | P10 | Router pin + fee tiers | SwapRouter02, 5 bps hops | **SwapRouter02; re-verify both pools/fee tiers at the deploy block** | G0 output; verification action, not a value. | Action at deploy | -| T1 | `RECOVERY_HASH` / issuer recovery | `bytes32(0)` (disabled) | **Open — see options below** | See the rewritten T1 row: question to Monerium still unsent; a pilot-only permissive validator is under consideration with material tradeoffs. | **OPEN** | +| T1 | `RECOVERY_HASH` / issuer recovery | `bytes32(0)` | **RESOLVED (verbal, 2026-08-26): the recovery flow validates the SAME ownership message as linking** — its hash is `LINK_HASH_191`, which the forwarder already whitelists, so issuer recovery works with the constrained validator as built. Keep `RECOVERY_HASH = 0` (slot reserved for a future distinct message). Pilot permissive-validator option (c) withdrawn. | The message is payout-neutral: the signature only proves address ownership; the recovery payout target (customer's own verified bank account) is Monerium's controlled process, satisfying the review-r1 requirement. **Get this in writing** — folded into G1 item 3. | Resolved (verbal) — written confirmation via G1 | | P11 | Guardian-settable `feeBps` (contract change) | — | **DECIDED + IMPLEMENTED (2026-08-26)**: guardian-only `setFeeBps`, capped by immutable `MAX_FEE_BPS`; increases announce on-chain and apply permissionlessly after `FEE_INCREASE_TIMELOCK = 24 hours` (Marcel's call); decreases (and cancel-via-restate) immediate. Monitoring reconciles guardian fee changes like owner-authorized config (warn + configVersion bump). | G2 must still scope the bounded pre-announced fee power. | Done — G2 scoping outstanding | +| O2 | Treasury (`FEE_RECIPIENT`) address + key custody at deploy | test addresses | **Decide before the implementation deploy — it is immutable for every clone**: FEE_RECIPIENT should be a Vortex-controlled Safe multisig, never an EOA env key. Guardian: EOA acceptable for the pilot, move to a hardware-backed key or multisig for GA (it now also holds the P11 fee power). Attestor/keeper stay env EOAs (bounded blast radius by design). | The treasury address is baked into the implementation; a wrong or lost-key choice is unfixable without a new implementation + clone migration. | Open — decide at deploy | | O1 | Client migration to a new clone (contract upgrades, or fee changes while P11 is unbuilt) | none (no tooling) | **Backend-enforced migration procedure**: admin endpoint that announces the migration (association monitor treats it as expected), waits its own delay, then links the new clone and moves the IBAN (`PATCH /ibans`) — with the invariant that Vortex tooling only ever targets factory clones (`isForwarder` + config read-back). No on-chain timelock is possible: the IBAN move is a Monerium API call the chain never sees; the preventive layer on Monerium's side is G1 item 4 (authorization requirements on `PATCH /ibans`). | Open — build when first needed | ## Business decisions (Marcel / partner) @@ -61,7 +62,7 @@ accept or overrule. Rationale and history stay in the detail sections below. | # | Item | Owner | Status | |---|---|---|---| -| T1 | **Monerium recovery-burn mechanism for contract addresses** — OPEN (2026-08-26). The issuer recovery flow validates a signature against the linked address via EIP-1271; the forwarder currently accepts only the link hash, so `RECOVERY_HASH = bytes32(0)` disables issuer recovery entirely. Options on the table: **(a) ship the pilot as-is** (no issuer recovery; the fallback-address sweep remains the client's recovery path; issuer backstop best-effort only); **(b) obtain the exact recovery message/hash from Monerium and whitelist it** — the preferred end state, but the question has still not been sent; **requirement (review r1)**: enable only if the message is parameterless or payout-neutral, since a parameterized message validated by our attestor would grant Vortex disposal discretion; **(c) pilot-only permissive validator (Marcel, 2026-08-26)**: accept that the pilot may need a less MiCA-clean variant where `isValidSignature` validates arbitrary attestor-signed messages so every Monerium-side flow (recovery included) works. **Recorded tradeoffs for (c)**: Monerium redeem orders also validate via EIP-1271, so a permissive validator reintroduces the redeem-to-arbitrary-IBAN path the constrained design exists to block — an attestor-key compromise becomes a fiat theft path, and the non-custody analysis changes because Vortex gains disposal capability (G2 must re-scope custody/MiCA before this ships; attestor key custody would need hardening, e.g. HSM; plan a migration back to the constrained validator for GA). Decision: Marcel + G2, before mainnet deploy | Monerium tech team (question), Marcel + G2 (pilot variant) | **OPEN — question not sent; pilot variant under consideration** | +| T1 | **Monerium recovery-burn mechanism for contract addresses** — OPEN (2026-08-26). The issuer recovery flow validates a signature against the linked address via EIP-1271; the forwarder currently accepts only the link hash, so `RECOVERY_HASH = bytes32(0)` disables issuer recovery entirely. Options on the table: **(a) ship the pilot as-is** (no issuer recovery; the fallback-address sweep remains the client's recovery path; issuer backstop best-effort only); **(b) obtain the exact recovery message/hash from Monerium and whitelist it** — the preferred end state, but the question has still not been sent; **requirement (review r1)**: enable only if the message is parameterless or payout-neutral, since a parameterized message validated by our attestor would grant Vortex disposal discretion; **(c) pilot-only permissive validator (Marcel, 2026-08-26)**: accept that the pilot may need a less MiCA-clean variant where `isValidSignature` validates arbitrary attestor-signed messages so every Monerium-side flow (recovery included) works. **Recorded tradeoffs for (c)**: Monerium redeem orders also validate via EIP-1271, so a permissive validator reintroduces the redeem-to-arbitrary-IBAN path the constrained design exists to block — an attestor-key compromise becomes a fiat theft path, and the non-custody analysis changes because Vortex gains disposal capability (G2 must re-scope custody/MiCA before this ships; attestor key custody would need hardening, e.g. HSM; plan a migration back to the constrained validator for GA). Decision: Marcel + G2, before mainnet deploy | Monerium tech team (question), Marcel + G2 (pilot variant) | **RESOLVED verbally 2026-08-26**: recovery validates the same ownership message as linking — already whitelisted as `LINK_HASH_191`; no contract change needed; option (c) withdrawn; written confirmation via G1 item 3 | | T2 | Chainlink EUR/USD weekend behavior → weekend policy | G0 spike | **Answered 2026-07-17**: rounds observed on Sat/Sun (deviation-triggered), gaps up to 48 h; see P8. Weekend policy: execute normally with `MAX_ORACLE_AGE ≥ 52 h` | | T6 | Liquidity baseline (review F12 reproducibility) | G0 spike | **Recorded 2026-07-17, mainnet block 25553101**, QuoterV2 on pinned path EURe→(500)→EURC→(500)→USDC: 1k → 1.14298, 5k → 1.14288, 10k → 1.14278, 25k → 1.14252 USDC/EURe (Chainlink same day 1.14410 — 25k within ~14 bps incl. 2×5 bps fees). Deeper than the 07-10 snapshot; €10k cap comfortable. Re-run at deploy + wire into monitoring (task 6) | | T3 | Corporate KYB mechanism under whitelabel: Monerium-run verification vs KYC-reliance (reliance requires licenses we may not hold) | Monerium MSA negotiation | Open — fold into G1 | diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 2f55bc258..d48447705 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -13,7 +13,7 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e ## Security Invariants 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 Monerium's recovery message (disabled as `bytes32(0)` until registry T1 resolves). `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)`: 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. 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 over raw bytes, constant-time** — the `webhook-signature` header is verified as HMAC-SHA256 of the RAW request bytes (captured by a body-parser `verify` hook scoped to this route, never a re-serialization of parsed JSON) using `crypto.timingSafeEqual`, with a self-compare on length mismatch so timing does not leak the mismatch position. 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. From 980a60e69cf00843a3b0e4fa0bbf862a5e0424d5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 20:58:49 +0200 Subject: [PATCH 50/59] test(api): apply the decided 52h oracle age to the contract configs P8 was decided 2026-07-17 (Chainlink EUR/USD weekend gaps observed up to 48h) and confirmed final today; the unit/fork/invariant configs still carried the 26h placeholder that would revert most weekends. --- .../monerium-forwarder/test/VortexForwarder.fork.t.sol | 2 +- .../test/VortexForwarder.invariants.t.sol | 2 +- contracts/monerium-forwarder/test/VortexForwarder.t.sol | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol index c4a7c2918..aff5591ec 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -53,7 +53,7 @@ contract VortexForwarderForkTest is Test { oracle: CHAINLINK_EUR_USD, attestor: attestor, feeRecipient: makeAddr("feeRecipient"), - maxOracleAge: 26 hours, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h slippageBps: 100, maxFeeBps: 100, sweepDelay: 60 days, diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol index dca035a76..b959d0b9f 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -46,7 +46,7 @@ contract ForwarderHandler is Test { oracle: address(oracle), attestor: vm.addr(0xA11CE), feeRecipient: feeRecipient, - maxOracleAge: 26 hours, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h slippageBps: 100, maxFeeBps: 100, sweepDelay: 60 days, diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 392b8d247..45f2dd90a 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -124,7 +124,7 @@ contract VortexForwarderTest is Test { oracle: address(oracle), attestor: attestor, feeRecipient: feeRecipient, - maxOracleAge: 26 hours, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h slippageBps: 100, maxFeeBps: 100, sweepDelay: SWEEP_DELAY, @@ -203,7 +203,7 @@ contract VortexForwarderTest is Test { oracle: address(oracle), attestor: attestor, feeRecipient: feeRecipient, - maxOracleAge: 26 hours, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h slippageBps: 100, maxFeeBps: 100, sweepDelay: SWEEP_DELAY, @@ -301,7 +301,7 @@ contract VortexForwarderTest is Test { _fund(1_000e18); router.setNextOut(1_130e6); oracle.set(1.14e8, block.timestamp); - skip(27 hours); + skip(53 hours); // just past the 52h P8 window vm.prank(keeper); vm.expectRevert(VortexForwarder.StalePrice.selector); fwd.swapAndForward(); @@ -419,7 +419,7 @@ contract VortexForwarderTest is Test { oracle: address(oracle), attestor: attestor, feeRecipient: feeRecipient, - maxOracleAge: 26 hours, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h slippageBps: 100, maxFeeBps: 100, sweepDelay: SWEEP_DELAY, From a8156a73e098374af1ac37bf06bd3f4b60e9a144 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 26 Aug 2026 21:08:23 +0200 Subject: [PATCH 51/59] docs(repo): consolidate monerium b2b docs into the maintained set The docs/prd working set and docs/runbooks are absorbed into four maintained documents per docs/README.md rules: adr-0005 (decisions, final parameter registry, accepted risks), the architecture doc, operations-monerium-b2b-rollout (gates, deploy checklist, terms inputs), and operations-monerium-b2b-runbook (onboarding, incidents, triage, dormancy, migration). The consumer PRD survives as proposal-monerium-consumer-onramp; everything else lives in git history. All code and spec cross-references repointed. --- .../src/api/services/monerium-b2b/chain.ts | 4 +- .../monerium-b2b/conversion-executor.test.ts | 2 +- .../src/api/services/monerium-b2b/dormancy.ts | 4 +- .../api/services/monerium-b2b/monitoring.ts | 4 +- apps/api/src/config/vars.ts | 2 +- .../069-monerium-b2b-onramp-tables.ts | 2 +- .../070-monerium-keeper-chain-state.ts | 2 +- apps/api/src/models/moneriumAccount.model.ts | 2 +- .../src/models/moneriumFiatDeposit.model.ts | 2 +- contracts/monerium-forwarder/README.md | 9 +- .../script/manifest-core.ts | 2 +- .../src/VortexForwarder.sol | 2 +- docs/README.md | 4 + docs/adr-0005-monerium-b2b-onramp.md | 150 +++ docs/architecture-monerium-b2b-onramp.md | 7 +- docs/operations-monerium-b2b-rollout.md | 134 +++ docs/operations-monerium-b2b-runbook.md | 274 +++++ docs/prd/monerium-b2b-code-review-r1.md | 174 ---- docs/prd/monerium-b2b-implementation-plan.md | 99 -- docs/prd/monerium-b2b-terms-inputs.md | 106 -- ...m-eur-usdc-onramp-architecture-rereview.md | 411 -------- ...ium-eur-usdc-onramp-architecture-review.md | 934 ------------------ .../monerium-eur-usdc-onramp-b2b-variant.md | 151 --- .../prd/monerium-onramp-deferred-decisions.md | 99 -- ...d => proposal-monerium-consumer-onramp.md} | 8 +- docs/runbooks/monerium-b2b-dormancy.md | 64 -- docs/runbooks/monerium-b2b-incident.md | 112 --- docs/runbooks/monerium-b2b-onboarding.md | 123 --- .../05-integrations/monerium-b2b.md | 6 +- 29 files changed, 594 insertions(+), 2299 deletions(-) create mode 100644 docs/adr-0005-monerium-b2b-onramp.md create mode 100644 docs/operations-monerium-b2b-rollout.md create mode 100644 docs/operations-monerium-b2b-runbook.md delete mode 100644 docs/prd/monerium-b2b-code-review-r1.md delete mode 100644 docs/prd/monerium-b2b-implementation-plan.md delete mode 100644 docs/prd/monerium-b2b-terms-inputs.md delete mode 100644 docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md delete mode 100644 docs/prd/monerium-eur-usdc-onramp-architecture-review.md delete mode 100644 docs/prd/monerium-eur-usdc-onramp-b2b-variant.md delete mode 100644 docs/prd/monerium-onramp-deferred-decisions.md rename docs/{prd/monerium-eur-usdc-onramp.md => proposal-monerium-consumer-onramp.md} (98%) delete mode 100644 docs/runbooks/monerium-b2b-dormancy.md delete mode 100644 docs/runbooks/monerium-b2b-incident.md delete mode 100644 docs/runbooks/monerium-b2b-onboarding.md diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index 9c302c23f..21024c2df 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -16,7 +16,7 @@ import { config } from "../../../config/vars"; /** * viem clients + minimal hand-written ABI surface for the B2B keeper - * (docs/prd/monerium-b2b-implementation-plan.md §3, "Keeper"). + * (docs/architecture-monerium-b2b-onramp.md §3, "Keeper"). * * Key separation is an invariant (security-spec/05-integrations/monerium-b2b.md): * keeper key (swap submission) != guardian key (protective pause) != attestor key @@ -29,7 +29,7 @@ export const DEFAULT_PRIVATE_RPC_URL = "https://rpc.flashbots.net"; /** * Client notification confirmation depth in blocks — registry P9 - * (docs/prd/monerium-onramp-deferred-decisions.md). Not consumed by the keeper itself + * (docs/adr-0005-monerium-b2b-onramp.md). Not consumed by the keeper itself * (execution finality is handled via receipt + reorg-safe deposit identity); reserved * for the notification job (plan §3, "Notifications"). */ diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 6ade112ff..ac2d8095a 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { AllocatableDeposit, allocateUsdcProRata, classifyHashlessPending, selectDepositsForExecution } from "./conversion-executor"; -// R04 attribution (docs/prd/monerium-b2b-implementation-plan.md §3): pro-rata by +// R04 attribution (docs/architecture-monerium-b2b-onramp.md §3): pro-rata by // amount_raw against eureInRaw, floor division, remainder to the largest deposit. // No chain or database involved — pure math. diff --git a/apps/api/src/api/services/monerium-b2b/dormancy.ts b/apps/api/src/api/services/monerium-b2b/dormancy.ts index 9d2a0ec26..7ddc10955 100644 --- a/apps/api/src/api/services/monerium-b2b/dormancy.ts +++ b/apps/api/src/api/services/monerium-b2b/dormancy.ts @@ -14,10 +14,10 @@ import { forwarderAbi, getGuardianWalletClient, getPublicClient } from "./chain" * * 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 (deferred-decisions registry B5). + * partner-agreement item (adr-0005 registry B5). */ -/** Dormancy pause window — registry P5 (docs/prd/monerium-onramp-deferred-decisions.md). */ +/** Dormancy pause window — registry P5 (docs/adr-0005-monerium-b2b-onramp.md). */ export const DORMANCY_WINDOW_MS = 60 * 24 * 60 * 60 * 1000; export interface DormancyAccountFields { diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index e0cba66e2..63ab100e7 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -271,7 +271,7 @@ export async function runExecutableDepthCheck(): Promise { // PAUSE THRESHOLD (PRD §7.4): even minimum-size swaps would revert on minOut. logger.error( "monerium-b2b: PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS; engage guardian pause per " + - `docs/runbooks/monerium-b2b-incident.md. ${detail}` + `docs/operations-monerium-b2b-runbook.md. ${detail}` ); } else if (capImpactBps > slippageBps) { logger.warn(`monerium-b2b: executable depth below perSwapCap — cap-sized swaps would revert on minOut. ${detail}`); @@ -337,7 +337,7 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi * Association monitor (S1 detective control): compares the Monerium-side linked * addresses + IBAN state per active account against the DB record and alerts on ANY * change. Error-level: an unexplained association change is an incident trigger - * (docs/runbooks/monerium-b2b-incident.md). + * (docs/operations-monerium-b2b-runbook.md). */ export async function runAssociationMonitor(): Promise { const accounts = await monitoredAccounts([MoneriumAccountStatus.Active]); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 71b313d00..989d7d1c4 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -220,7 +220,7 @@ interface Config { clientId: string; redirectUri: string; }; - // B2B whitelabel onramp integration (docs/prd/monerium-b2b-implementation-plan.md §3). + // B2B whitelabel onramp integration (docs/architecture-monerium-b2b-onramp.md §3). // Separate credential set from the legacy consumer OAuth integration above. moneriumB2b: { attestorPrivateKey: string | undefined; diff --git a/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts b/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts index d4cd89ce2..c7b797b60 100644 --- a/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts +++ b/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts @@ -1,6 +1,6 @@ import { DataTypes, QueryInterface } from "sequelize"; -// B2B zero-touch onramp persistence (docs/prd/monerium-b2b-implementation-plan.md §3). +// B2B zero-touch onramp persistence (docs/architecture-monerium-b2b-onramp.md §3). // Deliberately separate from ramp_states: a Monerium IBAN account is permanent and // repeatedly funded, not a one-shot ramp. export async function up(queryInterface: QueryInterface): Promise { diff --git a/apps/api/src/database/migrations/070-monerium-keeper-chain-state.ts b/apps/api/src/database/migrations/070-monerium-keeper-chain-state.ts index e96c025e9..f01885248 100644 --- a/apps/api/src/database/migrations/070-monerium-keeper-chain-state.ts +++ b/apps/api/src/database/migrations/070-monerium-keeper-chain-state.ts @@ -1,6 +1,6 @@ import { DataTypes, QueryInterface } from "sequelize"; -// Keeper chain state for the B2B onramp (docs/prd/monerium-b2b-implementation-plan.md §3): +// Keeper chain state for the B2B onramp (docs/architecture-monerium-b2b-onramp.md §3): // - monerium_chain_cursors: persisted getLogs cursors for the poll-based EURe mint // watcher, keyed by watcher name (one row per watcher+chain). // - monerium_fiat_deposits.block_number: the mint block, required by the R04 attribution diff --git a/apps/api/src/models/moneriumAccount.model.ts b/apps/api/src/models/moneriumAccount.model.ts index f630c2e11..72e008be2 100644 --- a/apps/api/src/models/moneriumAccount.model.ts +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -8,7 +8,7 @@ export enum MoneriumAccountStatus { Closed = "closed" } -// Persistent B2B onramp account (docs/prd/monerium-b2b-implementation-plan.md §3): +// Persistent B2B onramp account (docs/architecture-monerium-b2b-onramp.md §3): // one row per client = one Monerium profile + IBAN + deployed forwarder. Long-lived, // repeatedly funded — deliberately NOT a RampState. profileId is the MONERIUM profile // UUID; vortexProfileId is the owning Vortex managed profile (nullable only for rows diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts index 7208370d9..715acc558 100644 --- a/apps/api/src/models/moneriumFiatDeposit.model.ts +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -91,7 +91,7 @@ MoneriumFiatDeposit.init( type: DataTypes.STRING(66) }, // Mint block, set by the mint watcher; the R04 attribution rule compares it to the - // execution block (docs/prd/monerium-b2b-implementation-plan.md §3). + // execution block (docs/architecture-monerium-b2b-onramp.md §3). blockNumber: { allowNull: true, field: "block_number", diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index 5532e407a..0d7517c65 100644 --- a/contracts/monerium-forwarder/README.md +++ b/contracts/monerium-forwarder/README.md @@ -5,11 +5,10 @@ per-client EIP-1167 clones whose EIP-1271 `isValidSignature` accepts only the fi Monerium link message from the Vortex attestor, with an immutable EURe→EURC→USDC conversion policy and client-controlled recovery. -- Spec: [docs/prd/monerium-b2b-implementation-plan.md](../../docs/prd/monerium-b2b-implementation-plan.md) §2 - and [docs/prd/monerium-eur-usdc-onramp-b2b-variant.md](../../docs/prd/monerium-eur-usdc-onramp-b2b-variant.md) -- All placeholder parameter values (slippage, delays, caps, fee) are tracked in - [docs/prd/monerium-onramp-deferred-decisions.md](../../docs/prd/monerium-onramp-deferred-decisions.md) — - do not treat values in code as final. +- Spec: [docs/architecture-monerium-b2b-onramp.md](../../docs/architecture-monerium-b2b-onramp.md) §2 +- Parameter values (slippage, delays, caps, fee) are decided in + [docs/adr-0005-monerium-b2b-onramp.md](../../docs/adr-0005-monerium-b2b-onramp.md) — + that table, not values hardcoded in tests or scripts, is authoritative. ```bash git submodule update --init # once per clone: fetches lib/forge-std diff --git a/contracts/monerium-forwarder/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts index 42aae9c48..7c6f5c103 100644 --- a/contracts/monerium-forwarder/script/manifest-core.ts +++ b/contracts/monerium-forwarder/script/manifest-core.ts @@ -3,7 +3,7 @@ import { Address, getAddress, Hex, keccak256, PublicClient, parseAbi, parseAbiIt /** * Shared chain-reading core for generate-manifest.ts / verify-manifest.ts. * - * R01 (docs/prd/monerium-b2b-implementation-plan.md §5): the manifest produced from + * R01 (docs/architecture-monerium-b2b-onramp.md §5): the manifest produced from * this state is CONSISTENCY EVIDENCE, NOT A TRUST ROOT. It is generated by Vortex from * the same chain state it attests to, so it cannot prove the deployment was honest — * only that the deployment has not silently changed since the manifest was published. diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index 709214bd5..fed166267 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -40,7 +40,7 @@ interface IVortexForwarderFactory { /// @title VortexForwarder /// @notice Per-client forwarding account for the Monerium B2B onramp -/// (docs/prd/monerium-eur-usdc-onramp-b2b-variant.md, implementation plan §2). +/// (docs/architecture-monerium-b2b-onramp.md §2). /// Deployed as an EIP-1167 clone by VortexForwarderFactory; the clone address is /// linked to the client's Monerium profile, EURe mints land here, and the only /// ways assets can ever leave are: diff --git a/docs/README.md b/docs/README.md index c883dca9c..b767fa270 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,15 +20,19 @@ The smaller set of general project documents stays directly in `docs/`: | [`adr-0002-alfredpay-fee-collection.md`](adr-0002-alfredpay-fee-collection.md) | Accepted decision on Alfredpay fee collection and sequential EVM fee distribution | | [`adr-0003-managed-headless-profiles.md`](adr-0003-managed-headless-profiles.md) | Accepted identity, ownership, authorization, and lifecycle decisions for managed headless profiles | | [`adr-0004-sandbox-demo-environment.md`](adr-0004-sandbox-demo-environment.md) | Accepted decision on the seeded sales-demo account in the sandbox environment | +| [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md) | Accepted decisions, final parameter registry, and accepted risks for the Monerium B2B onramp | | [`architecture-email-notifications.md`](architecture-email-notifications.md) | Current transactional/auth email architecture: queue, dispatch, producers | | [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | | [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md) | Current end-to-end architecture of the B2B EUR onramp: onboarding, deposit-to-payout, batching, fees, data model | | [`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-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) | Focused white-label Monerium 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 | | [`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 | | [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md new file mode 100644 index 000000000..02e507f49 --- /dev/null +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -0,0 +1,150 @@ +# ADR 0005: Monerium B2B Zero-Touch Onramp + +**Status:** Accepted (selected 2026-07-17; parameters finalized and documents consolidated +2026-08-26). This ADR is the single source of truth for the *decisions and risk +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 +[`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: +[`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md). + +## Context + +Partner-sourced business clients (SulPayments' 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. + +## Decision: the attestor-constrained forwarder + +Each client gets a dedicated minimal forwarding contract (`VortexForwarder`, an EIP-1167 +clone of one immutable implementation, deployed via a CREATE2 factory and initialized +atomically) whose `isValidSignature` accepts **exactly one construction**: the Vortex +attestor key's signature over `keccak256(chainid ‖ address(this) ‖ hash)` where `hash` +is the EIP-191 hash of the fixed ownership message. Vortex can therefore complete the +Monerium link with no client involvement, while the attestor key is provably not a means +of access to funds. + +**Why the naive alternative is unsafe:** Monerium validates *redeem orders* ("Send EUR +`` to `` …") through the same EIP-1271 interface. A general-purpose +validation key would let its holder redeem the client's EURe to an arbitrary IBAN — +a fiat theft path and unambiguous custody. The whole design follows from closing it. + +Supporting decisions, all in force: + +- **Conversion policy** (unchanged from the consumer design): pinned EURe→EURC→USDC + Uniswap v3 route, contract-constructed calldata (never caller-supplied — the swap is + permissionless after the trigger delay), Chainlink EUR/USD minimum-output bound with + staleness ceiling, exact approvals, atomic delta checks, fee skim to an immutable + treasury. +- **No upgradeability, ever.** Immutable-and-migratable: evolution (new pools, new + routes, contract fixes) happens by deploying a new implementation + factory and + migrating clients clone-by-clone — never by mutating deployed code. The custody + argument depends on it. +- **Mandatory self-custodied `fallbackAddress`** for every client (Tier C "no fallback" + dropped 2026-07-17 — condition of Monerium's acceptance; Tier B "partner-held + recovery key" rejected 2026-07-14 — the partner declines custody-like powers). All + emergency flows point to it: client `sweep`/config functions plus the permissionless + dead-man sweep. +- **Never send raw EURe to a CEX destination** — EURe recovery targets are the + fallback address only. +- **No on-contract redeem validator.** Redemption = withdraw to fallback, then redeem + normally; Monerium's issuer recovery is the break-glass backstop (see T1 below). +- **EIP-191 hash only, chainid-bound** (the raw-keccak variant was removed after the G0 + sandbox validation; chainid binding closes cross-chain replay — review r1). +- **Three distinct Vortex keys** (attestor / keeper / guardian), none able to move or + redirect funds; the keeper runs on exactly one backend (the mykobo flow variant). +- **Managed-profile integration:** each client is a managed child profile under the + partner manager (KYB mirror, credentials, read API, webhook tenancy). The flow is + quoteless and deliberately **not** part of `ramp_states` — evaluated and rejected + 2026-08-26 (N:M deposit↔execution batching, no quote, phase-machinery mismatch); a + read-level projection is the path if unified history is ever wanted. Tables stay + `monerium_*` (the legacy OAuth integration owns no tables; no collision). +- **Deposit webhooks as a generic event family** (`DEPOSIT_RECEIVED` / + `DEPOSIT_CONVERTED`) on the public webhook contract, delivered durably (outbox, + at-least-once) to the partner manager. + +## Final parameters (decided 2026-08-26 unless noted) + +| ID | Parameter | Value | +|---|---|---| +| B1 | Service fee | **0 bps pilot / 15 bps GA starting point** (per client, guardian-adjustable) | +| B2 | Penny-test amount | 5 USDC | +| B3 | Processing SLA wording | **Same business day**; weekend mints execute within the 52 h oracle window at possibly wider spreads | +| B4 | Pilot volume limits | **€50k/client/day, paper/contractual only** (no backend enforcement in the pilot; GA revisit) | +| B5 | Partner liability | Tier A defaults: partner warrants destination correctness; rotation loss borne by the client; dormancy re-activation on written partner confirmation | +| B6 | Redemption-limitation disclosure | Mandatory in client terms (committed to Monerium); draft in the rollout doc | +| P1 | `SLIPPAGE_BPS` | 100 (1%) | +| P2 | `MAX_FEE_BPS` | 100 (1%), immutable | +| P3 | Dead-man sweep delay | 60 days | +| P4 | Permissionless trigger delay | 24 h | +| P5 | Dormancy window | 60 days | +| P6 | `minSwapAmount` | floor €25 (immutable) / operational **€250** | +| P7 | `perSwapCap` | operational **€25k** / ceiling €50k (re-measure liquidity at the deploy block before raising) | +| P8 | `MAX_ORACLE_AGE` | **52 h** (observed Chainlink EUR/USD weekend gaps up to 48 h; applied to configs 2026-08-26) | +| P9 | Notification confirmation depth | 32 blocks (implemented) | +| P10 | Router pin | SwapRouter02, 5 bps fee tiers; re-verify pools at the deploy block | +| P11 | Fee adjustability | Guardian `setFeeBps` within `MAX_FEE_BPS`; increases behind a 24 h announced timelock, decreases immediate (implemented) | +| T2 | Whitelabel MSA terms | Open — G1 negotiation (rollout doc), includes the per-IBAN suspension ask | +| T3 | KYB submission mechanism | Open, deliberately unbuilt — pilot corporates are approved by Monerium under partner KYC reliance and imported via the admin mapping; no identity-data submission path may exist until this settles (security-spec invariant 11) | +| T4 | Sandbox wire-format verifications | Webhook digest encoding, delivery id field, order-state vocabulary, and the EIP-191 link-hash variant were confirmed against the sandbox during G0; re-verify against production before first mainnet deposit | +| 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 | +| 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 | + +## Review history (details in git history) + +The consumer PRD went through an 18-finding architecture review (dispositioned in the +PRD's appendix) and a 12-finding re-review; the B2B build dispositioned the re-review as +follows — R01 manifest is consistency evidence, not a trust root (accepted); R03 +enforceable delay start via the on-chain `strandedSince` marker (resolved); R04 +snapshot-based attribution under per-forwarder advisory locks (resolved); R05 per-clone +protective-only guardian pause (resolved); R06 durable webhook persist-before-200 +(resolved); R07 client config changes reconciled as expected transitions (accepted); R09 +unsolicited-token rules incl. flagged unattributed inflows (resolved); R10 role/parameter +bound invariants as audit targets (resolved); R11 exit guarantees scoped to fallback-key +availability (accepted); R02/R08 consumer-only. A 10-finding internal code review (r1) +was fixed/dispositioned in July, and a 19-finding deep review (multi-lens + adversarial +verification) was fully fixed 2026-08-26, plus one attribution defect found by worked +example (oversized-deposit allocation). + +## Risks accepted (with their compensating controls) + +- **S0 — provisioning trust.** Vortex deploys and configures the contracts with no + 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) + with penny test, dormancy gate, and minimum-forward diligence. Silent-loss risk + converts to a pause via the dormancy gate. +- **Fallback-key loss + broken destination** — ordinary self-custody residual, borne + by the client (terms; do not overpromise exits — R11). +- **Non-custody ≠ out of MiCA scope.** The constrained-attestor construction defeats + the custody definition, but exchange/transfer-service scoping is a separate G2 + question. Never present "no custody" as "no licence needed". +- **Stuck-state table** (route death, feed retirement, depeg beyond bound, blacklisted + destination): all fail-safe — swaps revert, funds accumulate as EURe, client exits + keep working; recovery is client-side sweep plus the issuer backstop. Accepted. +- **Operational residuals:** reorgs deeper than the watcher's 12-block lag; + financial-operation claim-crash windows require manual reconciliation; deposit + batching is intra-client only and pro-rata attribution never changes a client's + effective rate. + +## Consequences + +Zero-touch onboarding works end to end (validated against the Monerium sandbox: link +accepted, IBAN issued, no client interaction). Clients keep unilateral exits that no +Vortex failure can block. The cost: every rescue path must be designed in upfront +(no universal owner key), fee/venue changes are governed by timelocks and migrations +rather than admin switches, and Vortex accepts elevated provisioning trust plus a +control-plane risk at Monerium that only contract terms and monitoring can bound. diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index b6033c821..c3c49766d 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -3,9 +3,10 @@ Current end-to-end architecture of the quoteless EUR → USDC onramp for KYB'd corporate clients. Normative security detail lives in [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md); -open parameters and their recommended values in -[`prd/monerium-onramp-deferred-decisions.md`](prd/monerium-onramp-deferred-decisions.md); -operator procedures in [`runbooks/monerium-b2b-onboarding.md`](runbooks/monerium-b2b-onboarding.md). +decisions, final parameters, and accepted risks in +[`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); +launch gates in [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md); +operator procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). ## The shape in one paragraph diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md new file mode 100644 index 000000000..30b515340 --- /dev/null +++ b/docs/operations-monerium-b2b-rollout.md @@ -0,0 +1,134 @@ +# 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 +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 — + 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 + SulPayments 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 power (P11). + +**G3 — external contract audit.** Parameters are final (ADR); the internal reviews and +the invariant suite are done, but this moves client funds. + +**G4 — pilot.** SulPayments agreement signed (terms inputs below), reliance +attestations per customer, 3–5 clients at **€50k/client/day** (paper control), fee 0. + +## Deploy checklist (mainnet bring-up) + +1. **Treasury first (O2):** create the dedicated fee Safe multisig — `FEE_RECIPIENT` is + immutable in the implementation. Confirm guardian key custody plan (EOA acceptable + for pilot; hardware/multisig at GA). +2. Re-verify the pinned pools and fee tiers at the deploy block (P10) and re-run the + liquidity baseline quote methodology (T6); confirm `perSwapCap` €25k still executes + within the slippage bound. +3. Deploy implementation + factory with the final parameters (ADR table: 52 h oracle + age, 100 bps slippage/fee cap, 60 d/24 h/60 d delays, €25 floor/€50k ceiling); set + operational `minSwapAmount` €250 and `perSwapCap` €25k; register the keeper key. +4. Verify factory + implementation source on the block explorer; generate, verify, and + publish the manifest. +5. Production whitelabel credentials from Monerium; configure the keeper backend (the + mykobo flow variant only): credentials, attestor/keeper/guardian keys (three distinct; + keeper funded), read RPC + private orderflow RPC, webhook secret. +6. Register the webhook endpoint at Monerium (`profile.updated`, `iban.updated`, + `order.created`, `order.updated`). +7. Sandbox residue before first production onboarding: simulate a SEPA deposit end to + end (dashboard → Receive → "Simulate bank transfer") and pin the three + `TODO(sandbox)` items (webhook digest encoding, delivery id field, order-state + vocabulary). +8. SulPayments side: manager profile configured (EU corridor, business type), secret + credential issued, deposit-event webhook registered and verifying signatures against + `GET /v1/public-key`. +9. Per client: runbook §1 (deploy clone → map → automated link/IBAN → penny test → + activate). + +## Terms & disclosure inputs (engineering-accurate; G2/partner own final wording) + +1. **Redemption limitation (B6 — mandatory, committed to Monerium).** Draft: + > EURe received at your dedicated forwarding address cannot be redeemed directly + > with Monerium from that address. If you need to redeem EURe (rather than receive + > the automatic USDC conversion), you must first withdraw it to your fallback + > address — from which you can redeem normally — or use Monerium's recovery + > process, which pays out only to your own verified bank account. + + (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: 5 USDC penny test before activation, + the 60-day dormancy gate, minimum forward at or above the destination's minimum + deposit, and never sending unconverted EURe to the destination. Destination changes + are client-only (fallback key); Vortex cannot redirect funds. +3. **Dormancy re-confirmation (P5/B5).** Draft: + > If no conversion completes for 60 days, forwarding pauses automatically and + > resumes only after you (or the partner on your behalf, in writing) re-confirm your + > payout address. Deposits made while paused remain in your forwarding account and + > convert after re-confirmation; your fallback-address rights are unaffected. +4. **Fees (B1/P1/P2)** — disclose fee and conversion bound separately: + - Service fee: per-client percentage set at account creation (**pilot 0; GA + starting point 15 bps**), assessed on gross USDC output, contractual ceiling + equal to the on-chain cap (1%). Increases require a 24 h on-chain pre-announcement + (P11); decreases are immediate. + - Conversion bound (not a fee): each conversion delivers at least the Chainlink + EUR/USD reference rate minus 1%, or it does not execute (deposits wait and + retry). Enforced by the contract assuming an honest oracle; not a principal + guarantee under oracle failure or a stablecoin collapse beyond the bound. + - Batching never changes a client's effective rate: co-converted deposits split fee + and output pro-rata by amount. +5. **Processing SLA (B3 — decided: same business day).** Draft: + > Deposits at or above the minimum convert the same business day under normal + > market conditions. Conversions also execute on weekends; the EUR/USD reference + > rate updates less frequently outside FX market hours (staleness ceiling 52 h), so + > weekend conversions may execute at a rate up to that age — always within the + > conversion bound. Deposits below the minimum accumulate until it is reached. + + Include: the SLA is a service target, not a guarantee; keeper outages beyond 24 h + open a permissionless execution path, so conversion does not depend on Vortex. +6. **Vortex powers & self-custody disclosure.** What Vortex can do: deploy the account, + run the conversion, pause it, tune bounded parameters, adjust the fee within the + disclosed cap and timelock. What Vortex cannot do: move, redeem, or redirect funds — + every exit target is client-controlled, and pauses never block the fallback rights + or the delayed automatic sweep. Exit guarantees are scoped to the client's continued + control of their fallback key (loss of that key plus a broken destination is an + ordinary self-custody residual, borne by the client). Vortex cannot prevent inbound + SEPA to an issued IBAN; deposits during a pause accumulate safely as EURe. + +## 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 | +| SulPayments agreement (terms above) | Marcel ↔ partner | Drafting inputs ready | +| Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering (needs Marcel's sandbox login) | Open — only remaining engineering unknown | +| Fee Safe multisig creation | Ops | Before implementation deploy | +| GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md new file mode 100644 index 000000000..097697484 --- /dev/null +++ b/docs/operations-monerium-b2b-runbook.md @@ -0,0 +1,274 @@ +# Monerium B2B Onramp — Operations Runbook + +All operator procedures for the B2B onramp in one place: onboarding, incident response, +alert triage, dormancy, and client migration. Architecture: +[`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); decisions +and parameters: [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); +security invariants: +[`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md). + +Ground rules that shape every procedure here: + +- **Vortex powers are delay-only.** Guardian/keeper can pause and execute the policy — + never move or redirect funds. There is no Vortex-side rescue path by design. +- **Pauses never trap client funds.** `fallbackAddress` functions (`sweep`, + `setDestination`, `setFallbackAddress`, `setClientPaused`) and the permissionless + dead-man sweep (`sweepStrandedEure`, after 60 days) work while paused. Do not promise + otherwise in comms. +- **Never send raw EURe to a CEX destination.** EURe recovery targets are + `fallbackAddress` only. + +## 1. Client onboarding + +Deploy → manifest → verify → map → (automated: link + IBAN) → penny test → activate. +One pass per client. Prerequisites: guardian key funded on the target chain; +`MONERIUM_B2B_*` env set on the keeper backend; partner paperwork complete; the client +company onboarded and KYB-approved on Monerium's side (partner KYC reliance) with its +Monerium profile UUID at hand; the partner configured as a managed-profile manager +(`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type +`business`). + +### 1.1 Paperwork inputs (from the partner agreement) + +- `destination` — client's payout address. CEX deposit addresses allowed; validate: + EIP-55 checksum, not zero/dead/precompile/token/router (the contract re-rejects + token/router/self at init), warn-and-attest for contract addresses and CEX addresses + (rotation risk — terms). +- `fallbackAddress` — client's **self-custodied** recovery address. Mandatory, no + exceptions (Monerium acceptance condition). Must be distinct from custodial/CEX + addresses. +- `feeBps` — per-client; pilot `0`, GA starting point 15 bps (ADR B1). Adjustable + later via the guardian's timelocked setter. +- Signed terms including the redemption-limitation disclosure (rollout doc, Terms §1). + +### 1.2 Deploy the forwarder clone + +```bash +# predict, then deploy (guardian-only); salt = any unused bytes32, convention: client index +cast call $FACTORY "predictAddress(bytes32)(address)" $SALT --rpc-url $RPC +cast send $FACTORY "deployForwarder(address,address,uint16,bytes32)" \ + $DESTINATION $FALLBACK $FEE_BPS $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). +Record the forwarder address + deploy tx hash. + +### 1.3 Manifest: generate, verify, publish + +From `contracts/monerium-forwarder/`: + +```bash +bun script/generate-manifest.ts $FACTORY $RPC manifests/-$FACTORY.json +bun script/verify-manifest.ts manifests/-$FACTORY.json $RPC # must PASS +``` + +(Some free RPCs refuse historical `eth_getLogs`; add `--logs-rpc ` for the +event enumeration.) Publish the manifest (commit + public location). The manifest is +**consistency evidence, not a trust root**: it lets anyone detect silent changes; the +verified source on the block explorer is what proves the deployment honest — verify it +there as part of this step. + +### 1.4 Map the client to a managed profile + +One idempotent admin call creates the managed child (business entity under the partner +manager), imports the Monerium KYB approval, verifies the deployed clone on chain, and +records the account (status `onboarding`): + +``` +POST /v1/admin/monerium-b2b/accounts (Authorization: Bearer $ADMIN_SECRET) +{ + "managerProfileId": "", + "externalSubjectId": "", + "contactEmail": "", + "moneriumProfileId": "", + "forwarderAddress": "", + "destination": "", + "fallbackAddress": "", + "feeBps": 0 +} +``` + +Replaying the identical call is safe (200); divergent input is a 409, never an +overwrite. + +### 1.5 Link + IBAN (automated) + +The keeper's onboarding step picks up every mapped `onboarding` account and, +exactly-once via the profile-scoped `financial_operations` ledger: links the forwarder +with the attestor signature (`POST /addresses` — HTTP 201, `state: linked`, zero client +interaction), then requests IBAN issuance (`POST /ibans`, async 202). The IBAN lands on +the account row via the `iban.updated` webhook; from then on the association monitor +treats the DB record as the reference state. Nothing to do manually — verify the row +has its IBAN before the penny test, and check the logs if it stays empty for more than +a few cycles. + +### 1.6 Penny test + +Prove the destination actually credits contract-originated USDC transfers (CEXes can +rotate or mis-credit) before real volume flows: + +1. Send a small SEPA deposit to the new IBAN (sandbox: dashboard → Receive → "Simulate + bank transfer"). Target forward amount: 5 USDC (ADR B2). +2. The keeper converts automatically once the balance reaches `minSwapAmount`; for a + sub-minimum penny test, temporarily lower `minSwapAmount` (guardian, bounded by the + floor) or fund up to the minimum. +3. **Partner/client confirms credit at the destination** in writing (a terms diligence + commitment). + +### 1.7 Activate + +``` +PATCH /v1/admin/monerium-b2b/accounts//status (Authorization: Bearer $ADMIN_SECRET) +{ "status": "active" } +``` + +(Refused with 409 while no IBAN is recorded.) Confirm the next monitoring pass picks +the account up cleanly, then hand the IBAN to the client via the partner. + +Failure at any step: nothing is at risk — the forwarder holds no funds until the client +wires EUR, and every recovery path is live from deployment. + +## 2. Incident response + +### 2.1 Pause procedures + +Guardian key = `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`; `$FACTORY` from the published +manifest. + +```bash +# Per-clone pause (one client — compliance hold, dormancy, targeted issue) +cast send "setGuardianPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY +# Global pause (all clones — protocol-level incident) +cast send $FACTORY "setGlobalPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY +# Availability lever: reduce the per-swap cap (instant, bounded by immutables) +cast send $FACTORY "setPerSwapCap(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Both pauses block `swapAndForward` only; unpause = same call with `false`. + +### 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. + +### 2.3 Client notification + +Clients have no Vortex UI; comms run through the partner plus direct email: notify the +partner ops contact first; email affected clients (**stop sending EUR to your IBAN until +further notice**; deposits already sent convert after resolution or are recoverable via +the fallback address — nothing is lost by pausing); status page entry if global. + +### 2.4 Critical-vulnerability sequence (the 02:00-UTC drill) + +Suspected vulnerability in `VortexForwarder`/factory: + +1. **Pause all** (`setGlobalPaused(true)`) — instant, protective-only, reversible. +2. **Ask Monerium to suspend affected IBANs** (§2.2) so no new EURe mints. +3. **Notify** partner + clients (§2.3). +4. **Assess.** Funds at risk = EURe balances on forwarders (stranded-balance monitor + output, or `cast call "balanceOf(address)" `); run the manifest + verifier against the live deployment. +5. **If funds must move: only clients can move them.** Instruct clients (via partner) + to sweep EURe with their fallback key: `sweep(EURE, )` from + `fallbackAddress` — provide exact calldata and a verification walkthrough. The + issuer recovery backstop (burn + payout to the client's own bank account; validates + the already-whitelisted ownership message) is the last resort. +6. **Ship the fix as a migration** (§5): new implementation + factory (new audit), new + clones, re-link, move IBANs, penny-test, republish the manifest. Old clones stay + paused; residual balances leave via fallback or dead-man sweep. +7. **Unpause / decommission** only contracts confirmed unaffected. + +### 2.5 Whitelabel-credential compromise (S1) + +On an unexplained `ASSOCIATION CHANGE` alert or any suspicion the Monerium credentials +leaked: treat as an active incident. First hour: (1) rotate the whitelabel client +secret at Monerium; (2) request IBAN suspension for affected accounts (§2.2); +(3) notify the partner to halt client sends; (4) global pause is optional — on-chain +funds are not at risk, only *future* mints can be redirected. Then reconcile: diff +Monerium-side links/IBANs against the DB for every account, treating the association +monitor's history as the timeline. Blast radius = deposit flow between the unauthorized +change and suspension. + +## 3. Alert triage (monitoring log lines → action) + +Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium-b2b:`. + +| Log line contains | Meaning | Action | +|---|---|---| +| `PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Executable depth below even minimum-size swaps; swaps would revert on minOut | Global pause (§2.1); investigate pool state (LP exit, depeg); consider lowering `perSwapCap`; re-run the liquidity-baseline methodology before unpausing | +| `executable depth below perSwapCap` | Cap-sized swaps would revert; availability, not fund risk | Lower `perSwapCap` or accept keeper retries; watch for escalation | +| `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, address linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | +| `stranded EURe on forwarder` (warn ≥12h) | Keeper is not converting | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts) | +| `stranded EURe ... past TRIGGER_DELAY` | Permissionless trigger now live; SLA long broken | Escalate the keeper outage; anyone may call `swapAndForward()` (same policy applies); communicate the delay | +| `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on factory` | Should-be-impossible state | Full incident: global pause, manifest verifier, compare against manifest history | +| `reconciled owner-authorized config change` | Client rotated destination/fallback, or a guardian fee change applied — expected, DB updated | No incident. Unexpected destination change → confirm with the partner; a surprise suggests a compromised fallback key (client should `setClientPaused(true)` and rotate) | +| `onboarding advance failed` (repeating for one account) | Link/IBAN automation stuck | Check the `financial_operations` row: `failed` retries itself; `unknown` needs manual reconciliation (compare Monerium-side state, then update the row) | +| `delivery ... abandoned after N attempts` | Partner webhook endpoint down > backoff horizon | Contact partner; deliveries are not retried after abandonment — partner should poll `GET /v1/monerium-b2b/deposits` to catch up | +| `MONERIUM_B2B_PRIVATE_RPC_URL is not set` | Keeper writes in the public mempool | Set the private orderflow RPC (operational finding on mainnet) | + +## 4. Dormancy gate + +Why: CEX rotation risk concentrates in dormant accounts — an exchange silently rotates +a deposit address; months later a deposit arrives and USDC would be forwarded to an +address the client no longer controls. The gate converts that silent loss into a pause. + +**Automatic:** an `active` account with no confirmed conversion for 60 days is paused +(`setGuardianPaused(true)` with the guardian key; log-only if the key is unset) and +`dormant_since` is recorded; the conversion executor skips it (the stranding marker +still arms — the dead-man sweep clock is unaffected). EURe arriving during dormancy +accumulates safely; past the sweep delay it flows to `fallbackAddress` automatically. + +**Re-confirmation (manual, via partner):** partner re-confirms in writing that the +destination is valid and client-controlled (ADR B5). If the destination changed, the +**client** updates it via their fallback key (`setDestination`) — Vortex cannot and must +not — and CEX destinations re-run the penny test. Archive the confirmation. + +**Un-pause (both steps, always):** + +```bash +cast send "setGuardianPaused(bool)" false --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +```sql +UPDATE monerium_accounts SET dormant_since = NULL WHERE forwarder_address = ''; +``` + +The DB flag, not the chain flag, gates the executor — un-pausing without clearing +`dormant_since` leaves the account skipped. Verify: a `SwapExecuted`, the execution row +`confirmed`, no stranded alert on the next pass. Never un-pause to "flush" a balance +without re-confirmation — that balance is exactly the rotation-risk scenario. + +## 5. Client migration to a new clone (manual; tooling = ADR O1, build when needed) + +For contract upgrades or config changes that require a new clone. **Announce first**: +record the migration (account id, old/new forwarder, window) so the association +monitor's alerts are expected, then: + +1. Deploy the new clone (§1.2) and verify it (`isForwarder` + config read-back — + Vortex tooling only ever targets factory clones). +2. Let the keeper drain the old clone (or client sweeps the remainder via fallback). +3. Link the new clone to the same Monerium profile (attestor flow — automated once the + account row's forwarder is repointed, or manual `POST /addresses`). +4. Move the IBAN: `PATCH /ibans/{iban}` with the new address — this is the + S1-sensitive operation; it must only ever happen inside an announced migration. +5. Update the `monerium_accounts` row (forwarder address), penny-test the new clone, + re-activate. + +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. + +## 6. Key compromise quick reference + +| Key | Blast radius | Response | +|---|---|---| +| Attestor | Can link addresses to profiles; never move funds (recovery payouts go only to the client's own bank account) | Rotate key; new forwarders need a new implementation (ATTESTOR is immutable); existing links unaffected | +| Keeper | `poke`/`swapAndForward` only (policy-constrained); worst case gas theft | Rotate; `setKeeper(old,false)` + `setKeeper(new,true)`; refund gas | +| Guardian | Pause/unpause, bounded params, timelocked fee — delay-only griefing | Two-step `transferGuardian`/`acceptGuardian`; audit pause + pending-fee state after | +| Whitelabel API credentials | Control-plane: can re-link/move IBANs (future mints only) — S1 | §2.5 full sequence | +| `ADMIN_SECRET` | Map/suspend accounts (mapping is bounded by on-chain clone verification) | Rotate; audit recent admin mutations | +| Webhook HMAC secret | Fabricated inbound order events (accounting noise; forward-only lattice + mint watcher bound the damage) | Rotate at both ends; reconcile deposits against chain | +| Client fallback key (client-side) | Full control of that client's funds/config | Client's own responsibility (terms); assist via partner: pause the account; client rotates `setFallbackAddress` if still in control | diff --git a/docs/prd/monerium-b2b-code-review-r1.md b/docs/prd/monerium-b2b-code-review-r1.md deleted file mode 100644 index 96719957f..000000000 --- a/docs/prd/monerium-b2b-code-review-r1.md +++ /dev/null @@ -1,174 +0,0 @@ -# Monerium B2B Forwarder — Adversarial Code Review R1 - -**Reviewer:** adversarial security review (automated) -**Date:** 2026-07-17 -**Scope:** `contracts/monerium-forwarder/` (VortexForwarder + Factory + tests) against the B2B variant spec, implementation plan (invariants §2.3, dispositions §5), and deferred-decisions registry. -**Build/test:** `forge build` clean; `forge test --no-match-contract Fork` = 28 pass / 0 fail. Two PoC tests written during review (guardian stranding-reset, permissionless min-out execution) both passed, then removed. - -## Verdict - -The core non-custody property **holds on-chain**: there is no path for Vortex (attestor/guardian/keeper/deployer) to redirect converted USDC away from the client's `destination` or to a Vortex address, and `isValidSignature` cannot validate a Monerium redeem order. **No P0 found.** The strongest issue is a P1 liveness/griefing escalation: the guardian can indefinitely defeat the permissionless recovery backstop that the design deliberately kept guardian-proof. Several P2 notes and concrete test gaps follow. - -## Findings table - -| # | Sev | Title | Location | -|---|-----|-------|----------| -| F1 | **P1** | Guardian can indefinitely freeze the permissionless recovery paths via `minSwapAmount` raise + `poke()` (violates invariant §2.3.5; defeats un-pausable dead-man sweep) | `VortexForwarder.sol:265-276`, `293`, `328`; `VortexForwarderFactory.sol:96-98,120-124` | -| F2 | P2 | No `chainid`/domain separator in the EIP-1271 bound → cross-chain replay of the link attestation if the forwarder is ever deployed at the same address on another chain (EURe is multichain) | `VortexForwarder.sol:254` | -| F3 | P2 | Oracle staleness check omits round-completeness (`answeredInRound`/`roundId`) | `VortexForwarder.sol:337-339` | -| F4 | P2 | Permissionless `swapAndForward` is MEV-exposed (no private orderflow); executes at up to `SLIPPAGE_BPS` worse, sandwich-extractable | `VortexForwarder.sol:283-311` | -| F5 | P2 | Spec/code drift: variant §3.3 shows `abi.encode`, code uses `abi.encodePacked`; attestor signs the raw `bound` digest. Off-chain signer must match exactly or links silently fail | `VortexForwarder.sol:254` vs variant §3.3 | -| F6 | P2 | `swapAndForward` clears `strandedSince` even when a `perSwapCap` remainder ≥ `minSwapAmount` stays; permissionless path must re-poke + re-wait `TRIGGER_DELAY` per cap-chunk | `VortexForwarder.sol:328` | -| F7 | P2 | `RECOVERY_HASH` mechanism (disabled) only preserves non-custody **iff** Monerium's recovery message is parameterless — a hard constraint on how registry T1 may be resolved | `VortexForwarder.sol:237` | -| F8 | P2 | EURe compliance-transfer-revert behaviour unmodeled/undocumented: if Monerium freezes the forwarder's EURe, both `sweepStrandedEure` and client `sweep` revert (funds stuck) | `VortexForwarder.sol:349-356,379-384` | -| F9 | P2 (nit) | `_setMinSwapAmount` relies on unparenthesised `&&`/`||` precedence | `VortexForwarderFactory.sol:121` | -| F10 | P2 (nit) | Invariant §2.3.3 says "at most the two whitelisted hashes"; code accepts up to three hash constants (191, RAW, RECOVERY) | `VortexForwarder.sol:236-237` vs plan §2.3.3 | -| — | — | Test-suite gaps (enumerated in §"Test gaps") | — | - ---- - -## F1 (P1) — Guardian defeats the permissionless recovery backstop - -**Verified (PoC passed).** - -The dead-man sweep (`sweepStrandedEure`) and the permissionless `swapAndForward` are the design's guardian-proof liveness backstops. `sweepStrandedEure` is *deliberately not pause-gated* (`VortexForwarder.sol:345-347` comment: "recovery must work during incidents"), and implementation-plan invariant §2.3.5 states `strandedSince` is "monotonic per stranding episode; reset only by swap success/balance drop." Both guarantees are bypassable by the guardian. - -`poke()` (`:265-276`) arms/clears the marker against the **mutable, global** `FACTORY.minSwapAmount()`: - -```solidity -if (balance >= FACTORY.minSwapAmount()) { if (strandedSince == 0) strandedSince = ...; } -else if (strandedSince != 0) { strandedSince = 0; ... } // "balance drop" branch -``` - -The guardian can synthesise the "balance drop" by **raising the threshold above an existing balance** (`setMinSwapAmount`, `Factory.sol:96`, bounded only by `[MIN_SWAP_FLOOR, perSwapCap]` — floor is 1e18, so any real balance can be undercut). Sequence: - -1. Client has 100 EURe stranded; `strandedSince` armed at t0. Day 59 of the 60-day dead-man window. -2. Guardian `setMinSwapAmount(200e18)` (in bounds). -3. Anyone `poke()` → `balance(100) < min(200)` → `strandedSince = 0`. The 59 days are erased. -4. While the guardian holds `min > balance`, `poke()` can never re-arm, so `sweepStrandedEure` reverts `NotStranded` **forever**, and `swapAndForward` reverts `BelowMinimum` (`:293`) — no conversion either. - -Net: the guardian unilaterally re-creates the exact "stranded forever, no permissionless rescue" terminal state the dead-man sweep was built to eliminate. This is **strictly more power than pause** (pause leaves `sweepStrandedEure` callable). It is a liveness/griefing escalation, not theft — the client's own `fallbackAddress` can still `sweep()` — but it voids a stated security property for any client relying on the zero-touch/permissionless path, and because `minSwapAmount` is **global**, one raise freezes every client below the threshold at once. - -**Fix:** decouple the stranding marker from the guardian-tunable operational `minSwapAmount`. Options, cleanest first: -- Arm/clear `poke()` against the **immutable `MIN_SWAP_FLOOR`** instead of `FACTORY.minSwapAmount()`. The marker then only tracks "a non-trivial balance is parked," which the guardian cannot move. -- Or snapshot the arming threshold in storage when `strandedSince` is first set and clear only if balance falls below *that* snapshot. -- Or only clear on `balance == 0` / an actual observed decrease (track last-seen balance). - -Whichever is chosen, add an invariant/fuzz action that raises `minSwapAmount` mid-stranding and asserts `strandedSince` and the SWEEP_DELAY deadline are preserved. - ---- - -## F2 (P2) — Cross-chain replay of the link attestation - -`isValidSignature` binds only to `address(this)` (`:254`): `bound = keccak256(abi.encodePacked(address(this), hash))`. No `block.chainid`. The design doc claims "no cross-contract replay," but cross-**chain** replay is uncovered. Monerium EURe is deployed on multiple chains (Ethereum, Gnosis, Polygon, Arbitrum, …). Clone addresses are CREATE2-deterministic in the factory address + salt + init code; if the factory lands at the same address on two chains (common when teams want consistent addresses) and the same salt is used, the clone address collides, and **one attestor signature validates the Monerium link on both chains**. An attestation minted to link the mainnet forwarder would also validate a link of the identical Gnosis-side address (possibly to a different profile). - -**Status:** the fork test pins mainnet only; whether multichain deployment is intended is **unverified-hypothesis**. If it ever is, this rises to P1. It is cheap to close now. - -**Fix:** `bound = keccak256(abi.encodePacked(block.chainid, address(this), hash))` and mirror it in the off-chain attestor signer. - ---- - -## F3 (P2) — Oracle staleness omits round-completeness - -`_minOut` (`:337-339`) checks `answer <= 0` (handles negative and zero — good) and `updatedAt` age, but not `answeredInRound >= roundId` nor `roundId != 0`. On a Chainlink feed that carries a stale answer forward in a stuck round, `updatedAt` can still look fresh. `minOut` is the sole on-chain price protection, so a wrong-but-recent round weakens it within the `SLIPPAGE_BPS` band. Defense-in-depth. - -**Fix:** read `roundId`/`answeredInRound` from `latestRoundData` and require `answeredInRound >= roundId` and `roundId != 0`. - ---- - -## F4 (P2) — Permissionless swap path is MEV-exposed - -The keeper is planned to submit `swapAndForward` via private orderflow (plan §3). The permissionless branch (`:286-290`, anyone after `TRIGGER_DELAY`) is not, and executes with `amountOutMinimum = minOut` = worst allowed (1%). A searcher can call it in the public mempool and sandwich to the `SLIPPAGE_BPS` floor. A rando can also `poke()` immediately after any deposit to start the 24h clock (arming is intended/permissionless), then extract ≤1% once the keeper is ≥24h down. Within the documented "worst case within slippage bound," but it is a per-swap value leak specific to the fallback path and should be documented (and bounded by keeping `SLIPPAGE_BPS` tight / keeper promptness). - -**PoC confirmed:** a `rando` call after `TRIGGER_DELAY` with the router paying exactly `minOut` forwards the worst-allowed amount to `destination`. - ---- - -## F5 (P2) — Spec/code encoding drift + raw-digest signing - -Variant §3.3 pseudocode: `keccak256(abi.encode(address(this), LINK_HASH))`. Code (`:254`): `abi.encodePacked`. Packed encoding of `(address, bytes32)` is unambiguous (both fixed-length), so this is **safe**, but the two documents disagree, and the off-chain attestor MUST match the code exactly. Additionally, the contract `ecrecover`s over `bound` directly — the attestor signs the **raw 32-byte digest**, not an EIP-191 `personal_sign` of it (the test uses `vm.sign(pk, bound)` accordingly). A signer that uses `personal_sign` will produce non-validating links. - -**Fix:** reconcile the spec to `abi.encodePacked`, and document "attestor signs the raw `bound` digest (no EIP-191 prefix)". - ---- - -## F6 (P2) — `strandedSince` reset discards `perSwapCap` remainder - -`swapAndForward` unconditionally sets `strandedSince = 0` on success (`:328`). When `amountIn` is capped by `perSwapCap` and a remainder ≥ `minSwapAmount` stays, the marker is nonetheless cleared. The leftover then needs a fresh `poke()` + a full `TRIGGER_DELAY` before the permissionless path can move the next chunk, so a balance above the cap drains one cap-chunk per (poke + 24h) cycle if the keeper is absent. Liveness slowness, not a loss. - -**Fix (optional):** after the swap, if the remaining EURe balance ≥ `minSwapAmount`, leave `strandedSince` untouched (or reset to `block.timestamp`) so the remainder stays enrolled in the permissionless schedule. - ---- - -## F7 (P2) — `RECOVERY_HASH` non-custody constraint (registry T1 guardrail) - -`RECOVERY_HASH` is `bytes32(0)` (disabled) in all current configs, so **no live issue**. But the whole non-custody argument for a whitelisted hash depends on the message being **fixed/parameterless** — the same reason the link hash is safe and a redeem order is not. If T1 reveals that Monerium's recovery message contains variable fields (amount, IBAN, bank account), a single compile-time `RECOVERY_HASH` is either useless (hash varies per recovery) or unsafe if broadened to a scheme-match. This is not a finding against current code; it is a **hard constraint on resolving T1**: only enable `RECOVERY_HASH` if the recovery message is parameterless, otherwise the constrained-1271 safety property does not carry over. Worth stating explicitly in the registry T1 row. - ---- - -## F8 (P2) — EURe transfer-restriction assumption undocumented/untested - -EURe is a regulated e-money token with a compliance controller that can restrict transfers. The design handles the USDC (Circle) blacklist (atomic revert keeps funds as EURe). It does **not** document the case where the forwarder's *own* address (or `fallbackAddress`) is EURe-frozen: then `sweepStrandedEure` (`:353`) and client `sweep` (`:382`) both revert `TransferFailed` and funds are genuinely stuck. The mocks model no EURe transfer restriction, so this is neither documented nor tested. Out of contract scope to fix, but the "EURe always transferable for the forwarder/fallback" assumption should be stated (and covered in ops/terms). - ---- - -## F9 / F10 (P2 nits) - -- **F9:** `_setMinSwapAmount` (`Factory.sol:121`) — `value < MIN_SWAP_FLOOR || value > perSwapCap && perSwapCap != 0` is correct only because `&&` binds tighter than `||` (and the construction ordering sets cap after min while cap==0). Add parentheses: `value < MIN_SWAP_FLOOR || (perSwapCap != 0 && value > perSwapCap)`. -- **F10:** Invariant §2.3.3 wording says "at most the two whitelisted hashes"; the code accepts up to three constants (`LINK_HASH_191`, `LINK_HASH_RAW`, `RECOVERY_HASH`) = two *messages*, three *hashes*. Align the wording. - ---- - -## Spec-vs-code invariant check (plan §2.3 + §5) - -| Invariant / disposition | Status | -|---|---| -| §2.3.1 assets leave only via enumerated paths | **Holds.** Exit points: router pull (approved `amountIn`, `Overspend`-checked, reset to 0), USDC fee→`FEE_RECIPIENT` (≤`feeBps`, on swap delta only), USDC→`destination`, EURe→`fallbackAddress` (delayed), fallback `sweep`. Nothing else. | -| §2.3.2 no delegatecall/selfdestruct, CALL-only, value==0, reentrancy-guarded, safe-ERC20 | **Holds.** `nonReentrant` on all fund-movers; low-level calls carry no value; safe-transfer return-data handling present. | -| §2.3.3 1271 validates only whitelisted hashes, none authorize Vortex asset movement | **Holds** (wording nit F10). Redeem orders cannot validate. | -| §2.3.4 guardian delay-only, fallback client-only, non-upgradeable | **Mostly holds; F1 breaches the spirit** — guardian gains an unbounded freeze over permissionless recovery. Deploy-time `destination` trust is the documented S0 residual (guardian sets `destination` at `deployForwarder`; only fallback can change it after — contract correctly blocks post-deploy guardian changes). | -| §2.3.5 `strandedSince` not manipulable to skip/extend delays | **Breached — see F1.** Guardian can reset via a synthetic "balance drop." | -| R03 strandedSince marker | Implemented; F1 caveat. | -| R05 pause protective-only, never traps fallback | Holds — `sweep`/`sweepStrandedEure`/`setDestination` ungated by pause; invariant test covers fallback-sweep-while-paused. | -| R09 unsolicited tokens | Holds — fallback `sweep(token,to)`; unsolicited USDC forwarded to `destination` (fee applied to swap delta only, not to unsolicited USDC — correct). | - -**Factory/clone lifecycle:** `deployForwarder` clones + initializes atomically and is `onlyGuardian` (no initialize front-run — verified). `initialize` is `msg.sender == FACTORY` gated and one-shot; implementation self-bricks in its constructor (test-covered). CREATE2 address cannot be squatted by third parties (deployer = factory). `predictAddress` matches deployment (test-covered). Two-step guardian transfer is sound; `transferGuardian(0)` just cancels a pending transfer. Clones read `FACTORY.guardian()` dynamically, so a transfer re-points every clone with no per-clone migration. `implementation` is immutable (no swap). All good. - -## Test gaps (item 6) - -Named, missing tests (each maps to a finding or an untested branch): - -1. **Guardian raises `minSwapAmount` mid-stranding** — the exact F1 gap; no test toggles the threshold against an armed marker. -2. **Cross-chain replay** — untestable until a chainid is in the bound (F2); add once fixed. -3. **Oracle negative/zero answer** — `InvalidPrice` (`:338`) is never triggered; tests only exercise the stale (`updatedAt`) path. -4. **`RECOVERY_HASH` enabled branch** — every config uses `recoveryHash: bytes32(0)`, so the `isRecovery` path (`:237`) and its "recovery hash only, nothing else" property are entirely untested. -5. **Signature malleability / v** — the high-s check (`:251`) and `v ∉ {27,28}` (`:252`) rejections are untested; only wrong-signer and foreign-hash are covered. -6. **`signature.length != 65`** rejection (`:239`) untested. -7. **`guardianPaused` does NOT block `sweepStrandedEure`** — the "recovery during incident" property is asserted only for the fallback `sweep`, not the permissionless dead-man path. -8. **EURe transfer reverts** (compliance freeze) on the sweep/exit paths (F8) — mocks never fail a transfer. -9. **Fee rounding to zero** for dust swaps (`fee = usdcReceived*feeBps/BPS` floors to 0). -10. **`setFallbackAddress`** authority/gating (only `setDestination` is covered for fallback authority). -11. **Perpetual-remainder liveness** (F6): balance > `perSwapCap`, keeper absent — assert how many (poke + `TRIGGER_DELAY`) cycles are needed. - -## Notes that are NOT findings - -- Deploy-time `destination` correctness is the documented S0 provisioning trust (variant §4, plan R01); the contract cannot verify it and relies on the off-chain manifest + client confirmation. Correctly restricted post-deploy. -- Reentrancy: EURe/USDC are not ERC-777; all fund-movers are `nonReentrant`; a hooked token could reenter only ungated no-op functions (`poke`) with no harmful effect. The router-reentrancy guard is test-covered. -- Registry placeholders (fees, timings, T1–T5) are known-open and not reported as findings. - ---- - -## Author dispositions (2026-07-17) - -| Finding | Disposition | Resolution | -|---|---|---| -| F1 (P1) guardian disarms dead-man via minSwapAmount raise | **Fixed** (commit eff320f68) | `poke()` arms/clears against immutable `MIN_SWAP_FLOOR`; regression test added | -| P2: no chainid in EIP-1271 binding | **Fixed** | Binding is now `keccak256(chainid ‖ address(this) ‖ hash)`; cross-chain replay test added; backend attestor updated; **re-validated against Monerium sandbox (201, linked)** | -| P2: two accepted link-hash variants | **Fixed** | Narrowed to `LINK_HASH_191` only — G0 sandbox confirmed Monerium presents EIP-191; raw-variant rejection tested | -| P2: strandedSince reset discards perSwapCap remainder | **Fixed** | Swap re-arms the marker when post-swap balance ≥ floor; test added | -| P2: oracle round-completeness (answeredInRound) | **Rejected** | `answeredInRound` is deprecated by Chainlink for OCR feeds; `answer > 0` + `updatedAt` staleness ceiling is the current recommended validation. Zero/negative-answer test added | -| P2: permissionless swap path MEV-exposed | **Accepted-documented** | Bounded by oracle `minOut` by design; the permissionless path is a liveness fallback, not the normal path. Noted in security spec | -| P2: spec/code drift on attestation digest encoding | **Fixed** | Variant doc §3.3 sketch aligned with code (encodePacked, chainid, single hash) | -| P2: RECOVERY_HASH non-custody depends on parameterless recovery message | **Accepted** | Requirement added to registry T1: enable only if Monerium's recovery message is parameterless (or parameters are payout-neutral); else keep disabled | -| P2: EURe compliance-freeze behavior unmodeled | **Accepted-documented** | Added to variant doc failure notes: issuer freeze ⇒ nothing moves until resolved; inherent to e-money tokens | -| Test gaps (11 named) | **Closed (9) / N/A (2)** | Added: threshold-raise regression, RECOVERY_HASH-enabled branch, oracle answer≤0, 1271 malleability + length + bad-v, cross-chain replay, raw-variant rejection, cap-remainder re-arm. N/A: EURe-transfer-restriction assumption (documented instead), fee-rounding edge (covered by existing pro-rata unit tests backend-side) | diff --git a/docs/prd/monerium-b2b-implementation-plan.md b/docs/prd/monerium-b2b-implementation-plan.md deleted file mode 100644 index f08063d16..000000000 --- a/docs/prd/monerium-b2b-implementation-plan.md +++ /dev/null @@ -1,99 +0,0 @@ -# Implementation Plan: Monerium B2B Zero-Touch Onramp - -**Date:** 2026-07-17 -**Basis:** [B2B variant](./monerium-eur-usdc-onramp-b2b-variant.md) (as updated 2026-07-17) + [re-review dispositions](#5-re-review-dispositions-for-the-b2b-build) below -**Deferred parameters:** every placeholder value in this plan is tracked in the [deferred-decisions registry](./monerium-onramp-deferred-decisions.md) — implementation does not block on them. - -**Locked scope decisions (2026-07-17):** B2B variant first (consumer flow is phase 2, out of this plan). Fallback address mandatory (single tier). Whitelabel API directly, developed against sandbox during MSA negotiation. Adversarial review parallel to implementation. - -## 1. Deliverables overview - -| # | Deliverable | Where | -|---|---|---| -| D1 | `contracts/` Foundry package: `VortexForwarder` implementation + `VortexForwarderFactory` + tests (unit, mainnet-fork, invariant) | new top-level `contracts/` (not a bun workspace) | -| D2 | Backend: Monerium whitelabel service + persistent account/deposit/execution models + keeper | `apps/api` | -| D3 | Ops: config manifest publication + verifier script, monitoring/alerts, runbooks | `contracts/script`, `apps/api`, docs | -| D4 | Terms inputs: disclosure texts, penny-test + dormancy runbook | docs (with G2/partner) | -| D5 | Parallel adversarial review of spec + code | review agent round | - -## 2. Contract architecture (D1) - -### 2.1 Topology - -Each client needs their **own address** (the IBAN links to it), so per-client contracts are unavoidable. Pattern: **EIP-1167 minimal proxies** (clones) of an immutable `VortexForwarder` implementation, deployed via `VortexForwarderFactory` (CREATE2, deterministic addresses), initialized atomically in the deploy transaction. - -```text -VortexForwarder (implementation, immutable): - immutables (implementation-level): EURE, EURC, USDC, ROUTER, PATH params, ORACLE, - ORACLE_DECIMALS, MAX_ORACLE_AGE, SLIPPAGE_BPS, MAX_FEE_BPS, FEE_RECIPIENT, - ATTESTOR, GUARDIAN (Vortex ops), FACTORY, MIN_SWAP_FLOOR, CAP_CEILING, - SWEEP_DELAY, TRIGGER_DELAY, LINK_HASH, [RECOVERY_HASH — pending T1] - per-clone storage (set once by factory in deploy tx): - destination, fallbackAddress, feeBps, initialized - mutable state: strandedSince (R03 marker), accountPaused (guardian OR fallback), - destination (fallback-updatable), fallbackAddress (fallback-updatable) -``` - -### 2.2 Functions - -- `initialize(destination, fallbackAddress, feeBps)` — factory-only, once, in the deploy tx. `feeBps ≤ MAX_FEE_BPS`; `destination`, `fallbackAddress` nonzero, mutually distinct from token/router/oracle addresses. -- `isValidSignature(bytes32 hash, bytes sig)` — returns magic value iff `hash == LINK_HASH` and `sig` is the ATTESTOR's signature over `keccak256(address(this), LINK_HASH)` (plus, pending T1, the analogous check for `RECOVERY_HASH`). Everything else fails. -- `poke()` — permissionless; records `strandedSince = block.timestamp` iff EURe balance ≥ minSwapAmount and `strandedSince == 0`. Cleared on successful swap or balance dropping below threshold. This is the enforceable start time for both delayed mechanisms (fixes R03 for this build). -- `swapAndForward()` — callable by GUARDIAN/keeper any time, or **anyone** once `strandedSince` is older than TRIGGER_DELAY. Atomic: oracle-checked `minOut` (PRD v2 §7.3 math verbatim), contract-constructed `exactInput` calldata (pinned path, recipient = self), exact approval + reset, delta checks, fee skim (pilot 0), full USDC balance → `destination`. Reverts as a unit (blacklisted destination ⇒ funds stay EURe). -- `sweepStrandedEure()` — permissionless once `strandedSince` older than SWEEP_DELAY; transfers full EURe balance to `fallbackAddress` (never `destination` — CEX rule). -- `fallbackOnly` functions: `setDestination`, `setFallbackAddress`, `setAccountPaused(bool)`, `sweep(token, to)` (any token incl. EURe/USDC/unsolicited — R09). -- `guardianOnly`: `setAccountPaused(bool)` per clone (compliance holds, dormancy gate — R05) and factory-level `setGlobalPaused(bool)`. Guardian pause is protective-only: it can never move funds, change config, or block `fallbackOnly` functions (fallback sweep/exit must work even when paused — invariant). -- Operational params (`minSwapAmount`, `perSwapCap`) live on the factory, guardian-settable within immutable floor/ceiling (R10 invariant table in the contract spec). - -### 2.3 Invariants (audit targets, adapted from PRD v2 + re-review) - -1. Assets leave a clone only via: swap (router, minOut-checked, output to self), USDC→`destination`, fee ≤ feeBps→FEE_RECIPIENT, EURe→`fallbackAddress` (delayed sweep), or `fallbackOnly` sweep. Exhaustive. -2. No delegatecall, no selfdestruct, CALL only, `value == 0`, reentrancy-guarded, safe-ERC20. -3. `isValidSignature` validates at most the two whitelisted hashes; neither authorizes asset movement by Vortex. -4. Guardian powers are delay-only; fallback powers are client-only; nothing is Vortex-upgradeable. -5. `strandedSince` cannot be manipulated to skip delays (monotonic per stranding episode; reset only by swap success/balance drop). - -## 3. Backend (D2) - -New `apps/api` module (not the one-shot ramp state machine), per PRD v2 §11 adapted: - -- **Models:** `MoneriumAccount` (profileId, iban, forwarderAddress, configVersion, status), `FiatDeposit` (moneriumOrderId unique, mint tx `(chainId, txHash, logIndex, blockHash)`, amounts, compliance status), `ConversionExecution` (includedDepositIds, eureIn/usdcGross/fee/usdcNet, txHash, status). -- **Whitelabel client:** profile creation, corporate KYB submission (mechanism pending T3 — build against sandbox, abstract the KYB step), address link (`POST /addresses` with attestor signature), IBAN issuance, webhook ingestion. Auth-layer abstracted; sandbox first. -- **Webhooks:** HMAC over raw bytes, constant-time compare, **durable persist before 200** (R06), webhook-ID dedup, forward-only status transitions. -- **Attribution (R04):** an execution record snapshots the EURe balance and the set of `FiatDeposit`s with mint block ≤ execution block that are not yet allocated; allocation pro-rata, floor 6 dp, remainder to largest deposit. Deposits discovered later join the next execution. On-chain balance = safety source; order IDs = accounting source. Per-forwarder serialization via Postgres advisory lock. -- **Keeper:** mint detection (webhook + Transfer watcher), `poke()` submission, `swapAndForward()` via private orderflow, nonce management, stale-tx replacement, retries with alerting. -- **Dormancy gate (R05):** backend job pauses accounts (guardian per-clone pause) after P5 days without a successful forward; unpause on partner re-confirmation. -- **Notifications:** after N confirmations with block-hash re-verification; correction path on reorg. - -## 4. Phases - -- **Phase 0 — G0 spike (parallel with Phase 1):** whitelabel sandbox E2E: deploy forwarder (testnet), attestor-sign link, `POST /addresses`, verify Monerium's EIP-1271 validation passes, mint test EURe, observe. Chainlink EUR/USD weekend behavior (T2). Router pin + EURC hop fee tiers (P10). Reproducible liquidity baseline. **Send T1 question to Monerium tech now.** -- **Phase 1 — Contracts:** D1 complete with fork tests against real pools (incl. V1-token poisoning tests) and invariant/fuzz suite on §2.3. Exit: green suite + spec-code adversarial review round (D5) started. -- **Phase 2 — Backend:** D2 against sandbox; integration tests with mocked/sandbox Monerium; manifest publication + verifier (R01: documented as consistency evidence, not a trust root). -- **Phase 3 — Ops + terms:** monitoring (association changes at Monerium, executable-depth quotes, stranded balances, dormancy), runbooks (incident pause, 02:00-UTC vulnerability, penny test), disclosure/terms drafts to G2 + partner. -- **Phase 4 — Gates + pilot:** G1 written package, G2 sign-off, G3 audit, then G4 invite-only pilot (fee 0, conservative caps). - -Phases 0–2 are engineering-internal and start immediately; 3 runs alongside; 4 depends on externals. - -## 5. Re-review dispositions for the B2B build - -The re-review (R01–R12) targeted the consumer PRD v2; dispositions here cover the B2B build. The consumer flow re-review response is deferred to phase 2 (out of scope of this plan). - -| ID | Disposition (B2B) | Resolution | -|---|---|---| -| R01 | Accept | Manifest + verifier ship (D3) but are documented as consistency evidence with no independent trust root; S0 claim in variant doc already scoped to "detectable, not prevented". No cryptographic fix claimed | -| R02 | N/A here | No passkeys in the B2B flow. Owned by consumer phase 2 | -| R03 | Accept — resolved | `strandedSince` poke marker (§2.2) gives both delayed mechanisms an enforceable on-chain start time | -| R04 | Accept — resolved | Snapshot-based allocation rule + advisory-lock serialization (§3) | -| R05 | Accept — resolved | Per-clone guardian pause in contract state (§2.2), protective-only invariant; dormancy gate and compliance holds build on it | -| R06 | Accept — resolved | Durable webhook persistence before 200 (§3) | -| R07 | Accept | Fallback-initiated config changes (destination/fallback updates) are evented; verifier treats owner-authorized changes as expected state transitions, not incidents | -| R08 | N/A here | No opaque owner signatures — no user keys. The analogous surface (fallback-address key compromise) is disclosed in client terms (client self-custody responsibility) | -| R09 | Accept — resolved | `fallbackOnly sweep(token, to)` handles unsolicited tokens; unsolicited USDC is swept to destination with the next forward (documented); accounting treats non-Monerium EURe inflows as unattributed (flagged, not allocated to deposits) | -| R10 | Accept — resolved | §2.3.4/§2.2 role + parameter bounds table is part of the contract spec; audit target | -| R11 | Accept | "Always exit" language replaced: exit guarantees are scoped to fallback-key availability + issuer backstop (best-effort pending T1) | -| R12 | Accept | This plan + updated variant doc are the normative spec for the B2B build; the PRD v2 appendix format stays as-is for the consumer flow | - -## 6. What this plan deliberately defers - -Everything in the [registry](./monerium-onramp-deferred-decisions.md): fee value, all timing/size parameters (P1–P10), T1–T5 clarifications, G1 written package, G2 legal, partner terms. None block Phases 0–2. diff --git a/docs/prd/monerium-b2b-terms-inputs.md b/docs/prd/monerium-b2b-terms-inputs.md deleted file mode 100644 index cbecb5df1..000000000 --- a/docs/prd/monerium-b2b-terms-inputs.md +++ /dev/null @@ -1,106 +0,0 @@ -# Monerium B2B Onramp — Terms & Disclosure Inputs (D4) - -**Status:** engineering inputs for G2 legal review and the partner agreement — not final legal -text. Every bracketed value **[ID: …]** is a placeholder tracked in the -[deferred-decisions registry](./monerium-onramp-deferred-decisions.md); G2/partner own the final -wording, engineering owns the factual accuracy of the mechanics described. - -**Sources:** [b2b-variant doc](./monerium-eur-usdc-onramp-b2b-variant.md) §6, -[implementation plan](./monerium-b2b-implementation-plan.md) §5 (R08/R11 dispositions). - -## 1. Redemption limitation (registry B6 — committed to Monerium) - -Monerium's acceptance of the attestor pattern is conditional on clients being told explicitly -that the setup limits direct redemption. Commitment made in the TG thread 2026-07-17; this -disclosure is **mandatory** in the client terms. - -Draft text: - -> EURe received at your dedicated forwarding address **cannot be redeemed directly with -> Monerium from that address**. If you need to redeem EURe (rather than receive the automatic -> USDC conversion), you must first withdraw it to your fallback address — from which you can -> redeem normally — or, as a last resort, use Monerium's recovery process, which pays out only -> to your own verified bank account. - -Notes for G2: the issuer recovery backstop is best-effort until its technical mechanism is -settled **[T1: recovery-message format unanswered — if unresolved at deploy, recovery for -contract addresses fails closed and the fallback address is the only redemption path; the -disclosure must not overpromise the backstop]**. - -## 2. Destination warranty & CEX rotation liability (registry B5) - -Allocation follows the traditional payout-processor model: a wrong/closed destination account is -the instructing party's loss; contractual allocation is the only mechanism that can hold this -risk, since a CEX deposit address's continued validity is not verifiable on-chain. - -Structure to draft: - -- **Client/partner warrants** that the named `destination` is valid, under the client's control - (or a deposit address of an account under the client's control), and that they will notify - Vortex of any change **before** further deposits. -- **Client/partner bears** losses from destination rotation, closure, or mis-crediting by the - destination platform. -- **Exchange-address attestation:** for CEX destinations the client attests awareness of - rotation and minimum-deposit behavior. -- **Vortex diligence commitments** (the consideration for the warranty): a verification transfer - before activation **[B2: 5 USDC]**; automatic pause after prolonged inactivity pending - re-confirmation (§3); minimum forward size at or above the destination's minimum deposit - **[P6: minSwapAmount, €25 floor]**; and never sending unconverted EURe to the destination. -- **Destination changes are client-only:** only the client's fallback key can change the - destination on-chain; Vortex cannot redirect funds (see §6). - -## 3. Dormancy re-confirmation (registry B5, P5) - -> If no conversion completes for **[P5: 60 days]**, forwarding pauses automatically and resumes -> only after you (or the partner on your behalf) re-confirm that your payout address is still -> valid. Deposits made while paused remain in your forwarding account and convert after -> re-confirmation; your fallback-address rights are unaffected by the pause. - -Mechanics reference for the drafters: `docs/runbooks/monerium-b2b-dormancy.md`. The -re-confirmation channel (partner API ping vs written confirmation) is a partner-agreement -decision **[B5]**. - -## 4. Fee disclosure structure (registry B1, P1, P2) - -Fee and slippage are disclosed **separately** — one is deterministic, the other a worst-case -market bound: - -- **Service fee:** a per-client percentage fixed at account creation and immutable thereafter, - assessed on the gross USDC output of each conversion. Current pilot value **[B1: 0]**; - contractual ceiling equal to the on-chain immutable cap **[P2: MAX_FEE_BPS = 100 bps = 1%]**. -- **Conversion bound (not a fee):** each conversion delivers **at least** the Chainlink EUR/USD - reference rate minus **[P1: SLIPPAGE_BPS = 100 bps = 1%]**, or it does not execute at all - (deposits then wait and retry). This bound covers market execution and both stablecoins' - deviation from their pegs; it is enforced by the contract, assuming an honest oracle — it is - not a principal guarantee under oracle failure or a stablecoin collapse beyond the bound. -- Batching: deposits arriving between conversions are converted together; fee and output are - allocated pro-rata by deposit amount, so batching never changes a client's effective rate. - -## 5. Processing SLA (registry B3) - -Placeholder wording pending the business decision: - -> Deposits at or above the minimum **[P6: €25]** convert **[B3: within 1 business hour]** under -> normal market conditions. Conversions execute on weekends; the EUR/USD reference rate updates -> less frequently outside FX market hours **[T2/P8: observed weekend gaps up to 48 h; oracle -> staleness ceiling 52 h]**, so weekend conversions may execute at a rate up to that age — -> always within the conversion bound of §4. Deposits below the minimum accumulate until the -> minimum is reached. - -Include: SLA is a service target, not a guarantee; keeper outages beyond -**[P4: TRIGGER_DELAY = 24 h]** open a permissionless execution path so conversion does not -depend on Vortex's liveness. - -## 6. Vortex-powers and self-custody disclosures (plan §5 R08/R11) - -- **What Vortex can do:** deploy the account, run the conversion, pause it (per-account and - globally), and tune bounded operational parameters. **What Vortex cannot do:** move, redeem, - or redirect funds; every exit target is client-controlled. Pauses can delay conversions but - never block the client's fallback-address rights or the delayed automatic sweep to the - fallback address **[P3: 60 days]**. -- **Fallback-key responsibility (R11 — do not overpromise):** exit guarantees are scoped to the - client's continued control of their fallback key, plus the issuer backstop (best-effort, - §1 note). Loss of the fallback key combined with a broken destination is an ordinary - self-custody residual risk and is borne by the client. -- **Deposit acceptance:** Vortex cannot prevent inbound SEPA to an issued IBAN; deposits during - a pause accumulate as EURe at the forwarding address under the protections above. diff --git a/docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md b/docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md deleted file mode 100644 index 6bb1c16e8..000000000 --- a/docs/prd/monerium-eur-usdc-onramp-architecture-rereview.md +++ /dev/null @@ -1,411 +0,0 @@ -# Architecture Re-review: Quoteless EUR → USDC Onramp via Monerium - -**Reviewed document:** [monerium-eur-usdc-onramp.md](./monerium-eur-usdc-onramp.md) v2.0 -**Prior review:** [monerium-eur-usdc-onramp-architecture-review.md](./monerium-eur-usdc-onramp-architecture-review.md) -**Review date:** 2026-07-13 -**Review status:** Materially improved; five blocking design gaps remain -**Recommended decision:** **No-go for production implementation freeze or security audit until R01–R05 are resolved in the specification.** G0 prototypes may proceed; external gates G0–G4 must still pass before launch. - -## 1. Executive conclusion - -Version 2 is a serious improvement. It retracts the unsafe end-to-end non-custody claim, removes the generic router, CoW, ERC-4337, and automatic redeem validator, chooses one module topology, and models the IBAN as a persistent account rather than a terminal ramp. The resulting v1 is credible and auditable in principle. - -The revised composition is nevertheless not ready to implement exactly as written. The remaining blockers are narrower than in v1, but concrete: - -1. the configuration manifest has no trust root independent of the provisioning system it is supposed to detect; -2. a second passkey is incorrectly treated as RP-independent recovery; -3. the delayed permissionless fallback cannot determine when an ERC-20 balance crossed the threshold from the proposed state; -4. a live-balance sweep can consume deposits that the database has not attributed, so the proposed pro-rata accounting is not yet deterministic; -5. the contract state does not contain the per-account guardian pause required by the incident, sanctions, limit, and permissionless-fallback behavior. - -The core on-chain safety direction is now sound: fixed token addresses, a fixed path and router, module-constructed calldata, oracle-derived `minOut`, `CALL` only, exact approvals, delta checks, atomic fee/forwarding, and owner-authorized migration. The remaining work is mostly about making the surrounding claims and state transitions match what the system can actually enforce. - -## 2. Cognitive-load assessment - -The revised v1 is now mostly `🧠`: one Safe deployment path, one conversion module, one route, one fallback handler, and one persistent backend model. The largest remaining `🤯` area is the interaction among live ERC-20 balances, Monerium orders, webhook ordering, permissionless execution, compliance holds, and accounting attribution. Those facts currently live in separate sections, but correctness depends on reasoning about all of them at once. R03–R06 propose one explicit lifecycle that reduces that cross-section load. - -## 3. Disposition of the original findings - -| Original | Re-review status | Comment | -|---|---|---| -| F01 | **Resolved at claim level; external gate open** | S1 now admits Vortex/Monerium authority. G1 remains a genuine launch gate, not an implementation detail. | -| F02 | **Partially resolved** | The stage-based trust model is good. The manifest/verifier does not yet make provisioning fraud independently detectable; see R01. | -| F03 | **Mostly resolved** | The link signature is no longer described as configuration consent. Opaque owner-signature risk after onboarding remains under-modeled; see R08. | -| F04 | **Mostly resolved** | Singleton plus Safe-keyed configuration is coherent. Pause state and parameter governance need completion; see R05 and R10. | -| F05 | **Resolved** | The automatic redeem validator and custom fallback behavior are removed. | -| F06 | **Mostly resolved** | Internal calldata and a pinned route establish the intended swap constraint. The liveness fallback is not implementable from the specified state; see R03. | -| F07 | **Resolved** | CoW is correctly deferred to a separate design. | -| F08 | **Partially resolved** | Long-lived account/deposit/execution objects are correct. The execution-to-deposit ledger is not yet race-safe; see R04. | -| F09 | **Resolved in design; validation open** | Math, rounding direction, assumptions, and loss wording are materially improved. Feed pinning and weekend behavior correctly remain in G0. | -| F10 | **Resolved** | Fee recipient, ceiling, per-account value, and pilot behavior are structurally defined. | -| F11 | **Partially resolved** | A recovery owner is mandatory, but one allowed choice is not RP-independent; see R02. | -| F12 | **Resolved in design; validation open** | Caps are correctly treated as availability controls and executable-depth measurement is required. | -| F13 | **Partially resolved** | Webhook authentication, deduplication, reorg policy, and database locking are covered. Durable receipt and attribution races remain; see R04 and R06. | -| F14 | **Partially resolved** | Migration is credible. The specified contract does not implement the claimed per-account guardian pause; see R05. | -| F15 | **Partially resolved** | G1/G2 are appropriate. Unsolicited EURe, the operational limit, and permissionless execution during a hold need explicit treatment; see R04 and R05. | -| F16 | **Mostly resolved** | Minimums, accumulation, gas cost, and destination validation are now explicit. Full-balance USDC behavior still needs a product rule; see R09. | -| F17 | **Mostly resolved** | Most absolute wording is corrected. S4 and the final statement still need the owner-signature caveat; see R08 and R11. | -| F18 | **Resolved** | The design sacrifice was applied effectively. | - -## 4. Priority summary - -| ID | Priority | Finding | Blocks | -|---|---|---|---| -| R01 | P0 | The manifest verifies consistency, not honest provisioning | S0 fraud-detection claim, audit scope | -| R02 | P0 | A second passkey is not RP-independent recovery | Recovery guarantee, onboarding UX | -| R03 | P0 | The delayed permissionless fallback has no enforceable start time | Contract design, liveness claim | -| R04 | P0 | Live balance sweeps race deposit attribution | Financial accounting, API correctness | -| R05 | P0 | Per-account guardian pause and operational-limit semantics are absent | Compliance holds, incident response | -| R06 | P1 | Webhooks must be durably accepted before returning `200` | Event loss, reconciliation | -| R07 | P1 | Mutable owner configuration conflicts with continuous manifest verification | Monitoring, false incident alerts | -| R08 | P1 | Opaque passkey owner signatures are omitted from the post-mint threat model | Security statement, user compromise | -| R09 | P1 | Full-USDC sweep and unsolicited EURe lack product/accounting rules | User expectations, ledger correctness | -| R10 | P1 | Operational parameter and role invariants are incomplete | Contract auditability, liveness | -| R11 | P2 | “Always exit” remains stronger than the stated assumptions | Specification accuracy | -| R12 | P2 | The response appendix obscures the normative specification | Maintainability | - -## 5. Detailed findings - -### R01 — The manifest verifies consistency, not honest provisioning - -**Priority:** P0 -**Affected PRD lines:** 64, 81, 130–132 - -#### Concern - -The PRD says a hostile provisioning pipeline is detectable because an open-source verifier checks the deployed Safe against a manifest published in a repository and API. That proves only: - -> deployed state == Vortex-published manifest - -It does not prove: - -> deployed state == independently approved Vortex policy + the user's intended destination and recovery owner - -If the frontend, backend, deployment path, and manifest publisher are compromised together—the S0 scenario—the attacker can deploy a hostile Safe and publish a matching hostile manifest. A verifier operated by the same onboarding backend will report success. A repository plus API is also not necessarily append-only, independently witnessed, or resistant to equivocation between users. - -#### Required resolution - -Choose one honest claim and implement its prerequisites: - -**Option A — independent detection:** - -1. Define a canonical policy schema with allowed Safe singleton/factory/handler/module/signer code hashes, threshold rules, immutable contract coordinates, and parameter bounds. -2. Have releases signed by keys outside the provisioning path, preferably an audit/release multisig with at least one independently operated signer. -3. Publish account manifests to an append-only, externally witnessed log; prevent presenting different histories to different observers. -4. Make the verifier compare live state both to the account manifest **and** to the independently signed policy release. -5. Define how destination and recovery-owner intent enter the trust root. A Vortex-controlled page displaying Vortex-controlled data is not an independent confirmation. -6. Specify who operates the independent check and what causes onboarding to halt if the provisioning backend itself is compromised. - -**Option B — narrower claim:** replace “fraud is detectable before first deposit” with “the deployed configuration is publicly auditable against a Vortex-published record.” Do not count this as a control against full provisioning compromise. - -### R02 — A second passkey is not RP-independent recovery - -**Priority:** P0 -**Affected PRD lines:** 43, 77–78, 96–97, 184–188, 270, 288 - -#### Concern - -The PRD permits “a second passkey on another device” as the mandatory independent recovery owner and later describes `(EOA/hardware/second passkey)` as RP-independent. A WebAuthn credential is scoped to its RP ID. A second device protects against loss of the first device; it does not protect against loss of control of the Vortex RP domain, browser origin, or domain-continuity infrastructure. - -The static recovery page also works only if it can be served from an origin accepted for the original RP ID. “Self-hostable” does not mean domain-independent. - -#### Required resolution - -1. Remove a same-RP passkey from the choices that satisfy the **independent** recovery requirement. -2. Require at least one RP-independent owner: hardware wallet, existing EOA, or offline recovery key. -3. A second passkey may remain as an additional convenience owner, but label it “device-redundant, RP-dependent.” -4. Threat-model the printable EOA: compromised-generation page, screenshots/cloud backup, printing, theft, inheritance, verification that the public address matches the paper secret, and rotation after suspected exposure. -5. Make the fork recovery test exercise the RP-independent owner with every Vortex service and the Vortex domain unavailable. Test the passkey/domain-continuity path separately. - -The W3C WebAuthn specification states that a credential can only be used with the RP ID for which it was registered. Device diversity does not alter that scope. - -### R03 — The delayed permissionless fallback has no enforceable start time - -**Priority:** P0 -**Affected PRD lines:** 107–120, 136–143, 267 - -#### Concern - -The module permits anyone to call only after the Safe's balance has exceeded `minSwapAmount` for `LIVENESS_FALLBACK_DELAY`. The proposed storage contains `initializedAt`, but no timestamp recording when a balance crossed the threshold. An ERC-20 transfer does not call the receiving Safe or module, so the module cannot observe and timestamp the crossing automatically. - -`initializedAt` cannot substitute for this: after an account is older than 24 hours, every new deposit would be immediately permissionless. - -#### Required resolution - -Select and specify one implementable policy: - -**Simplest:** make `swapAndForward` permissionless at all times. The fixed route and `minOut` already bound caller-controlled timing, but legal/compliance must accept that any observer may trigger conversion. - -**Delayed fallback:** add an explicit state machine: - -```text -arm(safe): - require(balance >= minSwapAmount) - if eligibleSince[safe] == 0: eligibleSince[safe] = block.timestamp - -swapAndForward(safe): - authorized keeper may execute immediately - other caller requires eligibleSince != 0 - other caller requires block.timestamp >= eligibleSince + delay - successful execution resets eligibleSince - a balance below threshold resets it without allowing a caller to move it forward -``` - -Define who may arm, whether arming itself is permissionless, how cap-sized partial executions re-arm residual balances, and how threshold/parameter changes affect an existing timer. Add these transitions to invariant and fuzz tests. - -### R04 — Live balance sweeps race deposit attribution - -**Priority:** P0 -**Affected PRD lines:** 88–91, 140–149, 220–242 - -#### Concern - -The database lock serializes Vortex keeper workers, but it cannot serialize external EURe transfers. Consider: - -1. the worker locks the Safe and selects deposits A and B; -2. deposit C mints on-chain before the keeper transaction executes, while its webhook/indexer record is delayed; -3. the module reads the live balance and sweeps A+B+C (subject to the cap); -4. `ConversionExecution.includedDepositIds` contains only A and B; -5. the PRD allocates all output pro-rata to A and B. - -The execution is safe on-chain but wrong in the accounting/API layer. The same problem occurs with direct third-party EURe transfers, delayed order-to-mint reconciliation, cap-sized partial consumption, and multiple issue orders sharing a batch. “On-chain balance is the safety source” does not by itself define accounting ownership. - -#### Required resolution - -Model funding and consumption as an event-sourced asset ledger: - -1. Record every EURe credit as a `FundingLot` keyed by `(chainId, txHash, logIndex)`, including amount, block position, source address, and classification `monerium_issue | direct_transfer | unresolved`. -2. Link Monerium orders to funding lots when evidence becomes available; do not require webhook arrival before the on-chain event can exist. -3. Create `ConversionExecution` from the confirmed execution receipt and emitted `amountIn/usdcOut/fee`, not from the pre-submission plan. -4. Consume funding lots in one documented order—FIFO by canonical block/transaction/log position is simpler than ad hoc pro-rata—and support partial lot consumption at `perSwapCap`. -5. If a consumed lot is not yet linked to a Monerium order, place its output in an accounting suspense bucket and reconcile later. Never silently allocate it to known deposits. -6. Handle execution reorgs by reversing ledger consumption and rebuilding from canonical logs. -7. Specify whether unsolicited EURe is converted, quarantined operationally, or merely classified after unavoidable conversion. - -An optional simplification is to let the keeper pass a bounded `amountIn` selected from its canonical snapshot while the module still constructs every destination/router/path field. This does not remove the need for receipt-based reconciliation, but it reduces accidental inclusion of a just-arrived deposit. - -### R05 — Per-account guardian pause and operational-limit semantics are absent - -**Priority:** P0 -**Affected PRD lines:** 116–120, 138–142, 175–178, 214–216, 248–262 - -#### Concern - -The incident section says the guardian can pause globally or per account, and destination screening says a hit causes a conversion pause. The proposed storage and execution checks contain only: - -- `Config.userPaused`, controlled by the Safe; and -- `globalPaused`, controlled by Vortex. - -There is no guardian-controlled per-Safe pause. Reusing `userPaused` would be unsafe semantically: the guardian must not clear a user's pause, and an owner action must not accidentally clear a compliance/incident hold. - -The pilot's “€1k/user/day operational limit” is also not a control as written. Vortex cannot stop an incoming SEPA payment, `perSwapCap` is proposed at €5–10k, and the permissionless fallback can execute without keeper policy. The document must say whether the limit is Monerium-enforced, contract-enforced, or merely monitored. - -#### Required resolution - -1. Add independent pause domains: - -```solidity -Config { ...; bool userPaused; } -mapping(address safe => bool) guardianPaused; -bool globalPaused; -``` - -2. Require all three to be false before conversion. -3. Define role-specific transitions: Safe owners control only `userPaused`; guardian controls only `guardianPaused` and `globalPaused`. -4. Define whether guardian unpause requires a second role, delay, resolved compliance state, or multisig policy. “Unpause is safe” is true for principal routing but not necessarily for sanctions/compliance obligations. -5. Make the delayed permissionless path honor both guardian pause levels. -6. Replace the ambiguous daily limit with an enforceable rule: a Monerium account limit, an on-chain rolling conversion limit, or an explicitly labeled monitoring/hold threshold. Define behavior for excess deposits and how the user exits. -7. Define the screening window between mint and permissionless eligibility. If the design promises a compliance hold, it needs enough deterministic time to set `guardianPaused` before fallback execution. - -### R06 — Webhooks must be durably accepted before returning `200` - -**Priority:** P1 -**Affected PRD line:** 234 - -#### Concern - -“Immediate `200` + async processing” is safe only if the verified event is committed durably before the response. If the API returns `200` and then crashes before persistence or queue publication, Monerium treats delivery as successful and will not retry it. - -#### Required resolution - -Specify the inbox transaction explicitly: - -1. read raw bytes and required signature headers; -2. validate timestamp/replay window and HMAC using constant-time comparison; -3. insert `{webhookId, webhookTimestamp, rawBody, signatureVersion, receivedAt, processingStatus}` under a unique constraint; -4. commit; -5. return `200` for a committed new event or already-committed duplicate; -6. return non-2xx if verification or durable insertion fails; -7. let a retryable worker process the inbox row and retain an error/dead-letter history. - -Monerium's current documentation signs `webhook-id`, `webhook-timestamp`, and the raw body and retries failed deliveries with the same ID. The PRD's desired controls match the provider; the missing point is ACK ordering. - -### R07 — Mutable owner configuration conflicts with continuous manifest verification - -**Priority:** P1 -**Affected PRD lines:** 123–132, 248–253 - -#### Concern - -The owner may change `destination`, while the manifest contains the “full config” and a continuous verifier checks live state against it. After a legitimate owner change, either: - -- the verifier reports a security incident forever; -- Vortex silently rewrites the manifest, weakening its historical value; or -- the owner must somehow publish an authenticated manifest revision, which is not specified. - -The same issue applies to owner/module changes performed through the disaster-recovery tool. - -#### Required resolution - -Split immutable and mutable evidence: - -- `DeploymentManifest`: immutable setup transaction, code hashes, initial owners/threshold/modules/config, signed policy release. -- `ConfigHistory`: append-only projection of canonical Safe/module events with block/log coordinates and reorg handling. -- `CurrentState`: derived from chain, with a classification of `authorized owner change`, `approved migration`, `unexpected code/state`, or `reorg pending`. - -The verifier should alarm on violations of policy and unexplained state transitions, not on every difference from genesis. Define how owner-authorized changes are authenticated and how the public record preserves old versions. - -### R08 — Opaque passkey owner signatures are omitted from the post-mint threat model - -**Priority:** P1 -**Affected PRD lines:** 64–68, 77–82, 93–97, 280, 297 - -#### Concern - -Appendix A correctly rejects the claim that an extra WebAuthn signature provides meaningful what-you-see-is-what-you-sign consent under a compromised frontend. The same limitation applies later: the passkey is a threshold-1 Safe owner that can disable the module, replace owners, change the destination, or transfer assets. A compromised Vortex origin can ask the user for an opaque passkey ceremony while presenting misleading UI. - -That is not unilateral Vortex authority—the attacker still needs user presence/verification—but it is a material route around the module policy and should not disappear from the S2/S4 threat model. - -#### Required resolution - -1. Add a residual risk: a compromised approved RP origin may induce a user to authorize a malicious Safe owner transaction because the authenticator does not display decoded Ethereum intent. -2. Make routine deposit/status flows never request a passkey assertion. Reserve owner signing for a visibly separate management/recovery flow. -3. Display decoded Safe transaction data, simulation, target code identity, and before/after owners/modules/destination in at least one independently implemented confirmation surface where feasible. -4. Decide whether dangerous owner operations need the RP-independent recovery owner as a second signature. If threshold 1 is retained, state the trade-off explicitly rather than treating the module as the only post-mint path. -5. Add an assumption to the final security statement: the user has not authorized a malicious owner transaction. - -### R09 — Full-USDC sweep and unsolicited EURe lack product/accounting rules - -**Priority:** P1 -**Affected PRD lines:** 143–151, 196–198, 204–216, 230–242 - -#### Concern - -The module charges the fee on `usdcDelta` but forwards the Safe's **full** remaining USDC balance. Therefore pre-existing or accidentally sent USDC is swept to `destination` without appearing in the conversion gross/net accounting. Likewise, anyone can send EURe to the public Safe; the module cannot know whether it came from the user's IBAN. - -This is not necessarily a principal-safety bug—the configured destination receives the assets—but it contradicts the impression that each forwarded amount maps cleanly to Monerium deposits and can create compliance and partner-API discrepancies. - -#### Required resolution - -Choose explicit rules: - -- Prefer forwarding exactly `usdcDelta - fee`, leaving pre-existing USDC under owner control; or state prominently that the Safe is an auto-sweeping account for **all** USDC. -- Treat direct EURe/USDC transfers as first-class ledger events, with source classification and an API representation. -- Decide whether direct EURe is supported, quarantined in accounting, or a disclosed unsupported action that may still convert because the contract cannot distinguish its provenance. -- Add tests with non-zero pre-swap USDC, direct EURe, fee-on/off, capped partial swaps, and late-arriving transfers. - -### R10 — Operational parameter and role invariants are incomplete - -**Priority:** P1 -**Affected PRD lines:** 111–124, 138, 173–178 - -#### Concern - -The storage sketch does not fully define whether operational parameters and keepers are global or per Safe, who may change each value, or which directions are delayed. It also permits contradictory values unless additional invariants are intended—for example `minSwapAmount > perSwapCap`, which permanently prevents execution. - -“Lowering instant, raising behind the timelock” is ambiguous because protective direction differs by parameter: lowering `perSwapCap` is protective, while lowering `minSwapAmount` causes more/smaller executions. - -#### Required resolution - -Define the complete setter table before audit: - -| Parameter | Scope | Invariant | Instant direction | Delayed direction | Role | -|---|---|---|---|---|---| -| `minSwapAmount` | global or per Safe | `MIN_SWAP_FLOOR <= minSwapAmount <= perSwapCap` | explicitly decide | explicitly decide | guardian/timelock | -| `perSwapCap` | global or per Safe | `minSwapAmount <= perSwapCap <= CAP_CEILING` | decrease | increase | guardian/timelock | -| keeper set | global | non-empty unless permissionless-only | removal during incident | addition/rotation policy | multisig/timelock | -| fallback delay | immutable or governed | bounded range | explicitly decide | explicitly decide | constructor/timelock | - -Specify pending-update cancellation, events, timelock identity, guardian rotation, lost-key response, and whether a global singleton creates an accepted all-account blast radius. Add invariant tests for every cross-parameter relationship. - -### R11 — “Always exit” remains stronger than the stated assumptions - -**Priority:** P2 -**Affected PRD lines:** 68, 93–97 - -#### Concern - -S4 says the user can “always exit with assets” given an owner credential, RPC, and funded relayer. Owner control guarantees the ability to authorize and submit a Safe transaction; it does not guarantee that EURe/USDC transfers or Monerium redemption will succeed under issuer freeze, blacklist, token pause/upgrade, chain censorship, or Monerium refusal. - -#### Required resolution - -Change the guarantee to: - -> Given a valid owner credential and transaction-submission path, the user can authorize arbitrary Safe transactions without Vortex and Vortex cannot veto them. Successful asset exit still depends on Ethereum, token contracts/issuers, and—when redeeming—Monerium. - -Add token/issuer restrictions to the S4 residual column. - -### R12 — The response appendix obscures the normative specification - -**Priority:** P2 -**Affected PRD lines:** 274–297 - -#### Concern - -The appendix is valuable review history, but it repeats security decisions and sometimes adds nuance not present in the normative sections. A future implementer now has to decide whether §1–§14 or Appendix A is authoritative. That is avoidable `🧠 → 🤯` load in an already cross-disciplinary specification. - -#### Required resolution - -After this review cycle: - -1. keep the PRD normative and self-contained; -2. move the response matrix to a separate decision/review log or mark it explicitly non-normative; -3. convert every accepted nuance into the applicable normative section; -4. add a compact invariant/role/state-transition appendix generated from the final design. - -## 6. Required acceptance gates for v3 - -Before production implementation freeze or security audit (G0 prototypes may proceed): - -- [ ] R01: manifest trust root and independent-verification claim are made precise. -- [ ] R02: recovery choices guarantee at least one RP-independent owner. -- [ ] R03: permissionless-fallback state machine is implementable and tested on paper. -- [ ] R04: funding-lot and execution-consumption ledger handles late/direct/reorged transfers. -- [ ] R05: user, guardian-account, and global pause domains are represented in storage and transitions. -- [ ] R06: webhook ACK occurs only after durable inbox commit. -- [ ] R07: manifest/config history handles legitimate owner changes. -- [ ] R08: opaque passkey owner-signature risk is included in the threat model and UX. -- [ ] R09: full-USDC and direct-token behavior is an explicit product rule. -- [ ] R10: all roles, setters, bounds, and timelock directions are specified. -- [ ] R11: S4 wording describes transaction authority rather than guaranteed asset exit. -- [ ] G0–G4 remain blocking for their stated implementation/launch stages. - -## 7. Requested author response - -Please respond with a disposition and proposed change for each item: - -| ID | Disposition (`Accept`, `Reject`, `Modify`, `Needs validation`) | Author response / proposed PRD change | -|---|---|---| -| R01 | | | -| R02 | | | -| R03 | | | -| R04 | | | -| R05 | | | -| R06 | | | -| R07 | | | -| R08 | | | -| R09 | | | -| R10 | | | -| R11 | | | -| R12 | | | - -The key requested answer is now narrower than in the first review: can the team define one deterministic lifecycle from “Monerium or direct EURe credit observed” through “eligible/paused,” “on-chain execution,” “canonical receipt,” and “deposit/output ledger allocation,” while preserving an independently recoverable owner and an independently meaningful provisioning record? - -## 8. Primary references checked during re-review - -- [Monerium Whitelabel webhooks](https://docs.monerium.com/whitelabel/) -- [Monerium API](https://docs.monerium.com/api/) -- [Safe fallback handler](https://docs.safe.global/advanced/smart-account-fallback-handler) -- [Safe and passkeys](https://docs.safe.global/advanced/passkeys/passkeys-safe) -- [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) -- [Uniswap v3 SwapRouter source](https://github.com/Uniswap/v3-periphery/blob/main/contracts/SwapRouter.sol) diff --git a/docs/prd/monerium-eur-usdc-onramp-architecture-review.md b/docs/prd/monerium-eur-usdc-onramp-architecture-review.md deleted file mode 100644 index 9e2fce802..000000000 --- a/docs/prd/monerium-eur-usdc-onramp-architecture-review.md +++ /dev/null @@ -1,934 +0,0 @@ -# Architecture Review: Quoteless EUR → USDC Onramp via Monerium - -**Reviewed document:** [monerium-eur-usdc-onramp.md](./monerium-eur-usdc-onramp.md) -**Review date:** 2026-07-13 -**Review status:** Blocking issues identified; author response requested -**Recommended decision:** **No-go for implementation or audit until the P0 findings are resolved** - -## 1. Purpose of this review - -This document is a deliberately adversarial review of the proposed Monerium EUR → USDC architecture. It is intended to be handed back to the authoring agent for a point-by-point response. - -For each finding, the author should state one of: - -- **Accept** — the PRD will be changed as recommended. -- **Reject** — explain why the concern does not apply and provide evidence. -- **Modify** — propose a different resolution and explain the resulting trust assumptions. -- **Needs validation** — identify the spike, Monerium confirmation, legal opinion, or contract prototype needed to decide. - -The review distinguishes between: - -- on-chain safety after EURe has reached the Safe; -- provisioning and fiat-ingress authority before mint; -- operational liveness and recovery; -- user-facing and legal claims; -- compatibility with Vortex's existing one-shot ramp architecture. - -## 2. Executive conclusion - -The product primitive is credible: a personal IBAN can mint EURe to a linked smart account, and an automated account policy can convert it to USDC. The current proposal, however, overstates the end-to-end security guarantee. - -The central claim — that Vortex can never redirect, withhold, or seize user funds and that a full Vortex compromise can lose only the slippage margin — is not established across the complete system. It currently excludes: - -1. Monerium address and IBAN-management authority; -2. malicious or incorrect Safe provisioning; -3. fallback-handler and recovery authority; -4. generic router calldata and unrelated Safe approvals; -5. passkey/domain dependence when Vortex disappears; -6. fees and future CoW execution; -7. the fact that a reusable IBAN is not a one-shot ramp. - -The architecture can be salvaged, but v1 should be materially smaller and the trust statement should be rewritten as a set of scoped guarantees rather than a single absolute claim. - -## 3. What appears sound - -The following foundations are reasonable, subject to sandbox and contract-level verification: - -- Monerium documents automatic issuance to the wallet linked to a dedicated IBAN. -- Monerium documents off-chain EIP-1271 verification for smart-contract-wallet address linking. -- Safe supports WebAuthn/passkey contract owners. -- Ethereum mainnet has supported the EIP-7951 P-256 precompile since Fusaka. -- Uniswap v3 supports a two-hop exact-input path in one router call. -- An immutable module that constructs tightly constrained calls and verifies token deltas is a sound direction. -- Oracle-based minimum output, strict staleness checks, no delegatecall, exact approvals, atomic forwarding, and invariant/fuzz tests are appropriate defenses. -- Explicitly acknowledging issuer freeze/upgrade powers and fail-safe reverts is correct. - -These strengths do not resolve the findings below, but they make a reduced v1 viable. - -## 4. Priority summary - -| ID | Priority | Finding | Blocks | -|---|---|---|---| -| F01 | P0 | Monerium control plane may redirect future deposits | Non-custody claim, GA | -| F02 | P0 | “Full Vortex compromise” excludes provisioning and fiat ingress | Security model, audit | -| F03 | P0 | The one signature does not bind destination or Safe configuration | Onboarding claim, destination attestation | -| F04 | P0 | Per-user module topology is internally contradictory | Contract design | -| F05 | P0 | RecoveryValidator is not an ordinary Safe module and may bypass ownership | Recovery, EIP-1271, custody | -| F06 | P0 | Router plus selector allowlisting is insufficient | Principal-safety invariant | -| F07 | P0 | CoW cannot preserve the synchronous module invariants behind the same interface | Route replaceability claim | -| F08 | P0 | A permanent IBAN cannot be represented as one terminal Vortex ramp | Backend architecture | -| F09 | P1 | Oracle math and economic bound are underspecified | Contract finalization | -| F10 | P1 | The proposed fee contradicts I1 and changes the compromise bound | Contract finalization | -| F11 | P1 | Passkey-only self-rescue depends on Vortex's RP domain and infrastructure | Liveness claim | -| F12 | P1 | Liquidity caps rely on snapshot TVL rather than executable depth | Availability and rollout | -| F13 | P1 | Webhook, reorg, concurrency, and deposit attribution rules are missing | Backend correctness | -| F14 | P1 | Immutable modules have no credible incident or migration path | Production safety | -| F15 | P1 | Compliance, data protection, and payment-reversal responsibilities are incomplete | Launch readiness | -| F16 | P1 | Dust, gas griefing, and destination edge cases break the automatic-flow promise | Product behavior | -| F17 | P2 | Several failure-mode statements need correction or sharper scope | Specification accuracy | -| F18 | P2 | v1 composes too many overlapping Safe extension mechanisms | Auditability and maintainability | - -## 5. Detailed findings - -### F01 — Monerium control-plane authority may redirect future deposits - -**Priority:** P0 -**Affected PRD sections:** §1, §4, §5.1, §8.3, §10 - -#### Concern - -The custody analysis begins after EURe reaches the expected Safe. It does not establish that Vortex lacks authority to change where future incoming EUR is minted. - -Monerium's published whitelabel documentation states that: - -- a whitelabel client can link addresses to a customer profile; -- `PATCH /ibans/{iban}` can re-associate an existing IBAN with a different linked address or chain; -- incoming payments are routed to the newly associated address; -- a SEPA memo can route issuance to another linked address. - -From the public API shape, a compromised Vortex backend appears able to link an attacker-controlled address — for which the attacker can provide a valid proof of ownership — and move the customer's IBAN to it. This is an inference that must be confirmed with Monerium, but it invalidates the current absolute claim unless Monerium applies additional scopes or user-authorization checks not described publicly. - -#### Required resolution - -Obtain a written and sandbox-verified Monerium guarantee that, for these profiles: - -1. Vortex cannot move the IBAN after activation without authorization from the currently linked Safe; -2. a new address cannot become the default mint address without equivalent end-user authorization; -3. memo-based routing can be disabled or constrained; -4. whitelabel credentials are scoped so their compromise cannot redirect customer issuance; -5. every attempted association change produces an independently monitored event. - -If Monerium cannot provide those controls, rewrite the trust model: - -> Vortex is trusted not to change the Monerium IBAN association. The on-chain non-custody guarantee starts only after EURe is finalized at the verified Safe. - -#### Questions for the author - -- What exact authorization does Monerium require for `PATCH /ibans/{iban}`? -- Can Vortex link its own address to a customer's profile using Vortex-controlled proof of that address? -- Can profiles be configured with exactly one non-movable Ethereum address? -- Can memo routing be disabled? -- How are legacy profiles with existing linked addresses handled? - -### F02 — The “full Vortex compromise” bound is not a full-compromise bound - -**Priority:** P0 -**Affected PRD sections:** §4, §8.2, §8.3, §14 - -#### Concern - -The §8.3 analysis covers a compromised keeper and registry multisig. A full Vortex compromise also includes: - -- Monerium client credentials; -- factory bytecode and deployment calldata; -- frontend-provided configuration; -- Safe singleton and proxy-factory selection; -- Safe owners and threshold; -- enabled modules, guard, module guard, and fallback handler; -- passkey signer coordinates and verifier configuration; -- destination, fee recipient, recovery IBAN, and recovery policy. - -A Safe can contain the user's passkey owner and still contain an attacker owner or unrestricted module. The fixed Monerium ownership message can validate through the user's owner without proving that the remainder of the Safe configuration is safe. - -#### Required resolution - -Rename §8.3 to **“Post-deployment keeper and route-governance compromise”** and add separate threat bounds for: - -1. provisioning compromise; -2. frontend compromise; -3. Monerium credential compromise; -4. oracle compromise; -5. passkey compromise; -6. stablecoin issuer compromise; -7. dependency upgrade or code-substitution compromise. - -Before address linking, both frontend and backend should independently verify the deployed account against a versioned public manifest containing: - -- chain ID; -- exact Safe singleton runtime hash and version; -- canonical proxy factory; -- owners and threshold; -- enabled modules; -- fallback handler; -- transaction guard and module guard; -- module runtime hash and immutable arguments; -- destination; -- fee configuration; -- oracle and registry addresses; -- recovery configuration. - -#### Questions for the author - -- Which components are assumed honest during provisioning? -- What prevents a compromised frontend and backend from presenting a Safe with an extra module? -- What independent evidence can a user or external auditor use to verify a specific deployed account? - -### F03 — The one Monerium signature does not bind the destination or configuration - -**Priority:** P0 -**Affected PRD sections:** §3.1, §5.1, §10 Q2 - -#### Concern - -The fixed message `I hereby declare that I am the address owner.` contains no: - -- chain ID; -- Safe address in the signed text; -- destination; -- module implementation or runtime hash; -- fee rule; -- router/oracle configuration; -- recovery policy. - -The assertion proves control of the configured Safe owner under the Safe's current EIP-1271 behavior. It does not “implicitly ratify” an off-chain destination shown in the UI. - -Requiring cryptographic ownership of an arbitrary external destination creates a second conflict: ownership must be proven by the destination's key, which cannot generally be proven by the Safe passkey and may require a wallet extension or separate signer. - -#### Required resolution - -Choose one explicit model: - -1. **Two-signature model:** retain the Monerium link assertion and add an EIP-712 deployment-intent signature covering the complete manifest. Add a separate destination signature if destination ownership is required. -2. **Safe-as-destination model:** retain USDC in the passkey-owned Safe and remove arbitrary destination attestation and forwarding. -3. **Trusted provisioning model:** keep one signature but state that the user trusts Vortex's frontend and provisioning backend to deploy the displayed configuration. -4. **Custom signer model:** create and audit a custom WebAuthn signer whose validation of the fixed Monerium hash also commits to immutable account configuration. This adds substantial bespoke cryptographic risk and is not recommended for v1. - -#### Questions for the author - -- Is “one signature” a hard product constraint or a preference? -- What constitutes destination ownership for exchanges, smart accounts, and custodial deposit addresses? -- Is a UI confirmation intended as legal consent, cryptographic consent, or both? - -### F04 — The module topology is internally contradictory - -**Priority:** P0 -**Affected PRD sections:** §5.1, §6.3, §8.1, I8 - -#### Concern - -The PRD describes `VortexSwapModule` as: - -- an immutable per-deployment singleton; -- parameterized per Safe; -- containing per-user immutable destination/configuration; -- allowing the user to change destination. - -A single Solidity deployment cannot have different constructor immutables per Safe. A contract-level immutable destination also cannot be changed. If configuration is stored in a mapping, it is storage, not immutable, and requires an initialization/update authorization design. - -#### Required resolution - -Specify one topology in pseudocode and storage layout: - -**Option A — per-Safe minimal clone with immutable arguments** - -- one module instance per Safe; -- destination, oracle, maximum slippage, registry, and fee configuration encoded as immutable arguments; -- changing destination means deploying and owner-authorizing a new module, then disabling the old one. - -**Option B — singleton with Safe-keyed configuration** - -- configuration initialized atomically during Safe setup; -- initialization cannot be front-run or repeated; -- updates are accepted only when the corresponding Safe itself calls through an owner-authorized transaction; -- events and explicit versioning cover every mutation. - -Pin an exact audited Safe version and deployed addresses. `v1.4.1+` is not a reproducible security dependency. - -#### Questions for the author - -- Is the module one deployment per user or one deployment for all users? -- Where exactly is destination stored? -- What on-chain operation implements “change destination”? -- Does the custom `VortexAccountFactory` add value beyond canonical Safe factories sufficient to justify its audit surface? - -### F05 — The recovery validator is not an ordinary Safe module - -**Priority:** P0 -**Affected PRD sections:** §5.3, §6.3, §8.4, Q3, Q4 - -#### Concern - -Enabling a normal Safe module does not change Safe's `isValidSignature`. ERC-1271 support is normally provided by a fallback handler. The design also needs ERC-4337, whose `Safe4337Module` acts as both module and fallback handler. The future CoW proposal expects an extensible fallback-handler path. These cannot be installed independently without an explicit composition design. - -Further issues: - -1. ERC-1271 receives a message hash and signature. It cannot parse the original redeem string unless that string is encoded into the signature payload and re-hashed on-chain. -2. Monerium documents both full and shortened IBAN forms. The policy needs one canonical representation. -3. Monerium accepts timestamps close to the present or in the future; the contract must impose its own narrow validity window. -4. “Whitelisting the link-message shape” is dangerous. If this means returning valid without a genuine passkey owner signature, anyone could satisfy Monerium's ownership check for the Safe. -5. A Vortex-triggerable redemption, even to a pinned bank account, gives Vortex power to dispose of user assets and changes the legal custody analysis. -6. A refund IBAN can be closed, reassigned, or cease belonging to the user. -7. Replay protection needs more than timestamp parsing: chain, Safe, Monerium profile, order identity, exact amount, currency, canonical IBAN, and prior-use state must be bound. - -#### Required resolution - -The recommended v1 resolution is to remove automatic EIP-1271 redemption and use: - -- the user's passkey owner; plus -- an independent, user-controlled recovery owner; -- an owner-authorized Monerium redeem order when recovery is required. - -If automated redemption remains, write a separate protocol specification covering: - -- the exact fallback-handler composition; -- signature byte schema; -- message reconstruction and hash equality; -- canonical amount, timestamp, currency, and IBAN encoding; -- replay state; -- caller independence; -- passkey validation for the address-link message; -- interaction with ERC-4337 and CoW domains; -- legal treatment of Vortex's unilateral redemption authority. - -#### Questions for the author - -- Is `RecoveryValidatorModule` intended to be a module, fallback handler, owner, or ERC-7579 validator? -- How does it obtain the original message bytes from the ERC-1271 call? -- Who or what produces the `signature` bytes for a policy-authorized redemption? -- Does the link message still require a genuine passkey assertion? - -### F06 — Router address plus selector allowlisting is insufficient - -**Priority:** P0 -**Affected PRD sections:** §5.2, §7.4, I1, I2, I3, I7, I9 - -#### Concern - -A selector does not constrain the semantic fields inside calldata. Uniswap `exactInput`, for example, still contains: - -- arbitrary token path; -- recipient; -- amount in; -- minimum output; -- deadline. - -Aggregator entry points can contain arbitrary nested calls. A malicious router may also exploit unrelated token or Permit2 allowances previously created by the Safe owner. Post-conditions over EURe and USDC do not prove that no other approved asset was taken. - -The current I1 statement is therefore stronger than what the described implementation enforces. - -#### Required resolution - -For each supported route, use an immutable, router-specific adapter or have the module construct calldata internally. Enforce: - -- exact router address; -- `value == 0`; -- `operation == CALL`; -- exact token path `EURe V2 → EURC → USDC`; -- exact `amountIn`; -- recipient equal to the Safe during an atomic swap-and-forward flow; -- router-level `amountOutMinimum >= module minOut`; -- a short deadline or execution-validity rule; -- successful return from every `execTransactionFromModule` call; -- safe ERC-20 handling for missing/false return values; -- exact allowance reset; -- expected balance changes and final transfer success. - -Scope the guarantee to EURe and USDC in a dedicated Safe. State explicitly that unrelated assets or approvals introduced by the user are outside the guarantee unless independently protected. - -Governance should allow **immediate route revocation** but require delay for additions or authority expansion. Waiting seven days to remove a compromised router is unacceptable. - -#### Questions for the author - -- Will route calldata be constructed by the module, decoded by the module, or trusted from the keeper? -- How are aggregators with arbitrary executor calldata intended to satisfy I1? -- Is the Safe contractually/product-restricted to this flow only? - -### F07 — CoW is not replaceable behind the synchronous router interface - -**Priority:** P0 -**Affected PRD sections:** §7.4, §8.2 - -#### Concern - -ComposableCoW is a persistent order-authorization system followed by asynchronous settlement. It uses EIP-1271 through an extensible fallback handler and introduces a different lifecycle: - -- conditional-order authorization; -- watchtower discovery; -- order generation; -- solver execution; -- settlement-time signature verification; -- allowance and receiver constraints; -- expiry, cancellation, replay, and possibly partial fills. - -It does not execute inside one `swapAndForward` call where the module can synchronously snapshot balances, call a router, reset approval, verify deltas, and forward output. - -#### Required resolution - -Remove CoW from the claim that all future routes fit the same interface and invariants. Treat it as a separate v2 architecture with its own threat model and fallback-handler composition. - -That v2 specification must cover: - -- exact sell token and maximum amount; -- exact buy token and receiver; -- oracle-derived minimum buy amount; -- order validity and replay domain; -- partial fills; -- cancellation and migration; -- settlement allowance; -- fallback-handler coexistence with ERC-4337 and recovery; -- output attribution to fiat deposits. - -#### Questions for the author - -- Is CoW intended as a synchronous router, a standing order, or a recurring conditional order? -- How would the current post-swap balance-delta invariant be preserved across asynchronous settlement? - -### F08 — A permanent IBAN is not a one-shot Vortex ramp - -**Priority:** P0 -**Affected PRD sections:** §5.2, §6.1, §11 - -#### Concern - -The proposed IBAN can receive deposits repeatedly for years. Vortex's existing ramp model represents one quote and one transaction that recursively reaches a terminal `complete` or `failed` phase. - -Reusing that state machine creates unresolved questions: - -- What reopens a completed ramp? -- How are two rapid deposits represented? -- Which output belongs to which bank deposit if balances are batched? -- How are duplicate Monerium webhooks handled? -- What is the status of the account when one deposit succeeds and another is held for compliance? -- How are historical fees and execution rates represented? - -The current phase processor also documents a non-atomic multi-instance lock and retry-exhaustion gap. A persistent, repeatedly funded Safe should not rely on those semantics without database-enforced serialization. - -#### Required resolution - -Introduce separate persistent models: - -```text -MoneriumAccount - profileId - iban - safeAddress - currentConfigVersion - onboarding/compliance/account status - -FiatDeposit - moneriumOrderId - amount/currency - payment status - mint tx hash + log index + block - compliance status - -ConversionExecution - safeAddress - included deposit IDs - EURe input - USDC gross output - fee - USDC net output - destination - execution tx/status/error -``` - -Use unique Monerium order IDs and `(chainId, txHash, logIndex)` as idempotency keys. Serialize execution by Safe using an atomic database lock or queue. If deposits are batched, define an auditable allocation and rounding rule. - -#### Questions for the author - -- Is the Vortex public API expected to expose one ramp forever or one transaction per deposit? -- Are deposits converted independently or batched? -- What webhook/status object does a partner subscribe to for recurring deposits? - -### F09 — Oracle math and the economic bound are underspecified - -**Priority:** P1 -**Affected PRD sections:** §7.2, §8.3, §11 - -#### Concern - -The formula mixes human-readable and raw token/feed units. For raw values with: - -- EURe: 18 decimals; -- Chainlink EUR/USD: expected 8 decimals, but must be queried; -- USDC: 6 decimals; - -the scale denominator is `10^(18 + feedDecimals - 6)`, which is `10^20` for an 8-decimal feed. The written `10^(-12)` is understandable only if the rate is already a unitless human value, which Solidity will not receive. - -The bound also assumes one USDC equals one USD. EUR/USD does not directly price either EURe market basis or USDC/USD basis. Chainlink deviation threshold is not a complete bound on either stablecoin. - -FX feeds also have market-hours/weekend behavior while the AMM is continuously tradable. - -#### Required resolution - -Specify executable pseudocode including: - -- reading `decimals()`; -- `answer > 0`; -- `updatedAt != 0`; -- maximum age and weekend policy; -- round validity checks appropriate to the chosen feed contract; -- `mulDiv` ordering and overflow safety; -- whether minimum output rounds down; -- use or rejection of a USDC/USD feed; -- behavior during EURe or USDC depeg; -- a maximum oracle age that is an immutable safety ceiling, even if an operational value can be lowered. - -Rewrite the loss statement as: - -> The swap cannot deliver less than the configured percentage of the accepted oracle-model value, assuming the oracle is honest and both token contracts behave as modeled. - -Do not call that a bound on principal under oracle or stablecoin failure. - -#### Questions for the author - -- Is USDC/USD explicitly assumed to be 1.0? -- What happens from Friday FX close through Monday updates? -- What exact proxy address, feed decimals, heartbeat, and failure policy will be pinned? - -### F10 — The fee proposal contradicts the invariants - -**Priority:** P1 -**Affected PRD sections:** §3.3, I1, §8.3, §9, Q1 - -#### Concern - -I1 says USDC can move only to the user's destination. §9 proposes sending an in-module fee to a Vortex treasury. That adds another permitted recipient and changes: - -- custody language; -- user net-output calculation; -- minimum-output calculation; -- balance-delta post-conditions; -- worst-case Vortex extraction; -- event and accounting requirements. - -The module cannot be finalized while fee behavior remains open. - -#### Required resolution - -Choose before contract design: - -1. zero fee/loss-leader for v1; -2. off-chain subscription or billing; -3. immutable on-chain `feeBps`, immutable treasury, immutable maximum fee, and exact calculation from swap output. - -If option 3 is chosen, update I1 and §8.3 and distinguish the disclosed fee from slippage and malicious extraction. - -#### Questions for the author - -- Is the fee assessed per deposit, per batch, or per execution? -- Who pays when multiple small deposits are batched? -- Can the fee or treasury ever change, and under whose authorization? - -### F11 — Passkey-only self-rescue depends on Vortex's domain and services - -**Priority:** P1 -**Affected PRD sections:** §5.3, §11, Q3 - -#### Concern - -WebAuthn credentials are scoped to a relying-party ID. A community recovery site on an unrelated domain cannot invoke a passkey registered for Vortex's RP ID. - -Self-rescue also requires: - -- access to the relevant RP domain/origin; -- a recoverable or discoverable credential; -- credential metadata where needed; -- a transaction builder that knows the Safe/passkey signature format; -- an RPC provider; -- ETH or a Vortex-independent bundler/paymaster/relayer. - -Cloud synchronization is not guaranteed for every credential, device, policy, or authenticator. Safe's own guidance recommends combining passkeys with other authentication methods. - -#### Required resolution - -Make an independent, user-controlled recovery owner mandatory for v1, such as a second passkey under an independent RP, a hardware wallet, or a carefully audited user-controlled recovery scheme. - -Publish and test a disaster-recovery package that can: - -- reconstruct the account from public chain data; -- invoke the user's credential under the correct RP ID; -- build a direct Safe transaction without Vortex's API; -- fund gas without Vortex's paymaster; -- disable the module, change destination, or redeem EURe. - -Document domain-control continuity if Vortex ceases operation. - -#### Questions for the author - -- What RP ID will be used? -- Are credentials required to be discoverable and backup-eligible? -- How does recovery work if Vortex loses its domain or backend database? -- Why is recovery optional if permanent self-custody is a core claim? - -### F12 — The liquidity cap is based on a snapshot, not a durable bound - -**Priority:** P1 -**Affected PRD sections:** §2.1, §7.3, §7.4, §12 - -#### Concern - -Pool TVL is not equivalent to executable depth within 100 bps. Concentrated liquidity can move out of range, providers can withdraw, and routing can change between measurements. - -“Successive executions spaced over time” is keeper policy, not an on-chain invariant. A compromised keeper or permissionless caller can submit multiple cap-sized transactions in rapid succession. Even honest executions may not recover liquidity merely because time passed. - -The quoted 10k and 50k results are also not reproducible without block numbers, quote parameters, gas/fee treatment, route, quote expiry, and provider response. - -#### Required resolution - -- Record a reproducible liquidity-assessment methodology and block number. -- Treat `minOut` as the safety condition and the size cap as an availability parameter. -- Monitor executable on-chain quotes and active liquidity continuously. -- Define launch and pause thresholds. -- If pacing is a requirement, enforce an aggregate per-Safe or global rate limit on-chain; otherwise describe pacing as best-effort only. -- Do not raise caps solely from TVL. - -#### Questions for the author - -- What exact evidence produced “~zero impact” and “~3.6% impact”? -- Is the cap intended to protect price, liveness, platform exposure, or all three? -- What automatically pauses execution when the pool migrates or liquidity disappears? - -### F13 — Webhook, reorg, concurrency, and attribution rules are incomplete - -**Priority:** P1 -**Affected PRD sections:** §5.2, §6.1, §11, §14.9 - -#### Concern - -“Webhook plus on-chain watcher” is a trigger strategy, not an idempotency or reconciliation design. - -Missing requirements include: - -- Monerium HMAC verification over raw request bytes; -- timestamp/replay-window validation; -- constant-time signature comparison; -- persisted webhook-ID deduplication; -- returning `200` promptly and processing asynchronously; -- event ordering and out-of-order updates; -- canonical-chain confirmation policy; -- reorg reconciliation using block hash and log identity; -- unique keeper nonce management across instances; -- serialization of concurrent executions for one Safe; -- stale private-relay transaction replacement; -- allocation of one batched output across several fiat deposits; -- reconciliation between Monerium order amount and actual minted amount; -- notification correction if an event is later reorged or reversed operationally. - -#### Required resolution - -Add an operational state and idempotency specification. On-chain balance should be the execution safety source, while Monerium issue-order IDs should be the accounting identity source. - -Use a database-enforced per-Safe execution lock. Contract-level reentrancy protection does not serialize separate transactions from multiple keeper processes. - -#### Questions for the author - -- How many confirmations are required before conversion and before notification? -- Can two deposits be intentionally combined into one swap? -- How are fees and output allocated when that happens? -- What prevents two keeper instances from racing the same Safe balance? - -### F14 — Immutable modules need an incident and migration path - -**Priority:** P1 -**Affected PRD sections:** §3.3, §7.4, I5, §11, §12 - -#### Concern - -Immutability prevents Vortex from introducing a malicious upgrade, but it also prevents patching a discovered module bug. Users with lost passkeys may continue receiving future deposits into an account whose automation is disabled or vulnerable. - -The registry can halt routes but cannot repair module logic. Moving an IBAN to a new Safe reintroduces F01 and requires an explicit user-authorization model. - -#### Required resolution - -Define before launch: - -- immediate route-disable authority; -- how Monerium deposits are paused or rejected during an incident; -- how users are notified before sending more EUR; -- module version discovery; -- owner-authorized migration to a new module/Safe; -- whether the existing IBAN can move only with the old Safe's signature; -- treatment of users who lost all recovery methods; -- sunset and support duration for old versions. - -#### Questions for the author - -- What happens after a critical module vulnerability is discovered at 02:00 UTC? -- Can Monerium suspend an individual IBAN immediately? -- How is a safe migration reconciled with the “one signature ever” promise? - -### F15 — Compliance and data-protection architecture is incomplete - -**Priority:** P1 -**Affected PRD sections:** §5.1, §6.1, §10, Q6, Q7 - -#### Concern - -The whitelabel flow makes Vortex responsible for handling or orchestrating sensitive identity, banking, and wallet data. The PRD does not define: - -- personal-only versus corporate scope; -- KYC sharing versus KYC reliance prerequisites; -- controller/processor roles and DPA terms; -- retention and deletion rules; -- encryption and access control for IBAN/profile data; -- log redaction and support tooling; -- sanctions and destination screening; -- periodic re-screening of a static destination; -- profile suspension, re-KYC, or closure behavior; -- SEPA recall/fraud claim allocation after EURe has been swapped and forwarded; -- legacy profile migration consent; -- source-of-funds and expected-volume monitoring; -- travel-rule and third-party destination consequences. - -#### Required resolution - -For v1, strongly consider personal, newly onboarded users only. Add a data-flow diagram and retention/access table. Obtain explicit MSA answers for recalls, fraud, compliance holds, profile suspension, IBAN changes, and losses after immediate conversion. - -Do not present the legal non-custody or MiCA conclusion as established until counsel has assessed both on-chain module authority and Monerium control-plane authority. - -#### Questions for the author - -- Is corporate onboarding genuinely in v1 scope? -- Who bears loss if a SEPA transfer is later recalled or alleged fraudulent? -- Can Vortex continue converting while a profile is under review or suspended? -- Who screens and re-screens the destination? - -### F16 — Dust, gas griefing, and destination edge cases break the automatic promise - -**Priority:** P1 -**Affected PRD sections:** §3, §5.1, §5.2, §7.3, §9, §11 - -#### Concern - -The headline says any EUR wired to the IBAN is automatically converted and forwarded. The €25 minimum means a smaller transfer may remain indefinitely as EURe. Mainnet gas can also make €25 uneconomical. - -A user can create recurring keeper costs by sending many small transfers. Vortex cannot prevent transfers to an already issued IBAN, so API-side rate limiting is insufficient. - -Destination risks include: - -- zero/burn/token/router/module addresses; -- wrong chain despite a valid EVM address; -- a contract with no method to recover received ERC-20s; -- exchange minimum-deposit thresholds; -- exchange address rotation or closure; -- destination sanctions/blacklisting after onboarding; -- destination equal to the Safe, where forwarding is redundant; -- loss of access to the destination despite continued automatic transfers. - -#### Required resolution - -- State a minimum deposit and maximum processing delay in user-facing terms. -- Define whether dust is refunded, accumulated, or held indefinitely. -- Make the operational threshold responsive to gas while retaining an immutable safety ceiling/floor where necessary. -- Batch deposits deliberately and disclose batching latency. -- Define destination validation and denylist rules. -- Warn that Vortex cannot prove recoverability of arbitrary contracts or exchange deposits. -- Monitor destination blacklist/sanctions state and define what happens to future deposits. - -#### Questions for the author - -- Who pays gas when fees are below execution cost? -- What happens to a €1 deposit if no further transfer arrives? -- What happens if the destination exchange raises its minimum deposit above the user's normal amount? - -### F17 — Failure-mode statements need correction and sharper scope - -**Priority:** P2 -**Affected PRD sections:** §5.2, §8.5, §11 - -#### Concern - -Several statements should be corrected or qualified: - -1. If swap and USDC forwarding are one atomic transaction, a Circle-blacklisted destination should make the final transfer revert and roll back the swap. Newly acquired USDC should not remain in the Safe from that execution. -2. “Vortex can censor, not execute” is inaccurate if Vortex controls a module capable of causing swap and transfer. The intended distinction is that Vortex can execute only the constrained policy. -3. “Cannot withhold” conflicts with acknowledged keeper censorship, registry route removal, and possible Monerium control-plane changes. Prefer “cannot redirect already-minted assets outside the enumerated policy under stated assumptions.” -4. “Maximum extractable value is 1.15%” is relative to the oracle model, excludes fees, stablecoin basis, provisioning, Monerium authority, other Safe assets/allowances, and oracle failure. -5. Passkeys may be synced; they are not universally cloud-synced by default. -6. Mainnet EIP-7951 is already live as of this PRD date; the spike should benchmark the exact chosen Safe/passkey implementation rather than treat post-Fusaka support as hypothetical. - -#### Required resolution - -Replace absolute language with a matrix of guarantees and assumptions. Every user-facing security claim should specify: - -- asset and lifecycle stage; -- trusted dependencies; -- authorized Vortex actions; -- excluded compromise classes; -- liveness versus theft protection; -- recovery requirements. - -### F18 — v1 combines too many overlapping extension mechanisms - -**Priority:** P2 -**Affected PRD sections:** §6, §7.4, §8.4, §12 - -#### Concern - -The proposed v1/v2 path requires engineers and auditors to reason simultaneously about: - -- Safe owner signatures; -- WebAuthn encoding and P-256 verification; -- ERC-4337 module/fallback behavior; -- a custom swap module; -- a router registry and timelock; -- a custom recovery EIP-1271 policy; -- potentially an extensible fallback handler; -- potentially ComposableCoW; -- a custom account factory; -- a persistent off-chain state machine. - -Much of the underlying problem is intrinsically complex, but the proposed expression adds avoidable cross-product complexity. In particular, recovery, ERC-4337, and CoW all touch Safe extension/fallback behavior, while the generic registry attempts to make materially different execution models look interchangeable. - -#### Required resolution - -Apply design sacrifice to v1: - -- one exact Safe deployment path; -- one passkey owner plus one independent recovery owner; -- one per-Safe swap module topology; -- one hard-coded Uniswap adapter; -- no generic aggregator calldata; -- no CoW; -- no automatic redeem validator; -- no mutable fee until finalized; -- one persistent-account/deposit/execution data model. - -Add complexity only after a concrete operational need and a new threat model justify it. - -## 6. Proposed reduced v1 architecture - -This is a strawman for discussion, not a final specification. - -### 6.1 Scope - -- Personal profiles only. -- Newly onboarded users only; legacy migration deferred. -- Ethereum mainnet only. -- EURe V2 → EURC → USDC through one Uniswap v3 route. -- One destination configured at onboarding. -- Explicit minimum deposit and processing SLA. -- No automated offramp/redeem. -- No CoW or generic aggregators. - -### 6.2 Monerium prerequisite - -Do not proceed unless Monerium provides an enforceable mechanism under which the IBAN association cannot be moved and no alternative mint destination can be added or selected without authorization from the currently linked Safe. - -If that is unavailable, preserve the product but change the security claim to acknowledge Vortex trust before mint. - -### 6.3 Safe - -- Pin exact canonical Safe contracts and runtime hashes. -- Passkey signer as one owner. -- Independent user-controlled recovery owner from launch. -- Threshold/recovery structure explicitly documented. -- Canonical Safe deployment components preferred over a custom factory unless atomic custom deployment is demonstrably necessary. -- Publish a configuration manifest and verifier. - -### 6.4 Swap policy - -- One per-Safe immutable module clone. -- Module constructs Uniswap calldata internally. -- Permissionless trigger if timing-grief analysis accepts it; otherwise a narrowly authorized keeper with a liveness fallback. -- `CALL` only, `value == 0`. -- Exact EURe approval, exact path, Safe recipient, module-derived `minOut`, short deadline. -- Atomic swap, approval reset, output check, fee calculation if any, and final transfer. -- Immediate route disable; no new route types in v1. - -### 6.5 Pricing - -- Exact Chainlink feed address and decimal handling. -- Explicit USDC/USD assumption or second feed. -- Immutable maximum staleness and maximum slippage. -- Weekend policy. -- Reproducible rounding and overflow behavior. - -### 6.6 Backend - -- Persistent `MoneriumAccount`. -- One `FiatDeposit` per Monerium issue order. -- One `ConversionExecution` per swap or intentional batch. -- Atomic per-Safe job serialization. -- HMAC-verified, deduplicated Monerium webhooks. -- On-chain watcher as reconciliation and trigger backup. -- Explicit reorg/finality policy. - -### 6.7 Recovery and migration - -- User or independent recovery owner signs any Safe exit or Monerium redeem order. -- No Vortex-only EIP-1271 redemption bypass. -- Public, tested disaster-recovery tooling. -- Immediate account/route pause and user notification during incidents. -- Owner-authorized module or account migration. - -### 6.8 Fees - -Prefer zero fee or off-chain billing for the first pilot. If an on-chain fee is required, finalize it before audit and include its immutable recipient and maximum in the signed configuration and contract invariants. - -## 7. Required acceptance gates - -The design should not advance from architecture review until all of the following are complete: - -- [ ] Monerium confirms IBAN/address reassociation authorization and memo-routing controls in writing and sandbox. -- [ ] The trust model is split by lifecycle stage and compromise class. -- [ ] One concrete module topology and storage layout is selected. -- [ ] The onboarding signature model honestly addresses configuration and destination consent. -- [ ] Recovery is redesigned or removed from v1. -- [ ] Router calldata is constructed or fully semantically validated on-chain. -- [ ] CoW is removed from the same-interface claim or specified separately. -- [ ] The backend uses persistent accounts plus per-deposit/per-execution records. -- [ ] Oracle pseudocode, decimals, staleness, weekend behavior, and rounding are finalized. -- [ ] Fee behavior is finalized and added to invariants. -- [ ] A Vortex-independent user recovery path is demonstrated. -- [ ] Liquidity measurements are reproducible and continuously monitored. -- [ ] Webhook, reorg, concurrency, and idempotency behavior is specified. -- [ ] Incident pause, module migration, and IBAN migration procedures are documented. -- [ ] Legal, compliance, privacy, sanctions, and payment-reversal responsibilities are signed off. -- [ ] User-facing minimums, timing, rate basis, fees, and failure behavior are drafted. -- [ ] Exact dependency versions, addresses, runtime hashes, and audit artifacts are pinned. - -## 8. Requested author response - -Please respond by copying this table and filling in the final two columns. - -| ID | Disposition (`Accept`, `Reject`, `Modify`, `Needs validation`) | Author response / proposed PRD change | -|---|---|---| -| F01 | | | -| F02 | | | -| F03 | | | -| F04 | | | -| F05 | | | -| F06 | | | -| F07 | | | -| F08 | | | -| F09 | | | -| F10 | | | -| F11 | | | -| F12 | | | -| F13 | | | -| F14 | | | -| F15 | | | -| F16 | | | -| F17 | | | -| F18 | | | - -The most important requested answer is not whether each mechanism can be implemented individually. It is whether the revised composition supports a precise, end-to-end security statement that remains true under every authority Vortex actually holds. - -## 9. Primary references - -- [Monerium Whitelabel documentation](https://docs.monerium.com/whitelabel/) -- [Safe smart-account modules](https://docs.safe.global/advanced/smart-account-modules) -- [Safe fallback handlers](https://docs.safe.global/advanced/smart-account-fallback-handler) -- [Safe and ERC-4337](https://docs.safe.global/advanced/erc-4337/4337-safe) -- [Safe and passkeys](https://docs.safe.global/advanced/passkeys/passkeys-safe) -- [Safe passkey signer guidance](https://docs.safe.global/sdk/signers/passkeys) -- [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) -- [EIP-7951 P-256 precompile](https://eips.ethereum.org/EIPS/eip-7951) -- [Ethereum Fusaka overview](https://ethereum.org/roadmap/fusaka/) -- [Chainlink Ethereum EUR/USD feed](https://data.chain.link/feeds/ethereum/mainnet/eur-usd) -- [ComposableCoW architecture](https://cowswap.mintlify.app/composable-cow/architecture) -- [Vortex state-machine security specification](../security-spec/03-ramp-engine/state-machine.md) -- [Historical Vortex Monerium security specification](../security-spec/05-integrations/monerium.md) diff --git a/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md b/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md deleted file mode 100644 index 4aaf44788..000000000 --- a/docs/prd/monerium-eur-usdc-onramp-b2b-variant.md +++ /dev/null @@ -1,151 +0,0 @@ -# B2B Variant: Zero-Touch Onboarding via Attestor-Linked Forwarder - -**Status:** Selected for implementation (2026-07-17). Monerium compliance verbally accepted the pattern (Telegram; conditional on fallback capability, which is now mandatory by design). Written approval pending (G1), legal review pending (G2), adversarial review running in parallel with implementation. -**Date:** 2026-07-14, updated 2026-07-17 -**Related:** [main PRD v2](./monerium-eur-usdc-onramp.md) · [review](./monerium-eur-usdc-onramp-architecture-review.md) · [re-review](./monerium-eur-usdc-onramp-architecture-rereview.md) · [deferred decisions](./monerium-onramp-deferred-decisions.md) · [implementation plan](./monerium-b2b-implementation-plan.md) - -**Update 2026-07-17 — Monerium compliance outcome and scope decisions:** - -- Monerium compliance accepts the attestor pattern **provided fallback capabilities are maintained**, and requires that clients be **explicitly informed that this setup limits their ability to redeem EURe directly with Monerium**. We committed to that disclosure (registry item B6). -- **Tier C is dropped:** a self-custodied `fallbackAddress` is mandatory for every client. Sections below that describe Tier C are retained for historical context only. -- **Issuer recovery backstop confirmed with a catch:** Monerium can burn tokens from a linked address and pay out — only to the customer's own external bank account, currently no fees, re-verification possible. But their recovery flow validates a signature against the linked address, which our constrained `isValidSignature` would reject. Exact recovery-message format needed from their technical team (registry item T1) so the forwarder can whitelist its hash at compile time; if unanswered by deploy, ship without it — the mandatory fallback address carries recovery, and the issuer backstop becomes best-effort. -- **Scope:** B2B variant implemented first (consumer flow is phase 2); backend targets the **whitelabel API** directly, developed against the sandbox during MSA negotiation. - ---- - -## 1. TL;DR (for everyone) - -**What this is.** A variant of our Monerium EUR→USDC onramp aimed at **business clients that come to us through a partner**. Each client gets a personal IBAN. Any euros they wire to it are automatically converted to USDC and delivered to a crypto address they chose upfront. The special part: **the client never has to touch a Vortex app, install a wallet, or digitally sign anything.** Onboarding happens entirely through paperwork (KYB with Monerium plus a contract with us that states their payout address). - -**Why this wasn't possible before.** Connecting an IBAN to an on-chain account normally requires the client to digitally sign one declaration ("I own this address") — that was the single step in our consumer design that forced the client into a UI with a passkey. In this variant, the on-chain account is a small special-purpose program (a "forwarding contract") that **we** deploy for the client, and it is built so that **our own signature can complete that connection** — while the program itself physically cannot do anything except convert the client's euros and deliver them to the client's pre-agreed address. - -**Why we're still not the custodian.** The obvious worry: "if Vortex signs, doesn't Vortex control the money?" No — and this is checkable by anyone reading the contract code on-chain. Our signing power is restricted inside the contract to exactly one sentence: the connection declaration. It cannot approve payouts, withdrawals, or transfers. Nobody — including us — can send the funds anywhere except the client's pre-agreed address (plus the client-controlled failsafes below). Our remaining powers are: run the conversion, pause it, and tune bounded operational parameters. We can delay money; we cannot take or redirect it. - -**The trade-off, honestly.** Because the client holds no key, there is no all-purpose recovery lever if something unexpected breaks (a trading pool dries up, a price feed is retired, the payout address stops working). Every rescue path must be designed in upfront. Our answer (and a condition of Monerium's acceptance): every client **must** name one **fallback address they control** in the paperwork. All emergency flows point there. As a break-glass backstop behind that, Monerium itself can recover tokens from the linked address and pay them out to the client's own verified bank account. Clients are told explicitly (Monerium requires this) that EURe received at the forwarding address cannot be redeemed directly from it — redemption runs via withdrawal to their fallback address, or via Monerium's recovery process as a last resort. - -(Earlier drafts had a "Tier C" for clients refusing any self-controlled address, and a "Tier B" where our partner holds emergency keys. Both are dropped: the partner declines custody-like powers, and Monerium's acceptance is conditioned on fallback capability.) - -**Exchange (CEX) payout addresses are allowed** — the partner requires this. We manage the known risks (exchanges rotate deposit addresses silently) with: a small test transfer before go-live, an automatic pause if an account is dormant too long until the address is re-confirmed, a minimum payout size, and contract terms that put address-validity risk on the client/partner — same way traditional payout providers handle wrong-bank-account risk. One iron rule in all tiers: **we never send raw EURe to an exchange address** (exchanges don't support the token; it would be lost). - -**What must happen before this ships.** (1) **Monerium must approve the pattern in writing** — the ownership declaration would be produced by us, not the client, and doing that without their blessing risks our account; (2) **legal review** — our non-custody argument is strong but "custody" isn't the only licensing question (automatically converting and forwarding for clients may itself be a regulated service under MiCA); (3) partner/client terms for the destination policy; (4) the open engineering findings from the ongoing architecture re-review also apply here. - ---- - -## 2. Context (technical from here on) - -The [main PRD v2](./monerium-eur-usdc-onramp.md) targets consumers: a Safe owned by the user's passkey plus a recovery owner, with a constrained swap module. Its one unavoidable UI moment is the Monerium link signature — the user's passkey signs `"I hereby declare that I am the address owner."`, validated via the Safe's EIP-1271. - -For partner-sourced business clients we want **zero Vortex-side interaction**. KYB happens with Monerium (legacy OAuth app now, whitelabel later — portability is a G1 MSA item; currently confirmed only informally). The destination address arrives via contract paperwork. The only blocker was the link signature. This variant removes it. - -## 3. Mechanism: the attestor-constrained forwarder - -### 3.1 How Monerium's check works - -Monerium validates a link by calling `isValidSignature(bytes32 hash, bytes signature)` (EIP-1271) on the to-be-linked contract — an off-chain `eth_call`, at link time. The message is a **fixed string**, so its EIP-191 hash is a compile-time constant. Redeem orders (`"Send EUR to at "`) are **also** EIP-1271-validated — this fact drives the whole design. - -### 3.2 Why the naive version is unsafe - -"Just make the deployer key the contract's 1271 owner" fails catastrophically: a general-purpose validation key doesn't just validate the link message — it validates **redeem orders too**. Vortex could then sign `"Send EUR to "` and Monerium would burn the EURe and pay out to an arbitrary bank account. Full disposal power: a theft path and unambiguous custody. The hard-coded forwarding logic is irrelevant because redemption bypasses it. - -### 3.3 The constrained version - -`isValidSignature` accepts **exactly one hash** and only from the Vortex attestor key, with the attestation bound to the specific contract: - -```solidity -bytes32 constant LINK_HASH = /* EIP-191 hash of the fixed Monerium link message */; - -function isValidSignature(bytes32 hash, bytes calldata sig) external view returns (bytes4) { - if (hash != LINK_HASH) return 0xffffffff; - // attestor signs keccak256(chainid, address(this), LINK_HASH): no cross-contract - // and no cross-chain replay, - // and no third party can link this contract to a foreign Monerium profile - address signer = ECDSA.recover(keccak256(abi.encodePacked(block.chainid, address(this), LINK_HASH)), sig); - return signer == VORTEX_ATTESTOR ? bytes4(0x1626ba7e) : bytes4(0xffffffff); -} -``` - -Consequences: - -- Vortex can complete the link with no client interaction. -- Vortex **cannot** sign redeem orders or anything else — the attestor key is provably not a "means of access" to the funds. -- Every other hash fails, so no future Monerium message type validates by accident. -- Restricting to the attestor key (rather than accepting the constant hash from anyone) prevents a third party with their own Monerium profile from linking our client's forwarder to *their* profile. - -### 3.4 Contract shape - -No Safe, no passkey, no fallback handler: the account **is** a minimal purpose-built forwarder (per client, or a singleton with per-client config — same Option B analysis as PRD v2 §6.2). It reuses the PRD v2 conversion policy unchanged: pinned EURe→EURC→USDC Uniswap v3 route, contract-constructed calldata, Chainlink EUR/USD `minOut` with staleness ceiling, `CALL` only, exact approvals with reset, atomic delta checks and forwarding, keeper + delayed permissionless trigger, guardian pause. Per-client config: `destination`, `fallbackAddress` (Tier A; zero for Tier C), `feeBps` (immutable post-init), tier flags. - -## 4. Custody and compliance - -| Power | Vortex? | Notes | -|---|---|---| -| Redirect minted funds to any address | **No** | Only `destination` / tiered failsafe targets; immutable logic | -| Sign redeem orders (fiat out) | **No** | 1271 constrained to the link hash | -| Withdraw / sweep to Vortex | **No** | No such function exists | -| Execute conversion, pause, tune bounded params | Yes | Delay-only powers; worst case within slippage bound + disclosed fee | -| Deploy the contract (provisioning) | Yes | S0 trust as in PRD v2; public config manifest still applies (re-review R01 caveat: the manifest is consistency evidence, not an independent trust root) | -| Move the IBAN at Monerium (`PATCH /ibans`) | Yes (credentials) | **Unchanged S1 risk** from PRD v2 — this variant neither worsens nor fixes it; G1 | - -Three non-negotiable caveats: - -1. **Monerium's written approval is required.** The link signature exists so Monerium can verify *the customer* controls the mint address; here the declaration is produced by Vortex about a contract in which the customer holds no key. Undisclosed, this is the "operating on behalf of third parties without prior written approval" pattern their ToS prohibits — heightened on the legacy OAuth app, where our whitelabel-portability assurances are informal. Present the pattern openly (immutable forwarder, on-chain-verifiable constraint, funds only reachable by the client); it is arguably *safer* for Monerium than a user EOA, but it's their call. New G1 item. -2. **Non-custody ≠ out of MiCA scope.** The constrained-attestor argument against custody (Art. 3(1)(17): no control of assets or means of access) is strong, and pause/params are delay-only powers. But *exchange of crypto-assets* and *transfer services on behalf of clients* are separate CASP services — automatic conversion+forwarding for clients may qualify regardless of custody. G2 counsel question; do not present "no custody" as "no license needed." -3. **Provisioning concentration.** With no user verification moment at all, S0 trust in Vortex is *higher* than in the consumer flow. The manifest/transparency machinery matters more here, not less. - -## 5. The recovery problem - -In the consumer design, the client's Safe ownership was a **universal escape hatch**: whatever broke, an owner could move the funds. With zero client keys, only pre-enumerated failures are recoverable; anything unforeseen is permanent. Concrete stuck states: - -| State | Behavior without failsafes | -|---|---| -| Route dies (the EURe/EURC pool is ~$100k TVL — one LP leaving kills it) | Swaps revert forever; EURe accumulates permanently | -| Chainlink retires the EUR/USD feed (routine, weeks of notice) | Staleness check fails forever; permanent | -| EURe depeg beyond slippage bound | Reverts by design; permanent if depeg persists | -| Destination blacklisted by Circle | Atomic revert; EURe accumulates permanently | -| **CEX rotates/closes the deposit address** | **Nothing reverts — funds keep arriving somewhere the client no longer controls. Silent loss; undetectable on-chain** | -| Vortex disappears | Permissionless trigger keeps the happy path alive; combined with any state above → stuck | - -## 6. Failsafe stack and destination policy - -Decision history: Tier B (partner-held recovery role) rejected 2026-07-14 — the partner declines custody-like powers. Tier C (no fallback, stuck-but-safe) dropped 2026-07-17 — Monerium's acceptance is conditioned on maintained fallback capability. **Final policy: exactly one tier; the fallback address is mandatory.** CEX destinations are allowed (partner requirement). - -### Mandatory fallback address (every client) - -One additional paperwork field: a **self-custodied** `fallbackAddress`. It can call `updateDestination`, `sweep(token, to)`, and pause/unpause its own account. Plus an immutable **permissionless dead-man sweep**: anyone may move a stranded EURe balance to `fallbackAddress` after N days unconverted (proposal: 60). Non-custodial: all authority and all emergency targets are the client's. Note a useful quirk: Circle blacklisting blocks USDC transfers, not contract calls or EURe — even a blacklisted fallback can still rotate the destination and sweep EURe out. - -### Redemption disclosure and issuer backstop (Monerium requirements/commitments, 2026-07-16/17) - -- Client terms must state explicitly: *EURe received at the forwarding address cannot be redeemed directly with Monerium from that address; redemption requires withdrawal to your fallback address (from which you can redeem normally), or Monerium's recovery process as a last resort.* (Registry B6.) -- Issuer backstop: Monerium confirmed it can burn tokens from a linked address and pay out **only to the customer's own external bank account**, currently without fees, possibly requiring re-verification. Open item T1: their recovery flow validates a signature against the linked address — the forwarder must whitelist the recovery-message hash (compile-time constant) or the backstop fails-closed for contract addresses. If T1 is unanswered at deploy time, ship without the whitelist; the mandatory fallback carries recovery. - -### Staleness must pause, not lose - -- **Never send raw EURe to a CEX destination.** Iron rule; EURe-recovery targets are the fallback (A) or the contract itself (C). -- **Penny test** at activation: small USDC forward, client/partner confirms exchange credit before the IBAN goes live (also catches exchanges that mis-credit contract-originated token transfers). -- **Dormancy gate:** no successful forward for X days (proposal: 60) ⇒ forwarding pauses until the destination is re-confirmed (partner API ping suffices — still zero client UI). Rotation risk concentrates in dormancy; this converts silent loss into a pause. -- **Minimum forward ≥ the exchange's minimum deposit**; below it, accumulate. -- **Liability allocation in the terms:** client/partner warrants destination validity and bears rotation losses; Vortex commits to the penny test and dormancy gate as diligence. This is how traditional payout processors carry the same risk (a wrong/closed bank account is the instructing party's loss) — contractual allocation is the only mechanism that can actually hold it, since a CEX address's continued validity is not verifiable on-chain. - -Residual loss scenario in Tier A: client loses the fallback key **and** the destination breaks — ordinary self-custody residual. In Tier C: any unforeseen terminal failure — accepted in writing. - -## 7. Differences vs the consumer flow (PRD v2) - -| | Consumer (PRD v2) | B2B variant | -|---|---|---| -| Client interaction | Passkey ceremony + one link signature | None on Vortex side (KYB with Monerium + paperwork) | -| Account | Safe v1.4.1, passkey + recovery owner | Minimal forwarder, no owners | -| Link signature | User passkey via Safe EIP-1271 | Vortex attestor via hash-constrained EIP-1271 | -| Universal recovery | Owners can do anything | None — tiered failsafes only (§6) | -| Destination changes | Owner-signed Safe tx | `fallbackAddress` only | -| Custody posture | User owns account | No one holds spend keys; Vortex delay-only powers; stronger in one way (no opaque owner-signature surface, cf. re-review R08), weaker in another (higher S0 provisioning trust) | -| Conversion policy | Identical (route, oracle, invariants, keeper) | Identical | - -## 8. Open items - -Tracked centrally in the [deferred-decisions registry](./monerium-onramp-deferred-decisions.md); summary: - -1. **G1 written package from Monerium** — verbal acceptance exists (attestor pattern, disclosure requirement, recovery backstop); must be consolidated in writing, together with the pre-existing items: IBAN pinning (`PATCH /ibans`), profile portability, recall liability, corporate-KYB mechanism (T3), and the recovery-message format (T1). -2. **G2 legal:** custody opinion for the constrained-attestor construction; MiCA exchange/transfer-service scoping; disclosure enforceability. -3. **Partner/client terms:** destination warranty, liability allocation, dormancy re-confirmation mechanics, redemption disclosure (B6). -4. **Inherited re-review findings** — dispositions and concrete resolutions live in the [implementation plan](./monerium-b2b-implementation-plan.md): R01 (manifest trust root), R03 (enforceable start time — resolved via on-chain `strandedSince` marker), R04 (sweep vs attribution races), R05 (per-account pause — load-bearing for the dormancy gate), R06 (webhook durability), R09 (unsolicited-token rules), R10 (parameter/role invariants). -5. **Adversarial review of this variant** — runs in parallel with implementation (decision 2026-07-17); must cover the constrained `isValidSignature` (encoding, replay, T1 whitelist interaction) and the fallback mechanics. diff --git a/docs/prd/monerium-onramp-deferred-decisions.md b/docs/prd/monerium-onramp-deferred-decisions.md deleted file mode 100644 index 0acf74c2d..000000000 --- a/docs/prd/monerium-onramp-deferred-decisions.md +++ /dev/null @@ -1,99 +0,0 @@ -# Monerium Onramp — Deferred Decisions Registry - -**Purpose:** single place for every parameter and decision we deliberately postponed so implementation can start. Nothing in here blocks coding; each row states the placeholder used in code/spec until decided. Review this file at every phase gate. - -**Last updated:** 2026-08-26 - -## Decision table — recommended values (2026-08-26) - -Consolidated pre-deploy view: every open parameter with a concrete recommendation to -accept or overrule. Rationale and history stay in the detail sections below. - -| # | Item | Placeholder in code | Recommended | Rationale | Status | -|---|---|---|---|---|---| -| B1 | GA `feeBps` | 0 (pilot) | **0 pilot / 25 bps GA starting point** | Covers keeper gas + ops without denting OTC economics; set per client at deploy, later adjustable via the P11 timelocked setter. Commercial call. | Open (business) | -| B2 | Penny-test amount | 5 USDC | **5 USDC** | Cheap, proves contract-originated credit at the destination. | Accept placeholder | -| B3 | Processing SLA wording | "within 1 business hour" | **"converted and forwarded within 4 hours of mint on business days; weekend/holiday mints execute against the last oracle round within the 52 h window and may see wider spreads"** | Matches keeper cadence + P8 weekend policy; 1 h leaves no incident headroom. | Draft for terms (G2) | -| B4 | Pilot client list + volume limits | €1k/client/day | **3–5 clients; €100k/client/day, €250k aggregate/day (paper controls)** | €1k/day is unusable for OTC tickets. Note: limits are contractual only — nothing in the backend enforces daily volume; `perSwapCap` bounds per-swap size, monitoring covers the rest. | Open (business) | -| B5 | Partner liability terms | — | **Tier A defaults from the variant doc**: partner warrants destination correctness, rotation loss borne by the client, dormancy re-activation on written partner confirmation (admin status endpoint) | Already the design the contract assumes. | Draft for partner agreement | -| B6 | Redemption-limitation disclosure | Draft in variant doc §6 | **Use the §6 draft** | Commitment made to Monerium; wording needs G2 review only. | Draft for terms (G2) | -| P1 | `SLIPPAGE_BPS` | 100 | **100** | T6 baseline shows ~14 bps impact at 25k incl. fees; 100 bps absorbs weekend EUR/USD drift inside the P8 window. | Accept placeholder | -| P2 | `MAX_FEE_BPS` | 100 | **100** | 1% ceiling leaves commercial room above the B1 value without weakening the client guarantee. | Accept placeholder | -| P3 | Dead-man sweep delay | 60 days | **60 days** | Long enough that no operational hiccup triggers it, short enough to be a credible client escape hatch. | Accept placeholder | -| P4 | Permissionless trigger delay | 24 h | **24 h** | Keeper cycles every minute; >24 h of silence means the fallback SHOULD be live. | Accept placeholder | -| P5 | Dormancy window | 60 days | **60 days** | Pairs with P3; backend-enforced, adjustable later without redeploy. | Accept placeholder | -| P6 | `minSwapAmount` (floor / operational) | €25 / €25 | **floor €25 (immutable) / operational €250** | Mainnet swap ≈ 400–600k gas; at €25 the keeper's gas can exceed 1% of volume. €250 keeps gas noise negligible for OTC sizes. Must stay ≥ each client's CEX minimum where applicable. | Set at deploy | -| P7 | `perSwapCap` (operational / ceiling) | €10k / €50k | **operational €25k / ceiling €50k** | T6: 25k executes within ~14 bps on the pinned path. Re-run T6 at the deploy block before raising the operational value. | Set at deploy | -| P8 | `MAX_ORACLE_AGE` | 26 h in test configs | **52 h** | Decided 2026-07-17 (observed weekend gaps up to 48 h). Still needs applying to the fork/invariant test configs and the deploy config. | Decided — apply | -| P9 | Notification confirmation depth | 32 blocks | **32 blocks** | Implemented (DEPOSIT_CONVERTED depth gate). | Done — close | -| P10 | Router pin + fee tiers | SwapRouter02, 5 bps hops | **SwapRouter02; re-verify both pools/fee tiers at the deploy block** | G0 output; verification action, not a value. | Action at deploy | -| T1 | `RECOVERY_HASH` / issuer recovery | `bytes32(0)` | **RESOLVED (verbal, 2026-08-26): the recovery flow validates the SAME ownership message as linking** — its hash is `LINK_HASH_191`, which the forwarder already whitelists, so issuer recovery works with the constrained validator as built. Keep `RECOVERY_HASH = 0` (slot reserved for a future distinct message). Pilot permissive-validator option (c) withdrawn. | The message is payout-neutral: the signature only proves address ownership; the recovery payout target (customer's own verified bank account) is Monerium's controlled process, satisfying the review-r1 requirement. **Get this in writing** — folded into G1 item 3. | Resolved (verbal) — written confirmation via G1 | -| P11 | Guardian-settable `feeBps` (contract change) | — | **DECIDED + IMPLEMENTED (2026-08-26)**: guardian-only `setFeeBps`, capped by immutable `MAX_FEE_BPS`; increases announce on-chain and apply permissionlessly after `FEE_INCREASE_TIMELOCK = 24 hours` (Marcel's call); decreases (and cancel-via-restate) immediate. Monitoring reconciles guardian fee changes like owner-authorized config (warn + configVersion bump). | G2 must still scope the bounded pre-announced fee power. | Done — G2 scoping outstanding | -| O2 | Treasury (`FEE_RECIPIENT`) address + key custody at deploy | test addresses | **Decide before the implementation deploy — it is immutable for every clone**: FEE_RECIPIENT should be a Vortex-controlled Safe multisig, never an EOA env key. Guardian: EOA acceptable for the pilot, move to a hardware-backed key or multisig for GA (it now also holds the P11 fee power). Attestor/keeper stay env EOAs (bounded blast radius by design). | The treasury address is baked into the implementation; a wrong or lost-key choice is unfixable without a new implementation + clone migration. | Open — decide at deploy | -| O1 | Client migration to a new clone (contract upgrades, or fee changes while P11 is unbuilt) | none (no tooling) | **Backend-enforced migration procedure**: admin endpoint that announces the migration (association monitor treats it as expected), waits its own delay, then links the new clone and moves the IBAN (`PATCH /ibans`) — with the invariant that Vortex tooling only ever targets factory clones (`isForwarder` + config read-back). No on-chain timelock is possible: the IBAN move is a Monerium API call the chain never sees; the preventive layer on Monerium's side is G1 item 4 (authorization requirements on `PATCH /ibans`). | Open — build when first needed | - -## Business decisions (Marcel / partner) - -| # | Decision | Placeholder until decided | Needed by | -|---|---|---|---| -| B1 | GA `feeBps` value (structure is built; per-client, immutable at init) | `0` (pilot) | Before first paying client | -| B2 | Penny-test amount for destination verification | 5 USDC | Pilot onboarding runbook | -| B3 | Processing SLA wording for client terms (incl. weekend behavior) | "within 1 business hour; FX-market-hours caveat" | Terms drafting (with G2) | -| B4 | Pilot client list + per-client volume limits | €1k/client/day | G4 pilot start | -| B5 | Partner liability terms: destination warranty, rotation-loss allocation, dormancy re-confirmation mechanics | — | Partner agreement signing | -| B6 | Redemption-limitation disclosure text (Monerium requires it; commitment made in TG thread) | Draft in variant doc §6 | Terms drafting (with G2) | - -## Contract parameters (decide before mainnet deploy; placeholders fine for sandbox/testnet) - -| # | Parameter | Placeholder | Notes | -|---|---|---|---| -| P1 | `SLIPPAGE_BPS` | 100 (1%) | Immutable; absorbs EURe + USDC basis vs Chainlink EUR/USD | -| P2 | `MAX_FEE_BPS` | 100 (1%) | Immutable ceiling for per-client feeBps | -| P3 | Dead-man sweep delay (stranded EURe → fallbackAddress) | 60 days | Immutable; uses on-chain `strandedSince` marker (R03 fix) | -| P4 | Permissionless swap-trigger delay | 24 h | Same marker as P3 | -| P5 | Dormancy pause window (no successful forward → pause pending re-confirmation) | 60 days | Operational (backend-enforced via per-account pause), not immutable | -| P6 | `minSwapAmount` floor / operational value | €25; must also be ≥ CEX min deposit per client | Floor immutable, operational value adjustable within bounds | -| P7 | `perSwapCap` operational value + immutable ceiling | €10k / €50k | Availability parameter, not safety (minOut is safety) | -| P8 | `MAX_ORACLE_AGE` | 26 h → **recommend 52 h** | T2 answered (2026-07-17): feed updates through weekends but sparsely — observed gaps 32.6 h / 36.1 h / **48.0 h**. 26 h would revert most weekends; 52 h covers observed max + margin, weekend EUR/USD moves sit well inside the 100 bps slippage bound | -| P9 | Notification confirmation depth | 32 blocks | Backend only | -| P10 | Uniswap router pin (SwapRouter w/ deadline vs SwapRouter02 w/o) + EURC hop fee-tier re-verification | SwapRouter02 | G0 spike output | - -## Technical clarifications pending (external) - -| # | Item | Owner | Status | -|---|---|---|---| -| T1 | **Monerium recovery-burn mechanism for contract addresses** — OPEN (2026-08-26). The issuer recovery flow validates a signature against the linked address via EIP-1271; the forwarder currently accepts only the link hash, so `RECOVERY_HASH = bytes32(0)` disables issuer recovery entirely. Options on the table: **(a) ship the pilot as-is** (no issuer recovery; the fallback-address sweep remains the client's recovery path; issuer backstop best-effort only); **(b) obtain the exact recovery message/hash from Monerium and whitelist it** — the preferred end state, but the question has still not been sent; **requirement (review r1)**: enable only if the message is parameterless or payout-neutral, since a parameterized message validated by our attestor would grant Vortex disposal discretion; **(c) pilot-only permissive validator (Marcel, 2026-08-26)**: accept that the pilot may need a less MiCA-clean variant where `isValidSignature` validates arbitrary attestor-signed messages so every Monerium-side flow (recovery included) works. **Recorded tradeoffs for (c)**: Monerium redeem orders also validate via EIP-1271, so a permissive validator reintroduces the redeem-to-arbitrary-IBAN path the constrained design exists to block — an attestor-key compromise becomes a fiat theft path, and the non-custody analysis changes because Vortex gains disposal capability (G2 must re-scope custody/MiCA before this ships; attestor key custody would need hardening, e.g. HSM; plan a migration back to the constrained validator for GA). Decision: Marcel + G2, before mainnet deploy | Monerium tech team (question), Marcel + G2 (pilot variant) | **RESOLVED verbally 2026-08-26**: recovery validates the same ownership message as linking — already whitelisted as `LINK_HASH_191`; no contract change needed; option (c) withdrawn; written confirmation via G1 item 3 | -| T2 | Chainlink EUR/USD weekend behavior → weekend policy | G0 spike | **Answered 2026-07-17**: rounds observed on Sat/Sun (deviation-triggered), gaps up to 48 h; see P8. Weekend policy: execute normally with `MAX_ORACLE_AGE ≥ 52 h` | -| T6 | Liquidity baseline (review F12 reproducibility) | G0 spike | **Recorded 2026-07-17, mainnet block 25553101**, QuoterV2 on pinned path EURe→(500)→EURC→(500)→USDC: 1k → 1.14298, 5k → 1.14288, 10k → 1.14278, 25k → 1.14252 USDC/EURe (Chainlink same day 1.14410 — 25k within ~14 bps incl. 2×5 bps fees). Deeper than the 07-10 snapshot; €10k cap comfortable. Re-run at deploy + wire into monitoring (task 6) | -| T3 | Corporate KYB mechanism under whitelabel: Monerium-run verification vs KYC-reliance (reliance requires licenses we may not hold) | Monerium MSA negotiation | Open — fold into G1 | -| T4 | Whitelabel sandbox: verify EIP-1271 link works against a deployed forwarder E2E (G0 headline item) | Us | **VALIDATED 2026-07-17**: Monerium sandbox accepted the attestor-signed link (HTTP 201, `state: linked`) on first attempt and issued an IBAN (state approved) — zero client interaction. Hash variant presented: **EIP-191** → narrow the contract to `LINK_HASH_191` only before audit (drop `LINK_HASH_RAW`; folded into review-r1 follow-ups). Sandbox artifacts (Sepolia): factory `0x82f4953CF3ACaa464b67f932AAF008af010a9376`, forwarder `0x67592847844958b455ae907D3Ef1EADBf6827fdc`, MockOracle `0x337dd479435aE2593c9B023B48617278c6AB34E3`, profile `d2de6768-b0e7-11f0-a4ad-fabb3106d2e3`, IBAN `EE08 7224 5745 6244 9516`. Client API notes: `POST /addresses` body `{address, chain, message, profile, signature}` confirmed; `GET /profiles` list 404s (use per-profile paths); `POST /ibans` is async 202 → poll. **Re-validated with hardened binding** (EIP-191-only + chainid, review-r1 fixes) same day: factory2 `0xcBE354e847bF597513148918E7EbDff72aC75842`, forwarder2 `0xD7444AB7270A142227Fe659D63873ABdc8AF9b72`, link 201. Remaining G0 sliver: simulate SEPA deposit → observe mint + webhook (dashboard button at sandbox.monerium.dev → Receive → "Simulate bank transfer" — needs Marcel's dashboard login, one click) | -| T5 | Whether Monerium rejects linking an address already linked to another profile (defense-in-depth question) | Monerium tech | Nice-to-have | - -## G1 — written approval package to collect from Monerium - -All currently Telegram-only. Consolidate into MSA or side letter: - -1. Attestor-pattern acceptance (compliance said "fine if fallback capabilities maintained" — fallback is now mandatory, so condition is met by design). -2. Redemption-limitation disclosure obligation (their explicit request; our commitment). -3. Issuer recovery backstop: burn from linked address + payout only to customer's own external bank account, no fees, re-verification possible (their statements 2026-07-16/17) + T1 mechanics. -4. IBAN pinning: authorization required for `PATCH /ibans` / `POST /addresses` on whitelabel profiles (pre-existing G1 item — unresolved). -5. OAuth→whitelabel profile portability + whether whitelabel `client_id` auto-accesses existing profiles (pre-existing; Telegram-only). -6. SEPA recall / fraud loss allocation after conversion+forwarding (pre-existing; unresolved). -7. Per-IBAN suspension capability for incident response (pre-existing; unresolved). -8. T3 corporate KYB mechanism. -9. Advance notice of any change to the EIP-1271 ownership/link message (and, once T1 resolves, the recovery message): the forwarder whitelists their exact hashes, so an unannounced change fail-closes new onboarding (2026-08-26). - -## G2 — legal review scope (unchanged, not started) - -Custody opinion on attestor construction; MiCA exchange/transfer-service scoping (non-custody ≠ out of scope); disclosure enforceability; DPA/controller-processor with Monerium; sanctions screening procedure for destinations. - -## Decisions already made (do not reopen without cause) - -- B2B variant first; consumer passkey flow is phase 2 (2026-07-17). -- Tier C dropped: self-custodied `fallbackAddress` mandatory for every client (2026-07-17; aligns with Monerium condition). -- Target whitelabel API directly, develop against sandbox; no legacy-OAuth interim build (2026-07-17). -- Adversarial review runs in parallel with implementation (2026-07-17). -- Attestor-constrained `isValidSignature` (link hash only, attestor key only, bound to contract address); never a general owner key. **Under reconsideration for the pilot only** via T1 option (c) — any relaxation goes through the T1 decision with G2, not silently. -- Never send raw EURe to a CEX destination; EURe recovery targets are `fallbackAddress` only. -- No on-contract redeem validator (F05 stands); redemption path = fallback sweep → client redeems from own address; issuer recovery as break-glass backstop (pending T1). -- Fee structure: per-client `feeBps` at init, guardian-adjustable via the 24 h-timelocked setter within immutable `MAX_FEE_BPS` (P11, 2026-08-26 — supersedes the original post-init immutability); `FEE_RECIPIENT` treasury immutable per implementation (pilot fee 0). diff --git a/docs/prd/monerium-eur-usdc-onramp.md b/docs/proposal-monerium-consumer-onramp.md similarity index 98% rename from docs/prd/monerium-eur-usdc-onramp.md rename to docs/proposal-monerium-consumer-onramp.md index 52e67d2b0..114058b6f 100644 --- a/docs/prd/monerium-eur-usdc-onramp.md +++ b/docs/proposal-monerium-consumer-onramp.md @@ -1,6 +1,12 @@ +> **Status (2026-08-26):** phase-2 proposal. The B2B variant of this design shipped +> (see [adr-0005-monerium-b2b-onramp.md](adr-0005-monerium-b2b-onramp.md)); this +> document describes the CONSUMER flow (Safe + passkey) that remains future work. Its +> architecture reviews and the B2B companion documents were absorbed into the +> maintained docs and live in git history. + # PRD: Quoteless EUR → USDC (Ethereum) Onramp via Monerium Whitelabel -**Version:** 2.0 (response to [architecture review](./monerium-eur-usdc-onramp-architecture-review.md); v1 in git history) +**Version:** 2.0 (response to the architecture review; review and v1 in git history) **Status:** Revised draft — awaiting re-review and external gates (§13) **Date:** 2026-07-13 **Owner:** Vortex team diff --git a/docs/runbooks/monerium-b2b-dormancy.md b/docs/runbooks/monerium-b2b-dormancy.md deleted file mode 100644 index 2c2cb1127..000000000 --- a/docs/runbooks/monerium-b2b-dormancy.md +++ /dev/null @@ -1,64 +0,0 @@ -# Runbook: Monerium B2B Onramp — Dormancy Gate - -Why this exists: CEX destination-rotation risk concentrates in dormant accounts — an exchange -silently rotates a deposit address, months later a deposit arrives, and USDC is forwarded to an -address the client no longer controls. Undetectable on-chain. The dormancy gate converts that -silent loss into a pause (b2b-variant doc §6). - -## Gate mechanics (automatic) - -Implemented in `apps/api/src/api/services/monerium-b2b/dormancy.ts`, run every keeper cycle: - -- An `active` account with **no confirmed conversion for 60 days** (placeholder — registry P5; - anchor = last confirmed `MoneriumConversionExecution`, or account creation if none) is paused: - the backend calls `setGuardianPaused(true)` on the clone with the guardian key and records - `dormant_since` on the `MoneriumAccount` row. -- If `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY` is unset, the gate runs **log-only**: `dormant_since` - is recorded and a warning states that no on-chain pause exists. -- While `dormant_since` is set, the conversion executor skips the account entirely. - -The pause is protective-only (contract invariant): it blocks `swapAndForward` and nothing else. -The client's fallback paths (`sweep`, `setDestination`, `setClientPaused`, …) and the -permissionless dead-man sweep (`sweepStrandedEure` after SWEEP_DELAY, registry P3) keep working. -EURe arriving during dormancy accumulates safely on the forwarder; if it strands past -SWEEP_DELAY it flows to the client's `fallbackAddress` automatically. - -## Re-confirmation (manual, via partner) - -Re-confirmation mechanics are a partner-agreement item (**registry B5**) — until settled, the -operational procedure is: - -1. Ask the partner to re-confirm with the client that the `destination` address is still valid - and under the client's control (a partner API ping/written confirmation suffices — zero - client UI by design). -2. If the destination changed: the **client** updates it via their fallback key - (`setDestination` from `fallbackAddress`) — Vortex cannot and must not do this. For CEX - destinations, re-run the penny test (onboarding runbook §7; amount registry B2). -3. Archive the confirmation evidence with the account record. - -## Un-pause - -Only after re-confirmation: - -```bash -cast send "setGuardianPaused(bool)" false --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -Then clear the dormancy flag so the executor resumes: - -```sql -UPDATE monerium_accounts SET dormant_since = NULL WHERE forwarder_address = ''; -``` - -The next keeper cycle converts any accumulated balance. Verify: a `SwapExecuted` for the -forwarder, the execution row `confirmed`, and no `stranded EURe` alert on the next monitoring -pass. - -## Edge notes - -- Un-pausing without clearing `dormant_since` leaves the executor skipping the account (the DB - flag, not the chain flag, gates the executor) — always do both. -- A dormant account that re-confirms but stays unused simply re-enters the gate after another - window; that is intended. -- Do not un-pause to "flush" a balance without re-confirmation — the balance is exactly the - rotation-risk scenario the gate exists for. diff --git a/docs/runbooks/monerium-b2b-incident.md b/docs/runbooks/monerium-b2b-incident.md deleted file mode 100644 index cb3db5861..000000000 --- a/docs/runbooks/monerium-b2b-incident.md +++ /dev/null @@ -1,112 +0,0 @@ -# Runbook: Monerium B2B Onramp — Incident Response - -Scope: the B2B forwarder deployment (`contracts/monerium-forwarder/`) and its keeper/monitoring -backend (`apps/api/src/api/services/monerium-b2b/`). Spec: `docs/prd/monerium-b2b-implementation-plan.md`, -`docs/security-spec/05-integrations/monerium-b2b.md`. - -Ground rules that shape every procedure here: - -- **Vortex powers are delay-only.** Guardian/keeper can pause and execute the policy — never move - or redirect funds. There is no Vortex-side rescue path by design. -- **Pauses never trap client funds.** `fallbackAddress` functions (`sweep`, `setDestination`, - `setFallbackAddress`, `setClientPaused`) and the permissionless dead-man sweep - (`sweepStrandedEure`, after SWEEP_DELAY) work while paused. Do not promise otherwise in comms. -- **Never send raw EURe to a CEX destination.** EURe recovery targets are `fallbackAddress` only. - -## 1. Pause procedures - -The guardian key is `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY` (distinct from keeper and attestor keys). -`$RPC` = the chain RPC, `$FACTORY` = the factory address from the published manifest. - -**Per-clone pause** (one client account — compliance hold, dormancy, targeted issue): - -```bash -cast send "setGuardianPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -**Global pause** (all clones at once — protocol-level incident): - -```bash -cast send $FACTORY "setGlobalPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -Both block `swapAndForward` only. Unpause = same call with `false`. Cap reduction (availability -lever, instant, bounded by immutables): - -```bash -cast send $FACTORY "setPerSwapCap(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -## 2. Monerium IBAN suspension ask - -Per-IBAN suspension capability is **G1 item 7 — not yet contractual** (registry). Until the MSA -settles it, this is a best-effort ask: - -1. Contact Monerium support/emergency channel; identify the whitelabel partner account and the - affected IBAN(s) + linked forwarder address(es). -2. Ask for: suspension of inbound SEPA on the IBAN(s) (deposits bounce back to senders), NOT - profile closure. -3. Record ticket/response — feed the outcome back into the G1 negotiation record. - -While unsuspended, inbound SEPA keeps minting EURe to the forwarder. That is safe (funds sit -behind the contract's invariants) but grows exposure — factor it into comms urgency. - -## 3. User notification - -Clients have no Vortex UI; all comms run through the partner plus direct email. - -1. Notify the partner ops contact first (they own the client relationship). -2. Email affected clients: **stop sending EUR to your IBAN until further notice**; deposits - already sent will either convert normally after resolution or be recoverable via the - fallback address — no funds are lost by pausing. -3. Status page entry if the pause is global. - -## 4. Critical-vulnerability sequence (the 02:00-UTC runbook) - -Adapted from PRD v2 §12 for the B2B topology (immutable clones, no per-user migration signature). -Assume a suspected vulnerability in `VortexForwarder`/factory: - -1. **Pause all** — `setGlobalPaused(true)` (§1). Instant, protective-only, reversible. -2. **Ask Monerium to suspend affected IBANs** (§2) so no new EURe mints while assessing. -3. **Notify** partner + clients to stop sending EUR (§3). -4. **Assess.** Funds at risk are EURe balances on forwarders (check with the stranded-balance - monitor output or `cast call "balanceOf(address)" `). Run the manifest - verifier against the live deployment as part of the assessment: - `bun script/verify-manifest.ts $RPC` (from `contracts/monerium-forwarder/`). -5. **If funds must move: only clients can move them.** Instruct clients (via partner) to sweep - EURe to safety with their fallback key: `sweep(EURE, )` from `fallbackAddress`. - Provide exact calldata and a verification walkthrough. The issuer recovery backstop - (Monerium burn + payout to the client's own bank account) is the last resort — best-effort - until registry T1 is resolved. -6. **Ship the fix as a migration.** Immutable contracts: deploy fixed implementation + factory - (new audit), deploy new clones per client, link the new clone to the client's profile - (attestor flow, onboarding runbook §3), issue/move the IBAN, penny-test, regenerate + publish - the manifest. Old clones stay paused; residual balances leave via fallback sweep or dead-man - sweep (never lost — SWEEP_DELAY, registry P3). -7. **Unpause / decommission.** Unpause only contracts that are confirmed unaffected. - -## 5. Alert triage (monitoring log lines → action) - -The monitors live in `apps/api/src/api/services/monerium-b2b/monitoring.ts` (worker-run, every -30 min). All lines are prefixed `monerium-b2b:`. - -| Log line contains | Meaning | Action | -|---|---|---| -| `PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Executable depth below even minimum-size swaps (PRD §7.4 pause threshold); swaps would revert on minOut | Engage global pause (§1); investigate pool state (LP exit, depeg); consider lowering `perSwapCap`; re-run the T6 quote methodology before unpausing | -| `executable depth below perSwapCap` | Cap-sized swaps would revert; availability, not fund risk | Lower `perSwapCap` (§1) or accept keeper retries; watch for escalation to pause threshold | -| `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB record (IBAN moved, address linked) — the S1 detective control | Treat as potential whitelabel-credential compromise: confirm with Monerium whether the change was authorized; if not: global pause, rotate `MONERIUM_WHITELABEL_CLIENT_SECRET`, ask for IBAN suspension (§2), full incident | -| `stranded EURe on forwarder` (warn ≥12h) | Keeper is not converting | Check worker liveness, RPC health, keeper gas balance, oracle staleness (swaps revert on `StalePrice`) | -| `stranded EURe ... past TRIGGER_DELAY` | ≥ TRIGGER_DELAY (registry P4) — permissionless trigger now live; SLA long since broken | Escalate keeper outage; anyone can call `swapAndForward()` now, which is acceptable (same policy applies); communicate delay to client | -| `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on factory` | Should-be-impossible state (immutable feeBps changed, wrong code) | Full incident: global pause, run the manifest verifier, compare against the published manifest history | -| `reconciled owner-authorized config change` | Client's fallbackAddress rotated destination/fallback — expected transition (R07), DB updated | No incident. Confirm with the partner that the client intended it; an unexpected change suggests a compromised fallback key → client should `setClientPaused(true)` and rotate via `setFallbackAddress` | -| `MONERIUM_B2B_PRIVATE_RPC_URL is not set` | Keeper writes going through public mempool | Operational finding on mainnet — set the private orderflow RPC | - -## 6. Key compromise quick reference - -| Key | Blast radius | Response | -|---|---|---| -| Attestor | Can link addresses to profiles, never move funds | Rotate key; new forwarders need a new implementation deployment (ATTESTOR is immutable); existing links unaffected | -| Keeper | Can call `poke`/`swapAndForward` (policy-constrained) — no fund redirection possible | Rotate `MONERIUM_B2B_KEEPER_PRIVATE_KEY`; `setKeeper(old,false)` + `setKeeper(new,true)` on the factory | -| Guardian | Can pause/unpause and tune bounded params — delay-only | Two-step `transferGuardian`/`acceptGuardian` to a new key; audit pause state afterwards | -| Whitelabel API credentials | Control-plane: could move IBANs/links at Monerium (S1) | Rotate at Monerium; association monitor is the detective control; check its history for unauthorized changes | -| Client fallback key (client-side) | Full control of that client's funds/config | Client's own responsibility (terms); assist via partner: pause account, client rotates `setFallbackAddress` if still in control | diff --git a/docs/runbooks/monerium-b2b-onboarding.md b/docs/runbooks/monerium-b2b-onboarding.md deleted file mode 100644 index 6d2e3af0c..000000000 --- a/docs/runbooks/monerium-b2b-onboarding.md +++ /dev/null @@ -1,123 +0,0 @@ -# Runbook: Monerium B2B Onramp — Client Onboarding - -Deploy → manifest → verify → map → (automated: link + IBAN) → penny test → activate. -One pass per client. Spec: `docs/prd/monerium-b2b-implementation-plan.md`; API call -shapes below are the sandbox-validated ones from registry item T4 (2026-07-17). - -Prerequisites: guardian key funded on the target chain; `MONERIUM_B2B_*` env set -(attestor key, RPC, webhook secret) plus the whitelabel API credentials -`MONERIUM_WHITELABEL_CLIENT_ID/SECRET`; partner paperwork complete; the client company -onboarded and KYB-approved on Monerium's side (partner KYC reliance) with its -Monerium profile UUID at hand; the partner configured as a managed-profile manager -(`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type -`business`). - -## 1. Paperwork inputs (from the partner agreement) - -- `destination` — client's payout address. CEX deposit addresses allowed; validate: EIP-55 - checksum, not zero/dead/precompile/token/router (the contract re-rejects token/router/self at - init), warn-and-attest for contract addresses and CEX addresses (rotation risk — terms doc). -- `fallbackAddress` — client's **self-custodied** recovery address. Mandatory, no exceptions - (Monerium acceptance condition). Must be distinct from custodial/CEX addresses. -- `feeBps` — per-client, immutable post-init. Pilot: `0` (registry B1). -- Signed terms including the redemption-limitation disclosure (registry B6 — Monerium requires - it; see `docs/prd/monerium-b2b-terms-inputs.md`). - -## 2. Deploy the forwarder - -```bash -# predict, then deploy (guardian-only); salt = any unused bytes32, convention: client index -cast call $FACTORY "predictAddress(bytes32)(address)" $SALT --rpc-url $RPC -cast send $FACTORY "deployForwarder(address,address,uint16,bytes32)" \ - $DESTINATION $FALLBACK $FEE_BPS $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). Record the -forwarder address + deploy tx hash. - -## 3. Manifest: generate, verify, publish - -From `contracts/monerium-forwarder/`: - -```bash -bun script/generate-manifest.ts $FACTORY $RPC manifests/-$FACTORY.json -bun script/verify-manifest.ts manifests/-$FACTORY.json $RPC # must PASS -``` - -(Some free RPCs refuse historical `eth_getLogs`; add `--logs-rpc ` for -the event enumeration — all other reads stay on `$RPC`.) - -Publish the manifest (commit + public location). The manifest is **consistency evidence, not a -trust root** (re-review R01): it lets anyone detect silent changes; it does not prove the -deployment was honest — that requires the verified source on the block explorer, so verify the -factory + implementation source there as part of this step. - -## 4. Map the client to a managed profile - -One idempotent admin call creates the managed child (business entity under the partner -manager), imports the Monerium KYB approval into `provider_customers`/`kyc_cases`, and -records the deployed forwarder as the `MoneriumAccount` row (status `onboarding`): - -``` -POST /v1/admin/monerium-b2b/accounts (Authorization: Bearer $ADMIN_SECRET) -{ - "managerProfileId": "", - "externalSubjectId": "", - "contactEmail": "", - "moneriumProfileId": "", - "forwarderAddress": "", - "destination": "", - "fallbackAddress": "", - "feeBps": 0 -} -``` - -Replaying the identical call is safe (200); divergent input is a 409, never an overwrite. -KYB submission via the API remains deliberately unimplemented (`submitKybData` → 501, -registry T3) — approval always happens on Monerium's side before this step. - -## 5. Link + IBAN (automated) - -The keeper's onboarding step (`monerium-b2b/onboarding.ts`, every cycle) picks up every -mapped account in `onboarding` status and, exactly-once via the profile-scoped -`financial_operations` ledger: - -1. links the forwarder with the attestor signature (`POST /addresses`, constrained - EIP-1271, EIP-191 variant; sandbox-validated per registry T4 — HTTP 201, - `state: linked`, zero client interaction), then -2. requests IBAN issuance (`POST /ibans`, async — expect 202). - -The IBAN lands on the `MoneriumAccount` row via the `iban.updated` webhook (or the next -cycle's `GET /ibans` read); from then on the association monitor treats the DB record as -the reference state. Nothing to do manually — verify the row has its IBAN before the -penny test, and check the logs if it stays empty for more than a few cycles. - -## 7. Penny test - -Purpose: prove the destination actually credits contract-originated USDC transfers (CEXes can -rotate or mis-credit) before real volume flows. - -1. Send a small SEPA deposit to the new IBAN (sandbox: dashboard at sandbox.monerium.dev → - Receive → "Simulate bank transfer"). Target forward amount: **5 USDC** (placeholder — - registry B2). -2. Keeper converts and forwards automatically once the balance ≥ `minSwapAmount`; for a - sub-minimum penny test, temporarily lower `minSwapAmount` (guardian, bounded by - `MIN_SWAP_FLOOR`) or fund up to the minimum. -3. **Partner/client confirms credit at the destination** (explicit written confirmation — - this is a diligence commitment in the terms, registry B5). - -## 8. Activate - -1. Activate the account (refused with 409 while the IBAN has not been issued): - - ``` - PATCH /v1/admin/monerium-b2b/accounts//status (Authorization: Bearer $ADMIN_SECRET) - { "status": "active" } - ``` - -2. Confirm the monitoring pass picks the account up cleanly (no association/config alerts on - the next cycle). -3. Hand the client's IBAN over via the partner. Done. - -Failure at any step: nothing is at risk — the forwarder holds no funds until the client wires -EUR, and every recovery path (fallback sweep, dead-man sweep) is live from deployment. diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index d48447705..55a238b7a 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/prd/monerium-b2b-implementation-plan.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 under the partner's KYC reliance, 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 @@ -22,7 +22,7 @@ The B2B zero-touch onramp (docs/prd/monerium-b2b-implementation-plan.md) gives e 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 is the Monerium order id** — `monerium_order_id` is unique; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are stored as 18-decimal base-unit strings converted from the provider decimal, never floats. 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 (deferred-decisions 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 mechanism is contractually unsettled (adr-0005 registry T3), so no identity-data submission code path exists in the B2B module or the shared client. Pilot corporates do not need one: they are onboarded and approved by Monerium under the partner's KYC reliance, and the admin mapping imports that outcome as an approved `kyc_cases` row. 12. **Account mapping is admin-only, idempotent, and conflict-on-divergence** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) provisions the managed child (business entity, via the managed-profile provisioning service and its manager checks), mirrors the approved KYB, and binds the operator-deployed forwarder. When a read RPC is configured, the submitted forwarder is verified against the chain before anything is persisted (factory `isForwarder` registration plus destination/fallbackAddress/feeBps read-back) so a wrong clone address can never become a mapped account. Replaying identical input returns the existing records; any divergence (same Monerium profile with different forwarder/destination/child/feeBps, same child with a second Monerium profile, a Monerium profile already bound to a different customer entity) is a 409, never an overwrite. A Monerium profile, a forwarder address, and a managed profile can each back at most one account (unique + partial-unique indexes, migrations 069/071). 13. **Onboarding provider writes are exactly-once** — 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. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. @@ -44,7 +44,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-limited to one pass per 30 minutes) is detection-only; its invariants: -1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per `docs/runbooks/monerium-b2b-incident.md`). +1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per `docs/operations-monerium-b2b-runbook.md`). 2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on the pinned EURe→EURC→USDC path at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline). Impact above `SLIPPAGE_BPS` at `minSwapAmount` size logs the error-level PAUSE THRESHOLD line; at `perSwapCap` size a warning. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. 3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal). 4. **Association monitor (S1 detective control)** — per active account, re-reads the profile's linked addresses (`GET /addresses?profile=`) and the partner-context IBAN list (`GET /ibans`) and error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. From e72d5e63906a4c5db1158b46821f49aa3ae5e05a Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 27 Aug 2026 17:55:00 -0300 Subject: [PATCH 52/59] fix(repo): reset dead-man timer after client sweep --- .../monerium-forwarder/src/VortexForwarder.sol | 4 ++++ .../monerium-forwarder/test/VortexForwarder.t.sol | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index fed166267..88689bbba 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -399,6 +399,10 @@ contract VortexForwarder { if (to == address(0)) revert ZeroAddress(); uint256 balance = IERC20(token).balanceOf(address(this)); _transfer(IERC20(token), to, balance); + if (token == address(EURE) && strandedSince != 0) { + strandedSince = 0; + emit Poked(0); + } emit TokenSwept(token, to, balance); } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 45f2dd90a..049f341f4 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -482,6 +482,20 @@ contract VortexForwarderTest is Test { assertEq(eure.balanceOf(fallbackAddr), 500e18); } + function test_fallbackEureSweep_resetsDeadManTimer() public { + _fund(500e18); + fwd.poke(); + skip(SWEEP_DELAY + 1); + + vm.prank(fallbackAddr); + fwd.sweep(address(eure), fallbackAddr); + assertEq(fwd.strandedSince(), 0); + + _fund(500e18); + vm.expectRevert(VortexForwarder.NotStranded.selector); + fwd.sweepStrandedEure(); + } + function test_fallbackAuthority_gated() public { vm.prank(rando); vm.expectRevert(VortexForwarder.NotFallbackAddress.selector); From 9924e50396653b279fa822c3ac3b2ee1c9b646f8 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 27 Aug 2026 17:58:49 -0300 Subject: [PATCH 53/59] fix(api): recover pending monerium swaps before eligibility checks --- .../monerium-b2b/conversion-executor.test.ts | 73 ++++++++++++++++++- .../monerium-b2b/conversion-executor.ts | 36 ++++++--- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index ac2d8095a..83d061918 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from "bun:test"; -import { AllocatableDeposit, allocateUsdcProRata, classifyHashlessPending, selectDepositsForExecution } from "./conversion-executor"; +import { FindOptions, Transaction } from "sequelize"; +import sequelize from "../../../config/database"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import { + AllocatableDeposit, + allocateUsdcProRata, + classifyHashlessPending, + runConversionExecutor, + selectDepositsForExecution +} from "./conversion-executor"; // R04 attribution (docs/architecture-monerium-b2b-onramp.md §3): pro-rata by // amount_raw against eureInRaw, floor division, remainder to the largest deposit. @@ -147,3 +159,62 @@ describe("classifyHashlessPending", () => { expect(result).toEqual({ kind: "fail", reason: "broadcast never reached the mempool" }); }); }); + +describe("runConversionExecutor recovery ordering", () => { + it("resolves an existing pending execution before a closed account can return", async () => { + const originalTransaction = sequelize.transaction; + const originalQuery = sequelize.query; + const originalFindAccount = MoneriumAccount.findByPk; + const originalFindExecution = MoneriumConversionExecution.findOne; + const originalFindExecutions = MoneriumConversionExecution.findAll; + + const account = { + dormantSince: null, + forwarderAddress: "0x1111111111111111111111111111111111111111", + id: "account-1", + status: MoneriumAccountStatus.Closed + } as MoneriumAccount; + const pending = { + accountId: account.id, + createdAt: new Date(), + id: "execution-1", + nonce: null, + status: MoneriumConversionExecutionStatus.Pending, + txHash: null, + updatedAt: new Date(), + async update(values: Partial) { + Object.assign(this, values, { updatedAt: new Date() }); + } + } as unknown as MoneriumConversionExecution; + + try { + sequelize.transaction = (async (...args: unknown[]) => { + const callback = args.at(-1) as (transaction: Transaction) => Promise; + return callback({} as Transaction); + }) as typeof sequelize.transaction; + sequelize.query = (async () => [[], 0]) as unknown as typeof sequelize.query; + MoneriumAccount.findByPk = (async () => account) as typeof MoneriumAccount.findByPk; + MoneriumConversionExecution.findOne = (async (options?: FindOptions) => { + const status = (options?.where as { status?: MoneriumConversionExecutionStatus } | undefined)?.status; + return status === MoneriumConversionExecutionStatus.Pending ? pending : null; + }) as typeof MoneriumConversionExecution.findOne; + MoneriumConversionExecution.findAll = (async (options?: FindOptions) => { + const status = (options?.where as { status?: MoneriumConversionExecutionStatus } | undefined)?.status; + return status === MoneriumConversionExecutionStatus.Pending || status === MoneriumConversionExecutionStatus.Failed + ? [pending] + : []; + }) as typeof MoneriumConversionExecution.findAll; + + await runConversionExecutor(account.id); + + expect(pending.status).toBe(MoneriumConversionExecutionStatus.Failed); + expect(pending.error).toBe("crashed before the transaction was sent"); + } finally { + sequelize.transaction = originalTransaction; + sequelize.query = originalQuery; + MoneriumAccount.findByPk = originalFindAccount; + MoneriumConversionExecution.findOne = originalFindExecution; + MoneriumConversionExecution.findAll = originalFindExecutions; + } + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index 810d2b326..fbea9b29b 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -310,15 +310,16 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans }); for (const pending of pendings) { if (!pending.txHash) { - const client = getPublicClient(); - const keeperAddress = getKeeperWalletClient().account.address; - const [latestNonceCount, pendingNonceCount] = - pending.nonce === null - ? [0, 0] - : await Promise.all([ - client.getTransactionCount({ address: keeperAddress, blockTag: "latest" }), - client.getTransactionCount({ address: keeperAddress, blockTag: "pending" }) - ]); + let latestNonceCount = 0; + let pendingNonceCount = 0; + if (pending.nonce !== null) { + const client = getPublicClient(); + const keeperAddress = getKeeperWalletClient().account.address; + [latestNonceCount, pendingNonceCount] = await Promise.all([ + client.getTransactionCount({ address: keeperAddress, blockTag: "latest" }), + client.getTransactionCount({ address: keeperAddress, blockTag: "pending" }) + ]); + } const unclaimedSwapTxHashes = pending.nonce !== null && latestNonceCount > pending.nonce ? await findUnclaimedSwapTxHashes(account, transaction) : []; const classification = classifyHashlessPending({ @@ -406,6 +407,23 @@ export async function runConversionExecutor(accountId: string): Promise { if (!account) { return; } + + // Recover an earlier broadcast before current account state or balance can make this + // cycle return. A successful swap commonly drains the balance below the minimum. + const existingPending = await MoneriumConversionExecution.findOne({ + attributes: ["id"], + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Pending } + }); + if (existingPending) { + const recovery = await withForwarderLock(account.forwarderAddress, transaction => + prepareExecutionSlot(account, transaction) + ); + if (recovery.kind === "skip") { + logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${recovery.reason}`); + return; + } + } + // Suspended/closed/dormant accounts never swap (dormancy is guardian-paused — // swapAndForward would revert Paused()), but the stranding marker MUST still arm for // them: the un-pausable dead-man sweep is the client's escape hatch for exactly the From d80096b3b86996f8e6cbd17b612b1e6c0c561771 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 27 Aug 2026 18:00:46 -0300 Subject: [PATCH 54/59] fix(repo): classify forwarder fees as guardian mutable --- ...354e847bF597513148918E7EbDff72aC75842.json | 6 ++++-- .../script/manifest-core.ts | 13 ++++++++---- .../script/verify-manifest.test.ts | 14 +++++++++++++ .../script/verify-manifest.ts | 21 +++++++++++-------- 4 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 contracts/monerium-forwarder/script/verify-manifest.test.ts diff --git a/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json index bd54e30c1..713fbbeef 100644 --- a/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json +++ b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json @@ -27,8 +27,10 @@ "salt": "0x0000000000000000000000000000000000000000000000000000000000000002", "txHash": "0x90e67a4f9698c9d009687eaaedd2d8b89eebfa104a2edb4c7923fa7a033f2787" }, + "guardianMutable": { + "feeBps": 0 + }, "immutables": { - "feeBps": 0, "isForwarder": true }, "runtimeBytecodeHash": "0x215c88056e67f5925d5660d5eb02a45dca8e69b50db65bacb5ba9ed44d63f364" @@ -60,6 +62,6 @@ }, "runtimeBytecodeHash": "0x0c362c605fc7a795c4cab02fca0751e27817caf84017296d8fa2325dde6f1d11" }, - "manifestVersion": 1, + "manifestVersion": 2, "purpose": "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). NOT a trust root (re-review R01): this file is produced by Vortex from the same chain state it attests to, so it proves only that the deployment has not silently changed since publication — not that it was honest. Verify contract behavior independently against the verified source on a block explorer." } diff --git a/contracts/monerium-forwarder/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts index 7c6f5c103..da1707f7f 100644 --- a/contracts/monerium-forwarder/script/manifest-core.ts +++ b/contracts/monerium-forwarder/script/manifest-core.ts @@ -11,7 +11,7 @@ import { Address, getAddress, Hex, keccak256, PublicClient, parseAbi, parseAbiIt * source on a block explorer. */ -export const MANIFEST_VERSION = 1; +export const MANIFEST_VERSION = 2; export const MANIFEST_PURPOSE = "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). " + @@ -106,9 +106,12 @@ export interface ForwarderManifestEntry { salt: Hex; txHash: Hex; }; - /** Set once in the deploy transaction; immutable afterwards. Mismatch = incident. */ - immutables: { + /** Guardian-adjustable under the contract's bounded, timelocked fee policy. */ + guardianMutable: { feeBps: number; + }; + /** Factory registration is fixed for the lifetime of the clone. Mismatch = incident. */ + immutables: { isForwarder: boolean; }; /** keccak256 of the clone's runtime code; must equal the EIP-1167 code for `implementation.address`. */ @@ -407,8 +410,10 @@ export async function readForwarderEntry( salt: deploy.salt, txHash: deploy.txHash }, + guardianMutable: { + feeBps: Number(feeBps) + }, immutables: { - feeBps: Number(feeBps), isForwarder }, runtimeBytecodeHash: forwarderCodeHash diff --git a/contracts/monerium-forwarder/script/verify-manifest.test.ts b/contracts/monerium-forwarder/script/verify-manifest.test.ts new file mode 100644 index 000000000..420901354 --- /dev/null +++ b/contracts/monerium-forwarder/script/verify-manifest.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test"; +import { severityFor } from "./verify-manifest"; + +describe("manifest diff severity", () => { + it("treats guardian fee changes as notices", () => { + expect(severityFor("forwarders.0x123.guardianMutable.feeBps")).toBe("NOTICE"); + }); + + it("keeps client changes expected and immutable changes fatal", () => { + expect(severityFor("forwarders.0x123.clientMutable.destination")).toBe("EXPECTED-TRANSITION"); + expect(severityFor("forwarders.0x123.immutables.isForwarder")).toBe("FAIL"); + expect(severityFor("forwarders.0x123.runtimeBytecodeHash")).toBe("FAIL"); + }); +}); diff --git a/contracts/monerium-forwarder/script/verify-manifest.ts b/contracts/monerium-forwarder/script/verify-manifest.ts index 4c300114a..b08b84993 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.ts @@ -39,12 +39,12 @@ import { * the client's own fallbackAddress (`onlyFallback` in the * contract). Owner-authorized, not an incident (re-review R07); * regenerate + republish the manifest. exit 0 - * NOTICE guardian-tunable operational parameters, a stale forwarder - * list (new deployments since publication), or a skipped - * completeness check. exit 0 + * NOTICE guardian-tunable forwarder/factory parameters, a stale + * forwarder list (new deployments since publication), or a + * skipped completeness check. exit 0 */ -type Severity = "FAIL" | "EXPECTED-TRANSITION" | "NOTICE"; +export type Severity = "FAIL" | "EXPECTED-TRANSITION" | "NOTICE"; interface Diff { actual: string; @@ -63,8 +63,9 @@ function flatten(value: unknown, prefix: string, out: Map): void out.set(prefix, String(value)); } -function severityFor(path: string): Severity { +export function severityFor(path: string): Severity { if (path.includes(".clientMutable.")) return "EXPECTED-TRANSITION"; + if (path.includes(".guardianMutable.")) return "NOTICE"; if (path.includes(".operational.")) return "NOTICE"; return "FAIL"; } @@ -205,7 +206,9 @@ async function main(): Promise { ); } -main().catch(error => { - console.error(`error: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); -}); +if (import.meta.main) { + main().catch(error => { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} From bfadc6a17317bb0237234f58863d395c1d34483d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 27 Aug 2026 18:01:19 -0300 Subject: [PATCH 55/59] chore(repo): exclude forge dependency from biome --- biome.json | 1 + 1 file changed, 1 insertion(+) diff --git a/biome.json b/biome.json index e45e3c4a7..9c7e46e81 100644 --- a/biome.json +++ b/biome.json @@ -23,6 +23,7 @@ "!**/gql/**", "!**/lottie/**", "!**/storybook-static/**", + "!contracts/monerium-forwarder/lib/**", "!contracts/relayer/artifacts/**", "!contracts/relayer/cache/**", "!contracts/relayer/ignition/deployments/**", From c7767d2f0c140e407c570d63257a95922d5cb4e2 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 31 Aug 2026 15:45:07 -0300 Subject: [PATCH 56/59] docs(api): document monerium b2b fork exercise --- docs/operations-monerium-b2b-runbook.md | 327 ++++++++++++++++++++++++ 1 file changed, 327 insertions(+) diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 097697484..32e0503e7 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -272,3 +272,330 @@ the IBAN's current default address; the old clone stays linked but inert. | `ADMIN_SECRET` | Map/suspend accounts (mapping is bounded by on-chain clone verification) | Rotate; audit recent admin mutations | | Webhook HMAC secret | Fabricated inbound order events (accounting noise; forward-only lattice + mint watcher bound the damage) | Rotate at both ends; reconcile deposits against chain | | Client fallback key (client-side) | Full control of that client's funds/config | Client's own responsibility (terms); assist via partner: pause the account; client rotates `setFallbackAddress` if still in control | + +## 7. Local mainnet-fork integration exercise + +This exercise validates the deployed forwarder and live keeper backend together against +real Ethereum mainnet token, pool, router, and oracle state. It is an opt-in operator +exercise, not a hermetic automated test: it needs an archive-capable Ethereum RPC, a +local Postgres database, and the API environment. It must not run in the PR-blocking test +suite; `operations-testing.md` deliberately keeps fork tests out of CI. + +The procedure below is the cleaned-up path from a successful reference run. It assumes +archive access and a historical EURe holder are available from the outset. Use only the +standard public Anvil development keys for the local roles. + +### 7.1 Reference configuration and result + +| Item | Reference value | +|---|---| +| Fork block | `25876292` | +| Chain ID | `1` | +| Anvil RPC | `http://127.0.0.1:8545` | +| Archive proxy | `http://127.0.0.1:9545` | +| EURe V2 | `0x39b8B6385416f4cA36a20319F70D28621895279D` | +| EURC | `0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c` | +| USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | +| SwapRouter02 | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | +| Chainlink EUR/USD | `0xb49f677943BC038e9857d61E7d053CaA2C1734C1` | +| EURe holder | `0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c` | +| Factory | `0xbe613aa10f731ea38786a082a341fe1a1bc9e266` | +| Factory deployment tx | `0xf3531fbdb27ed9303974b282ed2df4c53765c0910e210c7cba182e6c7f25a368` | +| Forwarder | `0xe06103c9E374a1CD78f17417d1eA3AE4eBaC7CFD` | +| Forwarder deployment tx | `0x7d4f667d4de9b7a5d5aced2203a3f11dcd0b477e5d405d5b8a2317f2b56b7c4c` | +| Destination | `0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc` | +| Fallback address | `0x976EA74026E726554dB657fA54763abd0C3a0aa9` | +| Local account id | `5ebca15c-dadf-4eeb-aabb-e9c7462ff6b3` | +| Mock Monerium profile id | `f436dbeb-6012-4688-ab3b-d2446980c835` | +| Managed profile id | `c419d077-3e2b-488a-b228-359311c63324` | +| Mock IBAN | `DE12500105170648489890` | + +The reference deposit transferred 25 EURe to the forwarder in transaction +`0x727a53eb525e5851d8db38ea99c2f39633b6213de5757639d82e6c112e49079a`. +The live keeper confirmed conversion transaction +`0xb52f38073c41b5e8d2f89deab5c2b8536362acfd97903579113630fc02b58eb4`, +consumed the full 25 EURe, and forwarded `29.012924` USDC with a zero fee. These +addresses and hashes are evidence from that ephemeral run, not deployment pins; use the +receipts and addresses produced by each new run. + +### 7.2 Start an archive-backed fork + +To avoid placing `ALCHEMY_API_KEY` in Anvil's process arguments, run a local proxy from +`apps/api/` that reads the existing `.env`: + +```bash +bun -e ' +import "dotenv/config"; +const upstream = `https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`; +Bun.serve({ + hostname: "127.0.0.1", + port: 9545, + async fetch(request) { + return fetch(upstream, { + method: request.method, + headers: { "content-type": request.headers.get("content-type") ?? "application/json" }, + body: request.body + }); + } +}); +await new Promise(() => {}); +' +``` + +In another terminal, start the fixed fork: + +```bash +anvil \ + --fork-url http://127.0.0.1:9545 \ + --fork-block-number 25876292 \ + --chain-id 1 \ + --host 127.0.0.1 \ + --port 8545 \ + --no-rate-limit +``` + +Confirm the fork can read historical state before deploying anything: + +```bash +cast call 0x39b8B6385416f4cA36a20319F70D28621895279D \ + "balanceOf(address)(uint256)" \ + 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + --rpc-url http://127.0.0.1:8545 +``` + +At the reference block the holder had `191297573010983027041550` raw EURe units. +Fund its native balance locally and impersonate it; do not mutate its EURe storage: + +```bash +cast rpc anvil_setBalance \ + 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + 0x8AC7230489E80000 \ + --rpc-url http://127.0.0.1:8545 + +cast rpc anvil_impersonateAccount \ + 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + --rpc-url http://127.0.0.1:8545 +``` + +Before deploying the forwarder, send 5 EURe to an unrelated address and verify its +balance. This isolates basic fork/provider/ERC-20 failures from forwarder failures: + +```bash +cast send 0x39b8B6385416f4cA36a20319F70D28621895279D \ + "transfer(address,uint256)(bool)" \ + 0x1234567890123456789012345678901234567890 \ + 5000000000000000000 \ + --from 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + --unlocked \ + --rpc-url http://127.0.0.1:8545 +``` + +### 7.3 Deploy the factory and clone + +Build from `contracts/monerium-forwarder/` and deploy +`VortexForwarderFactory.sol:VortexForwarderFactory` with three distinct standard Anvil +accounts: account 0 as guardian, account 1 as keeper, and account 2 as attestor. Use +account 3 as the fee recipient. Anvil prints these public development keys and addresses +on startup. + +Use the ADR's canonical constructor values, not the older values that may appear in test +fixtures: + +| Constructor field | Value | +|---|---:| +| `MAX_ORACLE_AGE` | 52 hours | +| `SLIPPAGE_BPS` | 100 | +| `MAX_FEE_BPS` | 100 | +| `SWEEP_DELAY` | 60 days | +| `TRIGGER_DELAY` | 24 hours | +| `POOL_FEE_EURE_EURC` | 500 | +| `POOL_FEE_EURC_USDC` | 500 | +| `RECOVERY_HASH` | `bytes32(0)` | +| `MIN_SWAP_FLOOR` | `25e18` | +| `CAP_CEILING` | `50000e18` | +| Initial `minSwapAmount` | `250e18` | +| Initial `perSwapCap` | `25000e18` | + +After deployment, register Anvil account 1 as a keeper and lower the mutable minimum to +the immutable 25 EURe floor for this exercise: + +```bash +cast send "$FACTORY" "setKeeper(address,bool)" "$KEEPER" true \ + --private-key "$GUARDIAN_KEY" --rpc-url http://127.0.0.1:8545 + +cast send "$FACTORY" "setMinSwapAmount(uint256)" 25000000000000000000 \ + --private-key "$GUARDIAN_KEY" --rpc-url http://127.0.0.1:8545 +``` + +Deploy a zero-fee client clone as in §1.2. Use a fresh salt and record the predicted +address and receipt. Read back `destination()`, `fallbackAddress()`, `feeBps()`, and +`FACTORY()`, then require `factory.isForwarder(forwarder) == true` before continuing. + +### 7.4 Create the local account fixture + +This exercise does not create a real Monerium corporate. Generate a random UUID for +`moneriumProfileId`, then call the normal admin mapping endpoint with an existing active +managed-profile manager: + +```json +{ + "managerProfileId": "", + "externalSubjectId": "local-corporate-", + "contactEmail": "local-corporate-@example.com", + "moneriumProfileId": "", + "forwarderAddress": "", + "destination": "", + "fallbackAddress": "", + "feeBps": 0 +} +``` + +Send it to `POST /v1/admin/monerium-b2b/accounts` with +`Authorization: Bearer $ADMIN_SECRET`. This exercises the real forwarder registration +and config verification before creating the managed child, approved KYB mirror, and +`onboarding` account. + +Because the Monerium profile is intentionally fake, mock an IBAN directly on the new +local `monerium_accounts` row. Then activate through the real endpoint rather than +updating status directly: + +```http +PATCH /v1/admin/monerium-b2b/accounts//status +Authorization: Bearer +Content-Type: application/json + +{ "status": "active" } +``` + +The activation response must report `accountStatus: "active"`. The direct IBAN update +is a local fixture seam only; never use it outside an ephemeral local database. + +### 7.5 Start the keeper backend + +The Monerium B2B worker is owned by the `mykobo` flow variant. A default `monerium` +backend serves the API but deliberately does not start this worker. The simplest +reproduction is one `mykobo` backend that serves both the admin API and keeper: + +```bash +FLOW_VARIANT=mykobo \ +PORT=3000 \ +MONERIUM_B2B_RPC_URL=http://127.0.0.1:8545 \ +MONERIUM_B2B_PRIVATE_RPC_URL=http://127.0.0.1:8545 \ +MONERIUM_B2B_GUARDIAN_PRIVATE_KEY="$ANVIL_ACCOUNT_0_KEY" \ +MONERIUM_B2B_KEEPER_PRIVATE_KEY="$ANVIL_ACCOUNT_1_KEY" \ +MONERIUM_B2B_ATTESTOR_PRIVATE_KEY="$ANVIL_ACCOUNT_2_KEY" \ +bun run --cwd apps/api dev +``` + +The log must contain `Starting Monerium B2B keeper worker`. Wait for one worker cycle +and verify `monerium_chain_cursors` contains `eure-mints:1` before sending the deposit; +otherwise the watcher's first-run bootstrap intentionally starts at the current settled +head and treats earlier chain history as out of scope. + +### 7.6 Send and settle the deposit + +Transfer exactly 25 EURe from the impersonated holder to the clone: + +```bash +cast send 0x39b8B6385416f4cA36a20319F70D28621895279D \ + "transfer(address,uint256)(bool)" \ + "$FORWARDER" \ + 25000000000000000000 \ + --from 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + --unlocked \ + --rpc-url http://127.0.0.1:8545 +``` + +Mine 13 blocks so the transfer is beyond the watcher's 12-block reorg safety depth: + +```bash +cast rpc anvil_mine 0xd --rpc-url http://127.0.0.1:8545 +``` + +Wait for the next worker cycle. A direct transfer has no matching Monerium order, so the +expected path is deliberately `unattr:` rather than an attributed customer deposit. The +log should show an unattributed EURe mint followed by an execution allocation. + +Verify the durable records: + +```sql +SELECT monerium_order_id, amount_raw, status, tx_hash, log_index, + block_number, allocated_execution_id +FROM monerium_fiat_deposits +WHERE account_id = ''; + +SELECT eure_in_raw, usdc_gross_raw, fee_raw, usdc_net_raw, destination, + tx_hash, nonce, block_number, status, error +FROM monerium_conversion_executions +WHERE account_id = ''; +``` + +Required results: + +- One `minted` deposit with an `unattr:` order id, the real transfer hash and log index, + and a non-null `allocated_execution_id`. +- One `confirmed` execution with the 25 EURe input, zero fee, non-null nonce/hash/block, + destination matching the clone, and `error IS NULL`. +- The forwarder's EURe balance is zero. +- The destination's USDC balance increased by `usdc_net_raw`. +- The conversion receipt contains `SwapExecuted` from the clone and a USDC `Transfer` + from the clone to the configured destination. + +### 7.7 What this exercise validates + +- An archive-backed mainnet fork can execute the real EURe V2 proxy and emit the + canonical `Transfer` event consumed by the watcher. +- Factory construction deploys the implementation with the canonical parameter values, + registers the keeper, and creates an initialized EIP-1167 clone. +- Successful admin provisioning reads back the clone's factory registration and config + before creating the managed child plus approved KYB mirror. +- The account activation success path works once an IBAN is present. +- Only the `mykobo` backend owns and starts the B2B keeper worker. +- The persisted chain cursor detects the transfer after it is moved beyond the 12-block + safety depth. +- A direct transfer to a known forwarder is durably recorded as an unattributed mint, + not silently presented as a Monerium customer order. +- The executor's durable path leaves a confirmed execution with its nonce, transaction + hash, block number, amounts, destination, and deposit allocation recorded. +- The real contract accepts the current Chainlink EUR/USD answer and swaps successfully + through the pinned EURe -> EURC -> USDC 5-bps Uniswap V3 path. +- Keeper authorization, the 25 EURe minimum, allowance reset, full EURe consumption, + zero-fee accounting, and forwarding to the immutable per-client destination work + together. +- Snapshot allocation links the observed deposit to the confirmed execution and assigns + the full USDC output. + +### 7.8 What this exercise does not validate + +- It does not make a SEPA transfer or ask Monerium to mint EURe. The input is an ordinary + ERC-20 transfer from an impersonated historical holder. +- It does not create or approve a corporate in Monerium, link the forwarder through the + attestor/EIP-1271 flow, request an IBAN, or process a real `iban.updated` webhook. The + Monerium profile UUID and IBAN are local fixtures. +- It does not test webhook HMAC verification, durable inbox deduplication, Monerium order + state transitions, amount/hash matching, or the attributed-deposit path. The tested + mint is intentionally unattributed. +- It does not prove that a bank or CEX credits the destination. It proves only the + on-chain USDC balance increase. +- It does not test invalid admin authentication or the activation rejection before an + IBAN is present. It also does not exercise provisioning rejection for an unregistered + or misconfigured forwarder; only authenticated successful provisioning and activation + run. +- It does not test out-of-bounds factory parameters or prove their rejection; the run + deploys only the canonical valid parameter set. +- It does not test fees above zero, fee-increase timelocks, per-swap-cap batching, + sub-minimum accumulation, pause controls, dormancy, permissionless triggering, + stranded-fund sweeping, fallback-key recovery, or client config rotation. +- It does not test stale/invalid oracle answers, insufficient liquidity, excess price + impact, slippage reverts, router failure, token transfer failure, or depeg behavior. +- It does not test reorg replacement, duplicate-log replay, concurrent executors, + advisory-lock contention, process crashes before/after broadcast, lost transaction + hashes, nonce replacement, or restart recovery. +- It does not test production key custody, private orderflow, production RPC behavior, + source verification, deployment manifests, or independent bytecode verification. +- It does not validate manager notifications, webhook outbox delivery, email delivery, + or the 32-block client-notification confirmation policy. +- It does not constitute a clean monitoring pass. In the reference run the association + monitor received the expected provider `403` for the fake profile, and the large-size + executable-depth quote timed out once; neither monitor was part of the conversion + success criterion. From f9cfa5f97917b883ee246b4ce73717f68d1ef532 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 11 Sep 2026 11:11:52 +0200 Subject: [PATCH 57/59] feat(frontend): add open graph and description meta tags The site emitted no description or og:* tags, so LinkedIn, X, Slack and messaging apps could not build a link preview for vortexfinance.co. The og:image is the existing 512px mark padded to 1200x630 on white so the preview works today; swap in a designed asset at public/og-image.png without touching code. --- apps/frontend/public/og-image.png | Bin 0 -> 159188 bytes apps/frontend/src/routes/__root.tsx | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/public/og-image.png diff --git a/apps/frontend/public/og-image.png b/apps/frontend/public/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..abbc591e72803f49c2b3704d6f515142facecafc GIT binary patch literal 159188 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 rel: "stylesheet" } ], - meta: [{ charSet: "utf-8" }, { content: "width=device-width, initial-scale=1.0", name: "viewport" }, { title: "Vortex" }], + meta: [ + { charSet: "utf-8" }, + { content: "width=device-width, initial-scale=1.0", name: "viewport" }, + { title: "Vortex" }, + { content: SITE_DESCRIPTION, name: "description" }, + { content: "Vortex", property: "og:title" }, + { content: SITE_DESCRIPTION, property: "og:description" }, + { content: "website", property: "og:type" }, + { content: SITE_URL, property: "og:url" }, + { content: `${SITE_URL}/og-image.png`, property: "og:image" }, + { content: "summary_large_image", name: "twitter:card" } + ], scripts: [{ children: GTM_SNIPPET }] }), shellComponent: RootDocument From 7cf646495d3d0320e7289ba183efebbc477ca581 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 11 Sep 2026 12:09:17 +0200 Subject: [PATCH 58/59] fix(api): harden monerium b2b rollout --- apps/api/.env.example | 12 + .../admin/moneriumB2b.controller.test.ts | 34 +- .../admin/moneriumB2b.controller.ts | 21 +- .../controllers/monerium-b2b.controller.ts | 50 +- apps/api/src/api/routes/v1/index.ts | 9 +- .../managed-profile-provisioning.service.ts | 147 ++--- .../monerium-b2b/account-provisioning.ts | 261 +++++---- .../src/api/services/monerium-b2b/chain.ts | 10 + .../conversion-allocation.test.ts | 75 +++ .../monerium-b2b/conversion-executor.test.ts | 171 ++++-- .../monerium-b2b/conversion-executor.ts | 507 ++++++++++++------ .../monerium-b2b/deposit-processor.test.ts | 335 +++++++++++- .../monerium-b2b/deposit-processor.ts | 242 +++++++-- .../monerium-b2b/feature-gate.test.ts | 58 ++ .../src/api/services/monerium-b2b/feature.ts | 4 + .../monerium-b2b/manager-events.test.ts | 98 +++- .../services/monerium-b2b/manager-events.ts | 62 ++- .../monerium-b2b/mint-watcher.test.ts | 14 +- .../api/services/monerium-b2b/mint-watcher.ts | 27 +- .../monerium-b2b/monerium-api.test.ts | 49 ++ .../api/services/monerium-b2b/monerium-api.ts | 36 +- .../api/services/monerium-b2b/monitoring.ts | 53 +- .../services/monerium-b2b/onboarding.test.ts | 35 +- .../api/services/monerium-b2b/onboarding.ts | 35 +- .../api/services/monerium-b2b/webhook.test.ts | 90 ++-- .../src/api/services/monerium-b2b/webhook.ts | 52 +- .../webhook/__tests__/webhook.service.test.ts | 17 + .../api/services/webhook/webhook.service.ts | 15 + .../src/api/workers/monerium-b2b.worker.ts | 61 +-- apps/api/src/config/express.ts | 20 +- apps/api/src/config/vars.test.ts | 81 +++ apps/api/src/config/vars.ts | 60 +++ .../075-add-conversion-broadcast-block.ts | 14 + ...076-create-monerium-deposit-allocations.ts | 56 ++ .../077-add-conversion-swap-log-index.ts | 14 + ...erium-deposit-allocation-migration.test.ts | 25 + apps/api/src/index.ts | 7 +- apps/api/src/models/index.ts | 6 + .../moneriumConversionExecution.model.ts | 23 +- .../models/moneriumDepositAllocation.model.ts | 82 +++ .../src/models/moneriumFiatDeposit.model.ts | 8 - apps/api/src/test-utils/preload.ts | 8 + ...erium-b2b-account-read.integration.test.ts | 30 +- docs/adr-0005-monerium-b2b-onramp.md | 4 +- docs/api/openapi/vortex.openapi.d.ts | 16 +- docs/api/openapi/vortex.openapi.json | 55 +- docs/api/pages/07-webhooks.md | 26 +- docs/api/wire-contract.snapshot.md | 24 +- docs/architecture-monerium-b2b-onramp.md | 101 ++-- docs/operations-monerium-b2b-rollout.md | 40 +- docs/operations-monerium-b2b-runbook.md | 44 +- .../05-integrations/monerium-b2b.md | 40 +- .../shared/src/endpoints/webhook.endpoints.ts | 13 +- 53 files changed, 2587 insertions(+), 790 deletions(-) create mode 100644 apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/feature-gate.test.ts create mode 100644 apps/api/src/api/services/monerium-b2b/feature.ts create mode 100644 apps/api/src/api/services/monerium-b2b/monerium-api.test.ts create mode 100644 apps/api/src/database/migrations/075-add-conversion-broadcast-block.ts create mode 100644 apps/api/src/database/migrations/076-create-monerium-deposit-allocations.ts create mode 100644 apps/api/src/database/migrations/077-add-conversion-swap-log-index.ts create mode 100644 apps/api/src/database/monerium-deposit-allocation-migration.test.ts create mode 100644 apps/api/src/models/moneriumDepositAllocation.model.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 30d8735ff..0d3c2b6af 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -164,6 +164,18 @@ MONERIUM_REDIRECT_URI=http://localhost:5174/dashboard/monerium/callback MONERIUM_WHITELABEL_CLIENT_ID=your-monerium-whitelabel-client-id MONERIUM_WHITELABEL_CLIENT_SECRET=your-monerium-whitelabel-client-secret +# Monerium B2B onramp. The entire surface stays unmounted unless this is exactly true. +MONERIUM_B2B_ENABLED=false +# Required before enabling the feature. +MONERIUM_B2B_ATTESTOR_PRIVATE_KEY= +MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS= +MONERIUM_B2B_GUARDIAN_PRIVATE_KEY= +MONERIUM_B2B_KEEPER_PRIVATE_KEY= +MONERIUM_B2B_RPC_URL= +MONERIUM_B2B_WEBHOOK_SECRET= +# Required in production. Non-production environments may submit through the public RPC. +MONERIUM_B2B_PRIVATE_RPC_URL= + # BRLA / Avenia # BRLA_BASE_URL= BRLA_API_KEY=your-brla-api-key diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts index 4e15ed584..db25244c6 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import express from "express"; +import { config } from "../../../config/vars"; import KycCase from "../../../models/kycCase.model"; import ManagedProfile from "../../../models/managedProfile.model"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; @@ -17,12 +18,16 @@ const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; const FALLBACK = "0x3333333333333333333333333333333333333333"; +const FACTORY = "0x4444444444444444444444444444444444444444"; describe("monerium b2b account mapping admin route", () => { let server: ReturnType; let baseUrl: string; + let originalRpcUrl: string | undefined; beforeAll(async () => { + originalRpcUrl = config.moneriumB2b.rpcUrl; + config.moneriumB2b.rpcUrl = undefined; await setupTestDatabase(); const app = express(); @@ -35,6 +40,7 @@ describe("monerium b2b account mapping admin route", () => { }); afterAll(() => { + config.moneriumB2b.rpcUrl = originalRpcUrl; server?.close(); }); @@ -190,13 +196,23 @@ describe("monerium b2b account mapping admin route", () => { expect(differentFee.status).toBe(409); expect(await MoneriumAccount.count()).toBe(1); + expect(await ManagedProfile.count()).toBe(1); + expect(await ProviderCustomer.count()).toBe(1); + expect(await KycCase.count()).toBe(1); + expect(await User.count()).toBe(2); }); it("compares submitted account data against the deployed clone config", () => { - const expected = { destination: DESTINATION.toLowerCase(), fallbackAddress: FALLBACK.toLowerCase(), feeBps: 0 }; - const matching = { destination: DESTINATION, fallbackAddress: FALLBACK, feeBps: 0, isForwarder: true }; + const expected = { + destination: DESTINATION.toLowerCase(), + factory: FACTORY.toLowerCase(), + fallbackAddress: FALLBACK.toLowerCase(), + feeBps: 0 + }; + const matching = { destination: DESTINATION, factory: FACTORY, fallbackAddress: FALLBACK, feeBps: 0, isForwarder: true }; expect(forwarderConfigMismatch(expected, matching)).toBeNull(); + expect(forwarderConfigMismatch(expected, { ...matching, factory: FORWARDER })).toContain("trusted factory"); expect(forwarderConfigMismatch(expected, { ...matching, isForwarder: false })).toContain("not a clone"); expect(forwarderConfigMismatch(expected, { ...matching, destination: FALLBACK })).toContain("destination"); expect(forwarderConfigMismatch(expected, { ...matching, fallbackAddress: DESTINATION })).toContain("fallbackAddress"); @@ -257,6 +273,20 @@ describe("monerium b2b account mapping admin route", () => { expect(reactivated.status).toBe(200); expect(await reactivated.json()).toMatchObject({ account: { accountStatus: "active" } }); + const regressed = await patchStatus(account.accountId, "onboarding"); + expect(regressed.status).toBe(409); + expect(await regressed.json()).toMatchObject({ error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION" } }); + + const closed = await patchStatus(account.accountId, "closed"); + expect(closed.status).toBe(200); + expect((await patchStatus(account.accountId, "closed")).status).toBe(200); + for (const invalidStatus of ["active", "onboarding", "suspended"]) { + const reopened = await patchStatus(account.accountId, invalidStatus); + expect(reopened.status).toBe(409); + expect(await reopened.json()).toMatchObject({ error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION" } }); + } + expect((await MoneriumAccount.findByPk(account.accountId))?.status).toBe(MoneriumAccountStatus.Closed); + expect((await patchStatus(account.accountId, "nonsense")).status).toBe(400); expect((await patchStatus(crypto.randomUUID(), "active")).status).toBe(404); }); diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts index ad823ec98..29804085a 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -83,6 +83,12 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi } const STATUS_VALUES = Object.values(MoneriumAccountStatus) as string[]; +const STATUS_TRANSITIONS: Record = { + [MoneriumAccountStatus.Onboarding]: [MoneriumAccountStatus.Active], + [MoneriumAccountStatus.Active]: [MoneriumAccountStatus.Suspended, MoneriumAccountStatus.Closed], + [MoneriumAccountStatus.Suspended]: [MoneriumAccountStatus.Active, MoneriumAccountStatus.Closed], + [MoneriumAccountStatus.Closed]: [] +}; export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: string }>, res: Response): Promise { try { @@ -105,6 +111,17 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st }); return; } + const targetStatus = status as MoneriumAccountStatus; + if (targetStatus !== account.status && !STATUS_TRANSITIONS[account.status].includes(targetStatus)) { + res.status(httpStatus.CONFLICT).json({ + error: { + code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", + message: `Monerium account cannot transition from ${account.status} to ${targetStatus}`, + status: httpStatus.CONFLICT + } + }); + return; + } // Activation requires the issued IBAN: the penny test (runbook §7) cannot have // happened without it, and the association monitor needs the reference state. if (status === MoneriumAccountStatus.Active && account.iban === null) { @@ -118,7 +135,9 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st return; } - await account.update({ status: status as MoneriumAccountStatus }); + if (targetStatus !== account.status) { + await account.update({ status: targetStatus }); + } res.status(httpStatus.OK).json({ account: { accountId: account.id, accountStatus: account.status } }); } catch (error) { logger.error("Error updating Monerium B2B account status:", error); diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index 94156621f..7edfe4737 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -5,14 +5,16 @@ import logger from "../../config/logger"; import { config } from "../../config/vars"; import MoneriumAccount from "../../models/moneriumAccount.model"; import MoneriumConversionExecution from "../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit from "../../models/moneriumFiatDeposit.model"; import { APIError } from "../errors/api-error"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; import { UNATTRIBUTED_ORDER_PREFIX } from "../services/monerium-b2b/mint-watcher"; import { - deriveEventId, + MONERIUM_ID_HEADER, MONERIUM_SIGNATURE_HEADER, + MONERIUM_TIMESTAMP_HEADER, recordWebhookEvent, verifyWebhookSignature } from "../services/monerium-b2b/webhook"; @@ -32,7 +34,12 @@ export const handleWebhook = async (req: Request, res: Response, next: NextFunct // Raw bytes captured by the body-parser verify hook in config/express.ts. const rawBody = (req as Request & { rawBody?: Buffer }).rawBody; - if (!rawBody || !verifyWebhookSignature(rawBody, req.header(MONERIUM_SIGNATURE_HEADER), secret)) { + const webhookId = req.header(MONERIUM_ID_HEADER); + const webhookTimestamp = req.header(MONERIUM_TIMESTAMP_HEADER); + if ( + !rawBody || + !verifyWebhookSignature(rawBody, webhookId, webhookTimestamp, req.header(MONERIUM_SIGNATURE_HEADER), secret) + ) { throw new APIError({ message: "Invalid webhook signature", status: httpStatus.UNAUTHORIZED }); } @@ -43,7 +50,7 @@ export const handleWebhook = async (req: Request, res: Response, next: NextFunct throw new APIError({ message: "Webhook payload is not valid JSON", status: httpStatus.BAD_REQUEST }); } - await recordWebhookEvent(deriveEventId(rawBody, payload), payload); + await recordWebhookEvent(webhookId as string, payload); res.status(httpStatus.OK).json({ received: true }); setImmediate(() => { @@ -131,28 +138,43 @@ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: where: { accountId: account.id, moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` } } }); - const executionIds = [...new Set(rows.map(row => row.allocatedExecutionId).filter((id): id is string => id !== null))]; + const allocations = rows.length + ? await MoneriumDepositAllocation.findAll({ + order: [["created_at", "ASC"]], + where: { depositId: rows.map(row => row.id) } + }) + : []; + const executionIds = [...new Set(allocations.map(allocation => allocation.executionId))]; const executions = executionIds.length ? await MoneriumConversionExecution.findAll({ where: { id: executionIds } }) : []; const executionById = new Map(executions.map(execution => [execution.id, execution])); + const allocationsByDeposit = new Map(); + for (const allocation of allocations) { + const grouped = allocationsByDeposit.get(allocation.depositId) ?? []; + grouped.push(allocation); + allocationsByDeposit.set(allocation.depositId, grouped); + } res.status(httpStatus.OK).json({ deposits: rows.map(row => { - const execution = row.allocatedExecutionId ? executionById.get(row.allocatedExecutionId) : undefined; + const depositAllocations = allocationsByDeposit.get(row.id) ?? []; return { amountRaw: row.amountRaw, - conversion: execution - ? { - executionId: execution.id, - status: execution.status, - txHash: execution.txHash, - usdcNetRaw: execution.usdcNetRaw - } - : null, + conversions: depositAllocations.map(allocation => { + const execution = executionById.get(allocation.executionId); + return { + eureInRaw: allocation.eureInRaw, + executionId: allocation.executionId, + status: execution?.status ?? "pending", + txHash: execution?.txHash ?? null, + usdcNetRaw: allocation.usdcNetRaw + }; + }), createdAt: row.createdAt, currency: row.currency, depositId: row.id, status: row.status, - txHash: row.txHash + txHash: row.txHash, + usdcNetRaw: depositAllocations.reduce((sum, allocation) => sum + BigInt(allocation.usdcNetRaw), 0n).toString() }; }), pagination: { limit, offset, total: count } diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 03cd98628..eaf46c128 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -1,4 +1,5 @@ import { Request, Response, Router } from "express"; +import { config } from "../../../config/vars"; import { sendStatusWithPk as sendMoonbeamStatusWithPk } from "../../controllers/moonbeam.controller"; import { sendStatusWithPk as sendPendulumStatusWithPk } from "../../controllers/pendulum.controller"; import { setAlfredpayCountryFromRoute } from "../../middlewares/alfredpay.middleware"; @@ -191,7 +192,9 @@ router.use("/monerium", moneriumRoutes); * delegation or child credential; EU/business policy). * GET /v1/monerium-b2b/deposits — the acting profile's deposits with conversion status. */ -router.use("/monerium-b2b", moneriumB2bRoutes); +if (config.moneriumB2b.enabled) { + router.use("/monerium-b2b", moneriumB2bRoutes); +} /** * POST v1/webhook @@ -285,7 +288,9 @@ router.use("/admin/managed-profiles", adminManagedProfilesRoutes); * deployed forwarder accounts (idempotent). * POST /v1/admin/monerium-b2b/accounts */ -router.use("/admin/monerium-b2b", adminMoneriumB2bRoutes); +if (config.moneriumB2b.enabled) { + router.use("/admin/monerium-b2b", adminMoneriumB2bRoutes); +} /** * Admin routes for API client observability dashboards diff --git a/apps/api/src/api/services/managed-profile-provisioning.service.ts b/apps/api/src/api/services/managed-profile-provisioning.service.ts index c919506fe..b976f55d2 100644 --- a/apps/api/src/api/services/managed-profile-provisioning.service.ts +++ b/apps/api/src/api/services/managed-profile-provisioning.service.ts @@ -80,80 +80,95 @@ async function existingResult( }; } -export async function provisionManagedProfile(input: ProvisionManagedProfileInput): Promise { - const externalSubjectId = input.externalSubjectId.trim(); - const contactEmail = input.contactEmail.trim().toLowerCase(); - if (!externalSubjectId || Joi.string().email().max(255).required().validate(contactEmail).error) { +async function provisionManagedProfileInTransaction( + input: ProvisionManagedProfileInput, + externalSubjectId: string, + contactEmail: string, + transaction: Transaction +): Promise { + const manager = await ManagedProfileManager.findByPk(input.managerProfileId, { + lock: Transaction.LOCK.UPDATE, + transaction + }); + if (!manager) { + throw new ManagedProfileProvisioningError("MANAGED_PROFILE_MANAGER_NOT_FOUND", "Managed profile manager not found"); + } + if (!manager.isActive) { + throw new ManagedProfileProvisioningError("MANAGED_PROFILE_MANAGER_INACTIVE", "Managed profile manager is inactive"); + } + // Operations are narrowed at request time too, but a child the manager could never operate + // is only a dead record, so refuse it at the point of creation. + if (manager.allowedCustomerTypes !== null && !manager.allowedCustomerTypes.includes(input.customerType)) { throw new ManagedProfileProvisioningError( "MANAGED_PROFILE_INVALID_INPUT", - "externalSubjectId must be non-empty and contactEmail must be a valid email address" + "The manager is not allowed to provision this customer type" ); } - return sequelize.transaction(async transaction => { - const manager = await ManagedProfileManager.findByPk(input.managerProfileId, { - lock: Transaction.LOCK.UPDATE, - transaction - }); - if (!manager) { - throw new ManagedProfileProvisioningError("MANAGED_PROFILE_MANAGER_NOT_FOUND", "Managed profile manager not found"); - } - if (!manager.isActive) { - throw new ManagedProfileProvisioningError("MANAGED_PROFILE_MANAGER_INACTIVE", "Managed profile manager is inactive"); - } - // Operations are narrowed at request time too, but a child the manager could never operate - // is only a dead record, so refuse it at the point of creation. - if (manager.allowedCustomerTypes !== null && !manager.allowedCustomerTypes.includes(input.customerType)) { - throw new ManagedProfileProvisioningError( - "MANAGED_PROFILE_INVALID_INPUT", - "The manager is not allowed to provision this customer type" - ); - } + const existing = await ManagedProfile.findOne({ + transaction, + where: { externalSubjectId, managerProfileId: input.managerProfileId } + }); + if (existing) return existingResult(existing, contactEmail, input.customerType, transaction); - const existing = await ManagedProfile.findOne({ - transaction, - where: { externalSubjectId, managerProfileId: input.managerProfileId } - }); - if (existing) return existingResult(existing, contactEmail, input.customerType, transaction); + const existingContactEmail = await ManagedProfile.findOne({ + transaction, + where: { contactEmail, managerProfileId: input.managerProfileId } + }); + if (existingContactEmail) { + throw new ManagedProfileProvisioningError( + "MANAGED_PROFILE_CONFLICT", + "The contact email is already associated with another managed profile" + ); + } - const existingContactEmail = await ManagedProfile.findOne({ - transaction, - where: { contactEmail, managerProfileId: input.managerProfileId } - }); - if (existingContactEmail) { - throw new ManagedProfileProvisioningError( - "MANAGED_PROFILE_CONFLICT", - "The contact email is already associated with another managed profile" - ); - } + const profile = await User.create({ email: null, id: crypto.randomUUID(), kind: "managed" }, { transaction }); + const customerEntity = await CustomerEntity.create( + { profileId: profile.id, status: "active", type: input.customerType }, + { transaction } + ); + await profile.update({ activeCustomerEntityId: customerEntity.id }, { transaction }); + const relationship = await ManagedProfile.create( + { + contactEmail, + creationSource: input.creationSource, + externalSubjectId, + managerProfileId: input.managerProfileId, + profileId: profile.id + }, + { transaction } + ); - const profile = await User.create({ email: null, id: crypto.randomUUID(), kind: "managed" }, { transaction }); - const customerEntity = await CustomerEntity.create( - { profileId: profile.id, status: "active", type: input.customerType }, - { transaction } - ); - await profile.update({ activeCustomerEntityId: customerEntity.id }, { transaction }); - const relationship = await ManagedProfile.create( - { - contactEmail, - creationSource: input.creationSource, - externalSubjectId, - managerProfileId: input.managerProfileId, - profileId: profile.id - }, - { transaction } + return { + contactEmail, + created: true, + creationSource: relationship.creationSource, + customerEntityId: customerEntity.id, + customerType: customerEntity.type, + externalSubjectId: relationship.externalSubjectId, + id: relationship.id, + managerProfileId: relationship.managerProfileId, + profileId: relationship.profileId + }; +} + +export async function provisionManagedProfile( + input: ProvisionManagedProfileInput, + transaction?: Transaction +): Promise { + const externalSubjectId = input.externalSubjectId.trim(); + const contactEmail = input.contactEmail.trim().toLowerCase(); + if (!externalSubjectId || Joi.string().email().max(255).required().validate(contactEmail).error) { + throw new ManagedProfileProvisioningError( + "MANAGED_PROFILE_INVALID_INPUT", + "externalSubjectId must be non-empty and contactEmail must be a valid email address" ); + } - return { - contactEmail, - created: true, - creationSource: relationship.creationSource, - customerEntityId: customerEntity.id, - customerType: customerEntity.type, - externalSubjectId: relationship.externalSubjectId, - id: relationship.id, - managerProfileId: relationship.managerProfileId, - profileId: relationship.profileId - }; - }); + if (transaction) { + return provisionManagedProfileInTransaction(input, externalSubjectId, contactEmail, transaction); + } + return sequelize.transaction(innerTransaction => + provisionManagedProfileInTransaction(input, externalSubjectId, contactEmail, innerTransaction) + ); } 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 cf17876c5..93d3d8a36 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -1,3 +1,4 @@ +import { Transaction, UniqueConstraintError } from "sequelize"; import { type Address, parseAbi } from "viem"; import sequelize from "../../../config/database"; import { config } from "../../../config/vars"; @@ -58,11 +59,14 @@ const factoryRegistryAbi = parseAbi(["function isForwarder(address forwarder) vi /** Pure comparison of the submitted account data against the deployed clone's config. */ export function forwarderConfigMismatch( - expected: { destination: string; fallbackAddress: string; feeBps: number }, - onchain: { destination: string; fallbackAddress: string; feeBps: number; isForwarder: boolean } + expected: { destination: string; factory: string; fallbackAddress: string; feeBps: number }, + onchain: { destination: string; factory: string; fallbackAddress: string; feeBps: number; isForwarder: boolean } ): string | null { + if (onchain.factory.toLowerCase() !== expected.factory.toLowerCase()) { + return `on-chain factory ${onchain.factory} differs from the trusted factory`; + } if (!onchain.isForwarder) { - return "the address is not a clone registered by its factory"; + return "the address is not a clone registered by the trusted factory"; } if (onchain.destination.toLowerCase() !== expected.destination) { return `on-chain destination ${onchain.destination} differs from the submitted value`; @@ -93,9 +97,16 @@ async function verifyForwarderOnChain( if (!config.moneriumB2b.rpcUrl) { return; } + const trustedFactory = config.moneriumB2b.forwarderFactoryAddress; + if (!trustedFactory) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS is not configured" + ); + } const client = getPublicClient(); const address = forwarderAddress as Address; - let onchain: { destination: string; fallbackAddress: string; feeBps: number; isForwarder: boolean }; + let onchain: { destination: string; factory: string; fallbackAddress: string; feeBps: number; isForwarder: boolean }; try { const [onchainDestination, onchainFallback, onchainFeeBps, factory] = await Promise.all([ client.readContract({ abi: forwarderConfigAbi, address, functionName: "destination" }), @@ -105,11 +116,17 @@ async function verifyForwarderOnChain( ]); const isForwarder = await client.readContract({ abi: factoryRegistryAbi, - address: factory, + address: trustedFactory as Address, args: [address], functionName: "isForwarder" }); - onchain = { destination: onchainDestination, fallbackAddress: onchainFallback, feeBps: onchainFeeBps, isForwarder }; + onchain = { + destination: onchainDestination, + factory, + fallbackAddress: onchainFallback, + feeBps: onchainFeeBps, + isForwarder + }; } catch (error) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", @@ -118,7 +135,7 @@ async function verifyForwarderOnChain( }` ); } - const mismatch = forwarderConfigMismatch({ destination, fallbackAddress, feeBps }, onchain); + const mismatch = forwarderConfigMismatch({ destination, factory: trustedFactory, fallbackAddress, feeBps }, onchain); if (mismatch) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", @@ -131,76 +148,74 @@ async function verifyForwarderOnChain( // 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). -async function mirrorApprovedKyb(customerEntityId: string, moneriumProfileId: string): Promise { - await sequelize.transaction(async transaction => { - const boundElsewhere = await ProviderCustomer.findOne({ - transaction, - where: { provider: "monerium", providerCustomerId: moneriumProfileId } - }); - if (boundElsewhere && boundElsewhere.customerEntityId !== customerEntityId) { - throw new MoneriumB2bProvisioningError( - "MONERIUM_B2B_ACCOUNT_CONFLICT", - "The Monerium profile is already bound to a different customer" - ); - } +async function mirrorApprovedKyb(customerEntityId: string, moneriumProfileId: string, transaction: Transaction): Promise { + const boundElsewhere = await ProviderCustomer.findOne({ + transaction, + where: { provider: "monerium", providerCustomerId: moneriumProfileId } + }); + if (boundElsewhere && boundElsewhere.customerEntityId !== customerEntityId) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The Monerium profile is already bound to a different customer" + ); + } - const [customer] = await ProviderCustomer.findOrCreate({ - defaults: { - customerEntityId, - customerType: "business", - provider: "monerium", - providerCustomerId: moneriumProfileId, - rail: "eur", - status: VerificationStatus.Approved, - statusExternal: "approved" - }, - transaction, - where: { customerEntityId, customerType: "business", provider: "monerium", rail: "eur" } - }); - if (customer.providerCustomerId && customer.providerCustomerId !== moneriumProfileId) { - throw new MoneriumB2bProvisioningError( - "MONERIUM_B2B_ACCOUNT_CONFLICT", - "The customer entity is already bound to a different Monerium profile" - ); - } - if (customer.providerCustomerId !== moneriumProfileId || customer.status !== VerificationStatus.Approved) { - await customer.update( - { providerCustomerId: moneriumProfileId, status: VerificationStatus.Approved, statusExternal: "approved" }, - { transaction } - ); - } + const [customer] = await ProviderCustomer.findOrCreate({ + defaults: { + customerEntityId, + customerType: "business", + provider: "monerium", + providerCustomerId: moneriumProfileId, + rail: "eur", + status: VerificationStatus.Approved, + statusExternal: "approved" + }, + transaction, + where: { customerEntityId, customerType: "business", provider: "monerium", rail: "eur" } + }); + if (customer.providerCustomerId && customer.providerCustomerId !== moneriumProfileId) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The customer entity is already bound to a different Monerium profile" + ); + } + if (customer.providerCustomerId !== moneriumProfileId || customer.status !== VerificationStatus.Approved) { + await customer.update( + { providerCustomerId: moneriumProfileId, status: VerificationStatus.Approved, statusExternal: "approved" }, + { transaction } + ); + } - const existingCase = await KycCase.findOne({ transaction, where: { providerCustomerId: customer.id } }); - if (existingCase) { - if (existingCase.status !== VerificationStatus.Approved) { - await existingCase.update( - { - approvedAt: existingCase.approvedAt ?? new Date(), - providerCaseId: moneriumProfileId, - rejectedAt: null, - status: VerificationStatus.Approved, - statusExternal: "approved" - }, - { transaction } - ); - } - } else { - await KycCase.create( + const existingCase = await KycCase.findOne({ transaction, where: { providerCustomerId: customer.id } }); + if (existingCase) { + if (existingCase.status !== VerificationStatus.Approved) { + await existingCase.update( { - approvedAt: new Date(), - customerEntityId, - provider: "monerium", + approvedAt: existingCase.approvedAt ?? new Date(), providerCaseId: moneriumProfileId, - providerCustomerId: customer.id, + rejectedAt: null, status: VerificationStatus.Approved, - statusExternal: "approved", - submittedAt: new Date(), - type: "kyb" + statusExternal: "approved" }, { transaction } ); } - }); + } else { + await KycCase.create( + { + approvedAt: new Date(), + customerEntityId, + provider: "monerium", + providerCaseId: moneriumProfileId, + providerCustomerId: customer.id, + status: VerificationStatus.Approved, + statusExternal: "approved", + submittedAt: new Date(), + type: "kyb" + }, + { transaction } + ); + } } function accountMatchesInput( @@ -246,68 +261,82 @@ export async function provisionMoneriumB2bAccount( // account whose config the monitors later legitimize. await verifyForwarderOnChain(forwarderAddress, destination, fallbackAddress, feeBps); - // The pilot reliance scope is KYB'd corporates only, so the child is always a - // business entity. Errors (inactive manager, subject/email conflicts) propagate - // as ManagedProfileProvisioningError for the controller to map. - const managedProfile: ProvisionManagedProfileResult = await provisionManagedProfile({ - contactEmail: input.contactEmail, - creationSource: "vortex", - customerType: "business", - externalSubjectId: input.externalSubjectId, - managerProfileId: input.managerProfileId - }); + 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 + // account conflict cannot leave an orphaned approved identity behind. + const managedProfile = await provisionManagedProfile( + { + contactEmail: input.contactEmail, + creationSource: "vortex", + customerType: "business", + externalSubjectId: input.externalSubjectId, + managerProfileId: input.managerProfileId + }, + transaction + ); + + await mirrorApprovedKyb(managedProfile.customerEntityId, moneriumProfileId, transaction); - await mirrorApprovedKyb(managedProfile.customerEntityId, moneriumProfileId); + const existing = await MoneriumAccount.findOne({ transaction, where: { profileId: moneriumProfileId } }); + if (existing) { + if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, fallbackAddress, feeBps)) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The Monerium profile is already mapped with different account data" + ); + } + if (existing.vortexProfileId === null) { + await existing.update({ vortexProfileId: managedProfile.profileId }, { transaction }); + } + return { account: { created: false, row: existing }, managedProfile }; + } - const account = await sequelize.transaction(async transaction => { - const existing = await MoneriumAccount.findOne({ transaction, where: { profileId: moneriumProfileId } }); - if (existing) { - if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, fallbackAddress, feeBps)) { + const boundToProfile = await MoneriumAccount.findOne({ + transaction, + where: { vortexProfileId: managedProfile.profileId } + }); + if (boundToProfile) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", - "The Monerium profile is already mapped with different account data" + "The managed profile already has a Monerium account for a different Monerium profile" ); } - // Adopt a pre-mapping row that was inserted by hand before the managed - // profile linkage existed. - if (existing.vortexProfileId === null) { - await existing.update({ vortexProfileId: managedProfile.profileId }, { transaction }); + const forwarderTaken = await MoneriumAccount.findOne({ transaction, where: { forwarderAddress } }); + if (forwarderTaken) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The forwarder address is already bound to another account" + ); } - return { created: false, row: existing }; - } - const boundToProfile = await MoneriumAccount.findOne({ - transaction, - where: { vortexProfileId: managedProfile.profileId } - }); - if (boundToProfile) { - throw new MoneriumB2bProvisioningError( - "MONERIUM_B2B_ACCOUNT_CONFLICT", - "The managed profile already has a Monerium account for a different Monerium profile" + const row = await MoneriumAccount.create( + { + destination, + fallbackAddress, + feeBps, + forwarderAddress, + profileId: moneriumProfileId, + status: MoneriumAccountStatus.Onboarding, + vortexProfileId: managedProfile.profileId + }, + { transaction } ); - } - const forwarderTaken = await MoneriumAccount.findOne({ transaction, where: { forwarderAddress } }); - if (forwarderTaken) { + return { account: { created: true, row }, managedProfile }; + }); + } catch (error) { + if (error instanceof UniqueConstraintError) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", - "The forwarder address is already bound to another account" + "The Monerium account mapping conflicts with an existing record" ); } + throw error; + } - const row = await MoneriumAccount.create( - { - destination, - fallbackAddress, - feeBps, - forwarderAddress, - profileId: moneriumProfileId, - status: MoneriumAccountStatus.Onboarding, - vortexProfileId: managedProfile.profileId - }, - { transaction } - ); - return { created: true, row }; - }); + const { account, managedProfile } = result; return { accountId: account.row.id, diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index 21024c2df..e59eb8467 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -1,3 +1,4 @@ +import type { MoneriumChain } from "@vortexfi/shared"; import { Account, Address, @@ -27,6 +28,15 @@ import { config } from "../../../config/vars"; /** Suggested private-orderflow endpoint for mainnet (MONERIUM_B2B_PRIVATE_RPC_URL). */ export const DEFAULT_PRIVATE_RPC_URL = "https://rpc.flashbots.net"; +const MONERIUM_CHAIN_NAMES: Record = { + 1: "ethereum", + 11155111: "sepolia" +}; + +export function moneriumChainForChainId(chainId: number): MoneriumChain | null { + return MONERIUM_CHAIN_NAMES[chainId] ?? null; +} + /** * Client notification confirmation depth in blocks — registry P9 * (docs/adr-0005-monerium-b2b-onramp.md). Not consumed by the keeper itself diff --git a/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts new file mode 100644 index 000000000..5257a2b2a --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts @@ -0,0 +1,75 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumChainCursor from "../../../models/moneriumChainCursor.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { reconcileConfirmedExecutionAllocations } from "./conversion-executor"; + +describe("confirmed Monerium conversion allocation", () => { + beforeAll(setupTestDatabase); + beforeEach(resetTestDatabase); + + it("waits for the mint cursor and uses the swap log as the exact snapshot boundary", async () => { + const account = await MoneriumAccount.create({ + destination: "0x2222222222222222222222222222222222222222", + fallbackAddress: "0x3333333333333333333333333333333333333333", + feeBps: 0, + forwarderAddress: "0x1111111111111111111111111111111111111111", + profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 100, + destination: account.destination, + eureInRaw: "60000000000000000000", + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 10, + txHash: "0xswap", + usdcNetRaw: "64800000" + }); + const included = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw: "60000000000000000000", + blockNumber: 100, + chainId: 1, + currency: "eur", + logIndex: 9, + moneriumOrderId: "included-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint-before" + }); + await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw: "10000000000000000000", + blockNumber: 100, + chainId: 1, + currency: "eur", + logIndex: 11, + moneriumOrderId: "later-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint-after" + }); + const cursor = await MoneriumChainCursor.create({ lastBlock: "99", name: "eure-mints:1" }); + const deps = { getChainId: async () => 1 }; + + expect(await reconcileConfirmedExecutionAllocations(deps)).toBe(0); + expect(await MoneriumDepositAllocation.count()).toBe(0); + + await cursor.update({ lastBlock: "100" }); + expect(await reconcileConfirmedExecutionAllocations(deps)).toBe(1); + expect(await reconcileConfirmedExecutionAllocations(deps)).toBe(0); + + const allocations = await MoneriumDepositAllocation.findAll(); + expect(allocations).toHaveLength(1); + expect(allocations[0]).toMatchObject({ + depositId: included.id, + eureInRaw: execution.eureInRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw + }); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts index 83d061918..514c97c84 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { FindOptions, Transaction } from "sequelize"; +import { encodeFunctionData } from "viem"; import sequelize from "../../../config/database"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { @@ -8,10 +9,15 @@ import MoneriumConversionExecution, { import { AllocatableDeposit, allocateUsdcProRata, + broadcastSwapSequence, classifyHashlessPending, + conversionAmountsFromSwapEvent, + isExpectedSwapTransaction, + recoveryBlockRanges, runConversionExecutor, selectDepositsForExecution } from "./conversion-executor"; +import { forwarderAbi } from "./chain"; // R04 attribution (docs/architecture-monerium-b2b-onramp.md §3): pro-rata by // amount_raw against eureInRaw, floor division, remainder to the largest deposit. @@ -30,27 +36,22 @@ describe("selectDepositsForExecution", () => { expect(selectDepositsForExecution(deposits, 150n * EUR)).toEqual(deposits); }); - it("stops before a deposit that would exceed the per-swap cap cut", () => { + it("splits a deposit at the per-swap cap cut", () => { const deposits = [deposit("a", 50n * EUR), deposit("b", 30n * EUR)]; - // eureIn = 60: the 30-EUR deposit would push cumulative to 80 — it waits for the - // next execution instead of being over-attributed to this one. - expect(selectDepositsForExecution(deposits, 60n * EUR)).toEqual([deposits[0]]); + expect(selectDepositsForExecution(deposits, 60n * EUR)).toEqual([ + deposits[0], + deposit("b", 10n * EUR) + ]); }); - it("selects an oversized oldest deposit so it can never wedge attribution", () => { - // A deposit larger than perSwapCap can never fit any execution (eureIn only - // shrinks as the balance drains); it must attach to the execution that starts - // converting it instead of blocking itself and every deposit behind it forever. - expect(selectDepositsForExecution([deposit("a", 100n * EUR)], 60n * EUR)).toEqual([deposit("a", 100n * EUR)]); + it("allocates only the converted portion of an oversized deposit", () => { + expect(selectDepositsForExecution([deposit("a", 100n * EUR)], 60n * EUR)).toEqual([deposit("a", 60n * EUR)]); }); - it("does not head-of-line block younger deposits behind an oversized one", () => { - const oversized = deposit("big", 120n * EUR); + it("allocates a remaining deposit portion before younger deposits", () => { + const outstanding = deposit("big", 20n * EUR); const younger = deposit("small", 5n * EUR); - // Execution 1 (eureIn = cap): only the oversized deposit attaches. - expect(selectDepositsForExecution([oversized, younger], 100n * EUR)).toEqual([oversized]); - // Execution 2 (remaining balance): the younger deposit gets its own allocation. - expect(selectDepositsForExecution([younger], 25n * EUR)).toEqual([younger]); + expect(selectDepositsForExecution([outstanding, younger], 25n * EUR)).toEqual([outstanding, younger]); }); it("handles an exact fit and an empty list", () => { @@ -113,55 +114,163 @@ describe("allocateUsdcProRata", () => { }); it("clamps an oversized sole deposit to the swapped amount and conserves the total", () => { - // 120 EUR deposit, execution swapped only 100 EUR (perSwapCap): the deposit's - // share is the full execution output, never an overshoot of it. - const shares = allocateUsdcProRata([deposit("big", 120n * EUR)], 100n * EUR, 108n * USDC); + const shares = allocateUsdcProRata([deposit("big", 100n * EUR)], 100n * EUR, 108n * USDC); expect(shares.get("big")).toBe(108n * USDC); }); + + it("does not assign output for an unindexed portion of an execution", () => { + const shares = allocateUsdcProRata([deposit("known", 60n * EUR)], 100n * EUR, 100n * USDC); + expect(shares.get("known")).toBe(60n * USDC); + }); +}); + +describe("conversionAmountsFromSwapEvent", () => { + it("excludes unsolicited USDC swept alongside this swap", () => { + expect( + conversionAmountsFromSwapEvent({ fee: 8n * USDC, forwarded: 208n * USDC, usdcOut: 108n * USDC }) + ).toEqual({ feeRaw: "8000000", usdcGrossRaw: "108000000", usdcNetRaw: "100000000" }); + }); + + it("refuses an impossible event whose fee exceeds this swap's output", () => { + expect(() => conversionAmountsFromSwapEvent({ fee: 2n, forwarded: 0n, usdcOut: 1n })).toThrow("fee exceeds"); + }); }); describe("classifyHashlessPending", () => { it("fails a row whose send phase was never reached (no persisted nonce)", () => { expect( - classifyHashlessPending({ latestNonceCount: 0, nonce: null, pendingNonceCount: 0, unclaimedSwapTxHashes: [] }) + classifyHashlessPending({ latestNonceCount: 0, matchingSwapTxHashes: [], nonce: null, scanComplete: true }) ).toEqual({ kind: "fail", reason: "crashed before the transaction was sent" }); }); it("adopts the unclaimed SwapExecuted hash when the nonce was consumed", () => { expect( - classifyHashlessPending({ latestNonceCount: 8, nonce: 7, pendingNonceCount: 8, unclaimedSwapTxHashes: ["0xlost"] }) + classifyHashlessPending({ latestNonceCount: 8, matchingSwapTxHashes: ["0xlost"], nonce: 7, scanComplete: true }) ).toEqual({ kind: "adopt", txHash: "0xlost" }); }); it("fails a consumed nonce with no SwapExecuted (reverted or replaced)", () => { const result = classifyHashlessPending({ latestNonceCount: 8, + matchingSwapTxHashes: [], nonce: 7, - pendingNonceCount: 8, - unclaimedSwapTxHashes: [] + scanComplete: true }); expect(result.kind).toBe("fail"); }); it("waits while the broadcast may still be in the mempool", () => { expect( - classifyHashlessPending({ latestNonceCount: 7, nonce: 7, pendingNonceCount: 8, unclaimedSwapTxHashes: [] }) - ).toEqual({ kind: "in-flight" }); + classifyHashlessPending({ latestNonceCount: 7, matchingSwapTxHashes: [], nonce: 7, scanComplete: true }) + ).toEqual({ kind: "in-flight", reason: "the persisted nonce has not been consumed" }); }); - it("fails when the nonce was persisted but the broadcast never reached the mempool", () => { + it("remains fail-closed when a persisted nonce is not visible in the mempool", () => { const result = classifyHashlessPending({ latestNonceCount: 7, + matchingSwapTxHashes: [], nonce: 7, - pendingNonceCount: 7, - unclaimedSwapTxHashes: [] + scanComplete: true }); - expect(result).toEqual({ kind: "fail", reason: "broadcast never reached the mempool" }); + expect(result.kind).toBe("in-flight"); + }); + + it("remains pending when recovery is incomplete or ambiguous", () => { + expect( + classifyHashlessPending({ latestNonceCount: 8, matchingSwapTxHashes: [], nonce: 7, scanComplete: false }).kind + ).toBe("in-flight"); + expect( + classifyHashlessPending({ + latestNonceCount: 8, + matchingSwapTxHashes: ["0xone", "0xtwo"], + nonce: 7, + scanComplete: true + }).kind + ).toBe("in-flight"); + }); +}); + +describe("isExpectedSwapTransaction", () => { + const keeper = "0x1111111111111111111111111111111111111111"; + const forwarder = "0x2222222222222222222222222222222222222222"; + const expected = { + from: keeper, + input: encodeFunctionData({ abi: forwarderAbi, functionName: "swapAndForward" }), + nonce: 7, + to: forwarder + }; + + it("requires the exact keeper, nonce, forwarder, and no-arg calldata", () => { + expect(isExpectedSwapTransaction(expected, keeper, forwarder, 7)).toBe(true); + expect(isExpectedSwapTransaction({ ...expected, from: forwarder }, keeper, forwarder, 7)).toBe(false); + expect(isExpectedSwapTransaction({ ...expected, nonce: 8 }, keeper, forwarder, 7)).toBe(false); + expect(isExpectedSwapTransaction({ ...expected, to: keeper }, keeper, forwarder, 7)).toBe(false); + expect(isExpectedSwapTransaction({ ...expected, input: "0x" }, keeper, forwarder, 7)).toBe(false); + }); +}); + +describe("recoveryBlockRanges", () => { + it("covers long recovery intervals with bounded inclusive pages", () => { + expect(recoveryBlockRanges(1n, 4500n)).toEqual([ + { fromBlock: 1n, toBlock: 2000n }, + { fromBlock: 2001n, toBlock: 4000n }, + { fromBlock: 4001n, toBlock: 4500n } + ]); + expect(recoveryBlockRanges(10n, 9n)).toEqual([]); + }); +}); + +describe("broadcastSwapSequence", () => { + it("never reserves or sends a swap when the preceding poke fails", async () => { + const actions: string[] = []; + await expect( + broadcastSwapSequence({ + broadcastBlockNumber: 100, + pendingNonce: 7, + pokeNeeded: true, + reserveSwap: async () => { + actions.push("reserve"); + return true; + }, + sendPoke: async nonce => { + actions.push(`poke:${nonce}`); + throw new Error("poke rejected"); + }, + sendSwap: async nonce => { + actions.push(`swap:${nonce}`); + return "0xswap"; + } + }) + ).rejects.toThrow("poke rejected"); + expect(actions).toEqual(["poke:7"]); + }); + + it("durably reserves the exact swap nonce after poke and before broadcast", async () => { + const actions: string[] = []; + const hash = await broadcastSwapSequence({ + broadcastBlockNumber: 100, + pendingNonce: 7, + pokeNeeded: true, + reserveSwap: async (nonce, blockNumber) => { + actions.push(`reserve:${nonce}:${blockNumber}`); + return true; + }, + sendPoke: async nonce => { + actions.push(`poke:${nonce}`); + }, + sendSwap: async nonce => { + actions.push(`swap:${nonce}`); + return "0xswap"; + } + }); + + expect(hash).toBe("0xswap"); + expect(actions).toEqual(["poke:7", "reserve:8:100", "swap:8"]); }); }); describe("runConversionExecutor recovery ordering", () => { - it("resolves an existing pending execution before a closed account can return", async () => { + it("does not expire another executor's fresh pre-send reservation", async () => { const originalTransaction = sequelize.transaction; const originalQuery = sequelize.query; const originalFindAccount = MoneriumAccount.findByPk; @@ -207,8 +316,8 @@ describe("runConversionExecutor recovery ordering", () => { await runConversionExecutor(account.id); - expect(pending.status).toBe(MoneriumConversionExecutionStatus.Failed); - expect(pending.error).toBe("crashed before the transaction was sent"); + expect(pending.status).toBe(MoneriumConversionExecutionStatus.Pending); + expect(pending.nonce).toBeNull(); } finally { sequelize.transaction = originalTransaction; sequelize.query = originalQuery; diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index fbea9b29b..94b30ef80 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1,16 +1,20 @@ import { Op, Transaction } from "sequelize"; -import { Address, parseEventLogs, TransactionReceipt, TransactionReceiptNotFoundError } from "viem"; +import { Address, encodeFunctionData, Hex, parseEventLogs, TransactionReceipt, TransactionReceiptNotFoundError } from "viem"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumChainCursor from "../../../models/moneriumChainCursor.model"; import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import { erc20Abi, factoryAbi, forwarderAbi, + getChainId, getForwarderImmutables, getKeeperWalletClient, getPublicClient, @@ -22,7 +26,8 @@ import { withForwarderLock } from "./deposit-processor"; * Per-account conversion executor (plan §3, "Keeper" + "Attribution (R04)"): * balance >= minSwapAmount -> poke() (stranding marker, R03) + swapAndForward() via the * private submission transport, with an execution record created and committed BEFORE - * anything is sent, then snapshot-based deposit attribution on confirmation. + * anything is sent. Snapshot-based deposit attribution is deferred until the mint + * cursor covers the confirmed swap's exact block/log boundary. * * Serialization: every database mutation runs inside the per-forwarder advisory lock * (withForwarderLock). The chain send/wait itself deliberately happens OUTSIDE a lock — @@ -37,18 +42,18 @@ import { withForwarderLock } from "./deposit-processor"; const RETRY_BASE_MS = 60_000; const RETRY_MAX_MS = 60 * 60_000; -/** A pending execution with a tx hash but no receipt after this long is declared failed. */ -const PENDING_TX_STALE_MS = 15 * 60_000; - /** How long one cycle waits for the swap receipt before deferring to the next cycle. */ const RECEIPT_TIMEOUT_MS = 3 * 60_000; /** - * Blocks scanned backwards when recovering a broadcast whose hash was never persisted - * (~6h at 12s blocks — far beyond any realistic crash-to-restart gap; the scan only - * runs for rows whose nonce is already consumed on-chain). + * A nonce-less pending row is a live pre-send reservation until this deadline. The + * executor compare-and-sets the row before broadcasting, so a stalled owner cannot + * resume and send after another process expires the reservation. */ -const HASHLESS_RECOVERY_LOOKBACK_BLOCKS = 1800n; +const PRE_SEND_RESERVATION_MS = 5 * 60_000; + +/** Keep recovery log requests below common RPC block-range limits. */ +const RECOVERY_LOG_BLOCK_RANGE = 2000n; /** * Serializes nonce derivation and the broadcasts that consume it across every process @@ -66,6 +71,44 @@ async function withKeeperSendLock(fn: () => Promise): Promise { }); } +interface SwapBroadcastSequence { + broadcastBlockNumber: number; + pendingNonce: number; + pokeNeeded: boolean; + reserveSwap(nonce: number, broadcastBlockNumber: number): Promise; + sendPoke(nonce: number): Promise; + sendSwap(nonce: number): Promise; +} + +/** Safety-critical ordering: harmless poke, durable swap identity, value-moving send. */ +export async function broadcastSwapSequence(input: SwapBroadcastSequence): Promise { + let swapNonce = input.pendingNonce; + if (input.pokeNeeded) { + await input.sendPoke(swapNonce); + swapNonce += 1; + } + if (!(await input.reserveSwap(swapNonce, input.broadcastBlockNumber))) { + throw new Error("execution lost its pre-send reservation"); + } + return input.sendSwap(swapNonce); +} + +/** Maps SwapExecuted into accounting values; `forwarded` may include pre-existing USDC. */ +export function conversionAmountsFromSwapEvent(event: { fee: bigint; forwarded: bigint; usdcOut: bigint }): { + feeRaw: string; + usdcGrossRaw: string; + usdcNetRaw: string; +} { + if (event.fee > event.usdcOut) { + throw new Error("SwapExecuted fee exceeds this swap's USDC output"); + } + return { + feeRaw: event.fee.toString(), + usdcGrossRaw: event.usdcOut.toString(), + usdcNetRaw: (event.usdcOut - event.fee).toString() + }; +} + // ------------------------------------------------------------------ R04 allocation math export interface AllocatableDeposit { @@ -74,27 +117,19 @@ export interface AllocatableDeposit { } /** - * Snapshot selection honoring the per-swap cap: deposits are taken oldest-mint-first - * until the next one would push the cumulative amount past eureInRaw (a cap-cut deposit - * stays unallocated and joins the next execution). One exception: an OVERSIZED oldest - * deposit — alone larger than the swapped amount — is selected anyway, because eureIn - * is capped at perSwapCap and only shrinks as the balance drains, so such a deposit - * could never fit a later execution and would permanently block attribution for every - * deposit behind it. It is attributed to the execution that begins converting it. - * Callers pass only unallocated minted deposits with mint block <= execution block (R04). + * Allocates an execution across oldest outstanding deposit balances. A cap-cut deposit + * is split: its remainder remains available for the next execution. This is what makes + * both one-execution-to-many-deposits and one-deposit-to-many-executions representable. */ export function selectDepositsForExecution(deposits: AllocatableDeposit[], eureInRaw: bigint): AllocatableDeposit[] { const selected: AllocatableDeposit[] = []; - let cumulative = 0n; + let remaining = eureInRaw; for (const deposit of deposits) { - if (cumulative + deposit.amountRaw > eureInRaw) { - if (selected.length === 0) { - selected.push(deposit); - } - break; - } - cumulative += deposit.amountRaw; - selected.push(deposit); + if (remaining <= 0n) break; + const amountRaw = deposit.amountRaw > remaining ? remaining : deposit.amountRaw; + if (amountRaw <= 0n) continue; + selected.push({ amountRaw, id: deposit.id }); + remaining -= amountRaw; } return selected; } @@ -102,10 +137,10 @@ export function selectDepositsForExecution(deposits: AllocatableDeposit[], eureI /** * R04 pro-rata attribution of the execution's net USDC: each deposit gets * floor(usdcNetRaw * effectiveAmount / eureInRaw), where effectiveAmount is the - * deposit's amount clamped to the EURe this execution actually swapped (only an - * oversized sole deposit ever clamps); the remainder (floor dust, plus any value from - * inflows not represented in the selection) goes to the largest deposit (ties: the - * earliest). Sum of shares always equals usdcNetRaw for a non-empty selection. + * allocated EURe amount / eureInRaw. When allocations cover the execution exactly, + * floor dust goes to the largest allocation (ties: earliest). If indexed deposits do + * not cover the execution, unknown value remains unattributed instead of inflating a + * known customer's share. */ export function allocateUsdcProRata( deposits: AllocatableDeposit[], @@ -117,21 +152,18 @@ export function allocateUsdcProRata( return shares; } let allocated = 0n; - let cumulative = 0n; let largest = deposits[0]; for (const deposit of deposits) { - const remaining = eureInRaw > cumulative ? eureInRaw - cumulative : 0n; - const effectiveAmount = deposit.amountRaw > remaining ? remaining : deposit.amountRaw; - cumulative += effectiveAmount; - const share = (usdcNetRaw * effectiveAmount) / eureInRaw; + const share = (usdcNetRaw * deposit.amountRaw) / eureInRaw; shares.set(deposit.id, share); allocated += share; if (deposit.amountRaw > largest.amountRaw) { largest = deposit; } } + const coveredEure = deposits.reduce((sum, deposit) => sum + deposit.amountRaw, 0n); const remainder = usdcNetRaw - allocated; - if (remainder > 0n) { + if (coveredEure === eureInRaw && remainder > 0n) { shares.set(largest.id, (shares.get(largest.id) as bigint) + remainder); } return shares; @@ -143,13 +175,13 @@ function errorText(error: unknown): string { return (error instanceof Error ? error.message : String(error)).slice(0, 500); } -async function allocateDeposits(execution: MoneriumConversionExecution, transaction: Transaction): Promise { - if (execution.blockNumber === null) { - return; +async function allocateDeposits(execution: MoneriumConversionExecution, transaction: Transaction): Promise { + if (execution.blockNumber === null || execution.swapLogIndex === null) { + return 0; } - // R04 snapshot: unallocated minted deposits with mint block <= execution block, - // oldest mint first. Unattributed inflow rows participate: their EURe was part of the - // swapped balance, and linking them marks the inflow as consumed by this execution. + // R04 snapshot: outstanding portions of minted deposits before the execution's exact + // block/log position, oldest mint first. Unattributed inflows participate because + // their EURe was part of the swapped balance, but never surface as customer claims. const deposits = await MoneriumFiatDeposit.findAll({ order: [ ["block_number", "ASC"], @@ -158,32 +190,105 @@ async function allocateDeposits(execution: MoneriumConversionExecution, transact transaction, where: { accountId: execution.accountId, - allocatedExecutionId: null, - blockNumber: { [Op.lte]: execution.blockNumber }, + [Op.or]: [ + { blockNumber: { [Op.lt]: execution.blockNumber } }, + { blockNumber: execution.blockNumber, logIndex: { [Op.lt]: execution.swapLogIndex } } + ], status: MoneriumFiatDepositStatus.Minted } }); + const existingAllocations = deposits.length + ? await MoneriumDepositAllocation.findAll({ transaction, where: { depositId: deposits.map(deposit => deposit.id) } }) + : []; + const allocatedByDeposit = new Map(); + for (const allocation of existingAllocations) { + allocatedByDeposit.set( + allocation.depositId, + (allocatedByDeposit.get(allocation.depositId) ?? 0n) + BigInt(allocation.eureInRaw) + ); + } const eureInRaw = BigInt(execution.eureInRaw); const selected = selectDepositsForExecution( - deposits.map(deposit => ({ amountRaw: BigInt(deposit.amountRaw), id: deposit.id })), + deposits + .map(deposit => ({ + amountRaw: BigInt(deposit.amountRaw) - (allocatedByDeposit.get(deposit.id) ?? 0n), + id: deposit.id + })) + .filter(deposit => deposit.amountRaw > 0n), eureInRaw ); if (selected.length === 0) { - return; + return 0; } const shares = allocateUsdcProRata(selected, eureInRaw, BigInt(execution.usdcNetRaw ?? "0")); - const selectedIds = selected.map(deposit => deposit.id); - await MoneriumFiatDeposit.update( - { allocatedExecutionId: execution.id }, - { transaction, where: { id: { [Op.in]: selectedIds } } } + await MoneriumDepositAllocation.bulkCreate( + selected.map(deposit => ({ + depositId: deposit.id, + eureInRaw: deposit.amountRaw.toString(), + executionId: execution.id, + usdcNetRaw: (shares.get(deposit.id) ?? 0n).toString() + })), + { transaction } ); + const coveredEure = selected.reduce((sum, deposit) => sum + deposit.amountRaw, 0n); + if (coveredEure !== eureInRaw) { + logger.error( + `monerium-b2b: execution ${execution.id} converted ${eureInRaw.toString()} raw EURe but only ` + + `${coveredEure.toString()} was covered by indexed deposit allocations` + ); + } logger.info( - `monerium-b2b: execution ${execution.id} allocated ${selectedIds.length} deposit(s): ` + - [...shares.entries()].map(([id, share]) => `${id}=${share.toString()}`).join(", ") + `monerium-b2b: execution ${execution.id} allocated ${selected.length} deposit portion(s): ` + + selected + .map(deposit => `${deposit.id}:eure=${deposit.amountRaw.toString()},usdc=${(shares.get(deposit.id) ?? 0n).toString()}`) + .join(", ") ); + return selected.length; } -/** Applies a mined receipt to a pending execution: confirmed + event amounts + R04 allocation, or failed on revert. */ +/** + * Allocates confirmed swaps only after the mint cursor has scanned through their + * block. This closes the normal head-lag race and also includes a mint that landed + * between the executor's balance read and the swap transaction. + */ +export async function reconcileConfirmedExecutionAllocations( + deps: { getChainId(): Promise } = { getChainId } +): Promise { + const chainId = await deps.getChainId(); + const cursor = await MoneriumChainCursor.findByPk(`eure-mints:${chainId}`); + if (!cursor) return 0; + + const executions = await MoneriumConversionExecution.findAll({ + order: [ + ["block_number", "ASC"], + ["swap_log_index", "ASC"] + ], + where: { + blockNumber: { [Op.lte]: Number(cursor.lastBlock) }, + id: { [Op.notIn]: sequelize.literal("(SELECT execution_id FROM monerium_deposit_allocations)") }, + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: { [Op.ne]: null } + } + }); + let allocated = 0; + for (const execution of executions) { + const account = await MoneriumAccount.findByPk(execution.accountId); + if (!account) continue; + allocated += await withForwarderLock(account.forwarderAddress, async transaction => { + if (await MoneriumDepositAllocation.count({ transaction, where: { executionId: execution.id } })) { + return 0; + } + const current = await MoneriumConversionExecution.findByPk(execution.id, { transaction }); + if (!current || current.status !== MoneriumConversionExecutionStatus.Confirmed) { + return 0; + } + return allocateDeposits(current, transaction); + }); + } + return allocated; +} + +/** Applies a mined receipt to a pending execution: confirmed + event amounts, or failed on revert. */ async function finalizeExecution( execution: MoneriumConversionExecution, receipt: TransactionReceipt, @@ -216,22 +321,22 @@ async function finalizeExecution( ); return; } - const { eureIn, usdcOut, fee, forwarded } = swapEvents[0].args; + const swapEvent = swapEvents[0]; + const { eureIn } = swapEvent.args; + const conversionAmounts = conversionAmountsFromSwapEvent(swapEvent.args); await execution.update( { blockNumber: Number(receipt.blockNumber), error: null, // The event's amountIn is authoritative (min(balance, cap) at execution time). eureInRaw: eureIn.toString(), - feeRaw: fee.toString(), + ...conversionAmounts, status: MoneriumConversionExecutionStatus.Confirmed, - txHash: receipt.transactionHash, - usdcGrossRaw: usdcOut.toString(), - usdcNetRaw: forwarded.toString() + swapLogIndex: swapEvent.logIndex, + txHash: receipt.transactionHash }, { transaction } ); - await allocateDeposits(execution, transaction); } // ------------------------------------------------------------------ pending resolution + backoff @@ -240,62 +345,125 @@ type PreparationResult = { kind: "proceed"; attempt: number } | { kind: "skip"; export type HashlessPendingClassification = | { kind: "fail"; reason: string } - | { kind: "in-flight" } + | { kind: "in-flight"; reason: string } | { kind: "adopt"; txHash: string }; +export interface RecoveryTransactionIdentity { + from: string; + input: string; + nonce: number; + to: string | null; +} + +const SWAP_AND_FORWARD_CALLDATA = encodeFunctionData({ abi: forwarderAbi, functionName: "swapAndForward" }); + +/** Exact transaction identity required before a lost hash may be adopted. */ +export function isExpectedSwapTransaction( + transaction: RecoveryTransactionIdentity, + keeperAddress: string, + forwarderAddress: string, + nonce: number +): boolean { + return ( + transaction.from.toLowerCase() === keeperAddress.toLowerCase() && + transaction.nonce === nonce && + transaction.to?.toLowerCase() === forwarderAddress.toLowerCase() && + transaction.input.toLowerCase() === SWAP_AND_FORWARD_CALLDATA.toLowerCase() + ); +} + /** * Decides what happened to a pending execution whose tx hash was never persisted (a * crash or DB error between broadcast and the hash update). Inputs are pure chain - * observations: the keeper account's confirmed/pending nonce counts and any - * SwapExecuted transaction hashes on this forwarder not claimed by another execution. + * observations. A nonce that has not been consumed remains uncertain indefinitely; + * once consumed, only one exact sender+nonce+target+calldata match may be adopted. */ export function classifyHashlessPending(input: { nonce: number | null; latestNonceCount: number; - pendingNonceCount: number; - unclaimedSwapTxHashes: string[]; + matchingSwapTxHashes: string[]; + scanComplete: boolean; }): HashlessPendingClassification { if (input.nonce === null) { // The nonce is persisted before any broadcast, so no nonce means the send phase // was never reached — nothing can be in flight. return { kind: "fail", reason: "crashed before the transaction was sent" }; } - if (input.latestNonceCount > input.nonce) { - // The swap nonce was consumed on-chain: either our swap mined (an unclaimed - // SwapExecuted exists — adopt its hash and finalize normally) or the transaction - // reverted/was replaced (no event; the swap did not execute). - const txHash = input.unclaimedSwapTxHashes[0]; - if (txHash) { - return { kind: "adopt", txHash }; - } - return { kind: "fail", reason: "nonce consumed without a SwapExecuted event (swap reverted or replaced)" }; + if (input.latestNonceCount <= input.nonce) { + return { kind: "in-flight", reason: "the persisted nonce has not been consumed" }; } - if (input.pendingNonceCount > input.nonce) { - return { kind: "in-flight" }; + if (!input.scanComplete) { + return { kind: "in-flight", reason: "an exact recovery scan could not be completed" }; } - return { kind: "fail", reason: "broadcast never reached the mempool" }; + if (input.matchingSwapTxHashes.length === 1) { + return { kind: "adopt", txHash: input.matchingSwapTxHashes[0] }; + } + if (input.matchingSwapTxHashes.length > 1) { + return { kind: "in-flight", reason: "multiple exact recovery candidates were found" }; + } + return { kind: "fail", reason: "nonce consumed without the expected swap transaction" }; +} + +/** Inclusive, non-overlapping block ranges for a complete bounded recovery scan. */ +export function recoveryBlockRanges(fromBlock: bigint, toBlock: bigint): Array<{ fromBlock: bigint; toBlock: bigint }> { + const ranges: Array<{ fromBlock: bigint; toBlock: bigint }> = []; + for (let start = fromBlock; start <= toBlock; start += RECOVERY_LOG_BLOCK_RANGE) { + const end = start + RECOVERY_LOG_BLOCK_RANGE - 1n; + ranges.push({ fromBlock: start, toBlock: end < toBlock ? end : toBlock }); + } + return ranges; } -/** SwapExecuted tx hashes on this forwarder (recent blocks) not claimed by any execution row. */ -async function findUnclaimedSwapTxHashes(account: MoneriumAccount, transaction: Transaction): Promise { +/** + * Scans every block since the pre-broadcast head and returns only unclaimed + * SwapExecuted transactions with the exact keeper identity persisted on the row. + */ +async function findMatchingSwapTxHashes( + pending: MoneriumConversionExecution, + account: MoneriumAccount, + transaction: Transaction +): Promise<{ matchingSwapTxHashes: string[]; scanComplete: boolean }> { + if (pending.nonce === null || pending.broadcastBlockNumber === null) { + return { matchingSwapTxHashes: [], scanComplete: false }; + } const client = getPublicClient(); const latestBlock = await client.getBlockNumber(); - const fromBlock = latestBlock > HASHLESS_RECOVERY_LOOKBACK_BLOCKS ? latestBlock - HASHLESS_RECOVERY_LOOKBACK_BLOCKS : 0n; - const logs = await client.getLogs({ - address: account.forwarderAddress as Address, - event: swapExecutedEvent, - fromBlock - }); - if (logs.length === 0) { - return []; + const loggedHashes = new Set(); + for (const range of recoveryBlockRanges(BigInt(pending.broadcastBlockNumber), latestBlock)) { + const logs = await client.getLogs({ + address: account.forwarderAddress as Address, + event: swapExecutedEvent, + ...range + }); + for (const log of logs) { + loggedHashes.add(log.transactionHash.toLowerCase() as Hex); + } + } + if (loggedHashes.size === 0) { + return { matchingSwapTxHashes: [], scanComplete: true }; } const known = await MoneriumConversionExecution.findAll({ attributes: ["txHash"], transaction, - where: { accountId: account.id, txHash: { [Op.ne]: null } } + where: { id: { [Op.ne]: pending.id }, txHash: { [Op.ne]: null } } }); const claimed = new Set(known.map(row => (row.txHash as string).toLowerCase())); - return logs.map(log => log.transactionHash).filter(hash => !claimed.has(hash.toLowerCase())); + const hashes = [...loggedHashes]; + const keeperAddress = getKeeperWalletClient().account.address; + const matchingSwapTxHashes: string[] = []; + let claimedExactMatch = false; + for (const hash of hashes) { + const candidate = await client.getTransaction({ hash }); + if (!isExpectedSwapTransaction(candidate, keeperAddress, account.forwarderAddress, pending.nonce)) { + continue; + } + if (claimed.has(hash.toLowerCase())) { + claimedExactMatch = true; + } else { + matchingSwapTxHashes.push(hash); + } + } + return { matchingSwapTxHashes, scanComplete: !claimedExactMatch }; } /** @@ -309,30 +477,49 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Pending } }); for (const pending of pendings) { - if (!pending.txHash) { - let latestNonceCount = 0; - let pendingNonceCount = 0; - if (pending.nonce !== null) { - const client = getPublicClient(); - const keeperAddress = getKeeperWalletClient().account.address; - [latestNonceCount, pendingNonceCount] = await Promise.all([ - client.getTransactionCount({ address: keeperAddress, blockTag: "latest" }), - client.getTransactionCount({ address: keeperAddress, blockTag: "pending" }) - ]); + const client = getPublicClient(); + if (pending.txHash) { + try { + const receipt = await client.getTransactionReceipt({ hash: pending.txHash as Hex }); + await finalizeExecution(pending, receipt, account.forwarderAddress, transaction); + continue; + } catch (error) { + if (!(error instanceof TransactionReceiptNotFoundError)) { + return { kind: "skip", reason: `receipt lookup failed for ${pending.txHash}: ${errorText(error)}` }; + } } - const unclaimedSwapTxHashes = - pending.nonce !== null && latestNonceCount > pending.nonce ? await findUnclaimedSwapTxHashes(account, transaction) : []; - const classification = classifyHashlessPending({ - latestNonceCount, - nonce: pending.nonce, - pendingNonceCount, - unclaimedSwapTxHashes - }); + } + + if (pending.nonce === null) { + if (pending.txHash) { + return { kind: "skip", reason: `execution ${pending.id} has a hash but no recovery nonce` }; + } + if (Date.now() - pending.createdAt.getTime() < PRE_SEND_RESERVATION_MS) { + return { kind: "skip", reason: `execution ${pending.id} is preparing its transaction` }; + } + const [expired] = await MoneriumConversionExecution.update( + { error: "crashed before the transaction was sent", status: MoneriumConversionExecutionStatus.Failed }, + { + transaction, + where: { id: pending.id, nonce: null, status: MoneriumConversionExecutionStatus.Pending } + } + ); + if (expired === 0) { + return { kind: "skip", reason: `execution ${pending.id} changed while its pre-send reservation was expiring` }; + } + continue; + } + + try { + const keeperAddress = getKeeperWalletClient().account.address; + const latestNonceCount = await client.getTransactionCount({ address: keeperAddress, blockTag: "latest" }); + const recovery = + latestNonceCount > pending.nonce + ? await findMatchingSwapTxHashes(pending, account, transaction) + : { matchingSwapTxHashes: [], scanComplete: true }; + const classification = classifyHashlessPending({ latestNonceCount, nonce: pending.nonce, ...recovery }); if (classification.kind === "in-flight") { - return { - kind: "skip", - reason: `execution ${pending.id} broadcast may still be in the mempool (nonce ${pending.nonce})` - }; + return { kind: "skip", reason: `execution ${pending.id} remains pending: ${classification.reason}` }; } if (classification.kind === "fail") { await pending.update( @@ -342,32 +529,13 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans continue; } logger.warn( - `monerium-b2b: recovered lost tx hash ${classification.txHash} for execution ${pending.id} via nonce ${pending.nonce}` + `monerium-b2b: recovered exact tx hash ${classification.txHash} for execution ${pending.id} via nonce ${pending.nonce}` ); await pending.update({ txHash: classification.txHash }, { transaction }); - // fall through to the receipt path with the adopted hash - } - let receipt: TransactionReceipt | null; - try { - receipt = await getPublicClient().getTransactionReceipt({ hash: pending.txHash as Address }); - } catch (error) { - if (error instanceof TransactionReceiptNotFoundError) { - receipt = null; - } else { - // RPC failure is not evidence of anything — never let it run the stale clock - // toward a false Failed while the swap may have succeeded. - return { kind: "skip", reason: `receipt lookup failed for ${pending.txHash}: ${errorText(error)}` }; - } - } - if (receipt) { + const receipt = await client.getTransactionReceipt({ hash: classification.txHash as Hex }); await finalizeExecution(pending, receipt, account.forwarderAddress, transaction); - } else if (Date.now() - pending.updatedAt.getTime() > PENDING_TX_STALE_MS) { - await pending.update( - { error: "timed out waiting for a receipt", status: MoneriumConversionExecutionStatus.Failed }, - { transaction } - ); - } else { - return { kind: "skip", reason: `execution ${pending.id} still awaiting receipt ${pending.txHash}` }; + } catch (error) { + return { kind: "skip", reason: `recovery lookup failed for execution ${pending.id}: ${errorText(error)}` }; } } @@ -436,6 +604,12 @@ export async function runConversionExecutor(accountId: string): Promise { const client = getPublicClient(); const forwarder = account.forwarderAddress as Address; const { eure, factory } = await getForwarderImmutables(forwarder); + if ( + !config.moneriumB2b.forwarderFactoryAddress || + factory.toLowerCase() !== config.moneriumB2b.forwarderFactoryAddress.toLowerCase() + ) { + throw new Error(`Forwarder ${forwarder} is not bound to the configured trusted factory`); + } const [balance, strandedSince, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([ client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }), @@ -498,30 +672,48 @@ export async function runConversionExecutor(accountId: string): Promise { // Send phase, serialized across processes: explicit nonces because poke + swap go // back-to-back through the private transport, which may not expose a coherent - // pending pool for derivation. The swap nonce is persisted durably BEFORE any - // broadcast so crash recovery can tell "never sent" from "sent, hash lost". + // pending pool for derivation. Poke is harmless and may fail before the value-moving + // send is attempted; persist the swap nonce only after poke succeeds, immediately + // before swapAndForward is broadcast. const txHash = await withKeeperSendLock(async () => { - let nonce = await client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }); - const pokeNonce = pokeNeeded ? nonce++ : null; - await execution.update({ nonce }); - - if (pokeNeeded) { - await keeper.writeContract({ - abi: forwarderAbi, - account: keeper.account, - address: forwarder, - chain: null, - functionName: "poke", - nonce: pokeNonce as number - }); - } - return keeper.writeContract({ - abi: forwarderAbi, - account: keeper.account, - address: forwarder, - chain: null, - functionName: "swapAndForward", - nonce + const [pendingNonce, broadcastBlock] = await Promise.all([ + client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }), + client.getBlockNumber() + ]); + const broadcastBlockNumber = Number(broadcastBlock); + return broadcastSwapSequence({ + broadcastBlockNumber, + pendingNonce, + pokeNeeded, + reserveSwap: async nonce => { + const [reserved] = await MoneriumConversionExecution.update( + { broadcastBlockNumber, nonce }, + { where: { id: execution.id, nonce: null, status: MoneriumConversionExecutionStatus.Pending } } + ); + if (reserved === 1) { + execution.set({ broadcastBlockNumber, nonce }); + } + return reserved === 1; + }, + sendPoke: async nonce => { + await keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "poke", + nonce + }); + }, + sendSwap: nonce => + keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "swapAndForward", + nonce + }) }); }); await execution.update({ txHash }); @@ -533,7 +725,8 @@ export async function runConversionExecutor(accountId: string): Promise { } catch (error) { if (execution.txHash) { // The transaction is (or may be) in flight; leave the row pending — the next - // cycle resolves it via receipt lookup or declares it stale. + // cycle resolves it by receipt or exact nonce-bound recovery. Time alone is + // never evidence that it is safe to send another value-moving transaction. logger.warn(`monerium-b2b: execution ${execution.id} awaiting receipt after error: ${errorText(error)}`); return; } diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts index 05ffedc4b..c8bde0fae 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts @@ -1,5 +1,9 @@ import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; @@ -12,6 +16,9 @@ import { } from "./deposit-processor"; const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; +const PROFILE_ID = "11111111-1111-4111-8111-111111111111"; +const ORDER_ID = "22222222-2222-4222-8222-222222222222"; +const PROCESSOR_DEPS = { getChainId: async () => 11155111 }; describe("forward-only deposit status transitions", () => { it("allows pending to progress to minted, held, or returned", () => { @@ -63,10 +70,20 @@ describe("parseOrderEvent", () => { data: { address: "0x1111111111111111111111111111111111111111", amount: "100.5", + chain: "sepolia", + counterpart: { + identifier: { + address: "0x1111111111111111111111111111111111111111", + chain: "sepolia", + standard: "chain" + } + }, currency: "eur", - id: "order-1", + id: ORDER_ID, kind: "issue", - meta: { txHash: "0xabc" }, + memo: "", + meta: { placedAt: "2026-07-17T00:00:00Z", txHashes: ["0xabc"] }, + profile: PROFILE_ID, state: "processed" }, timestamp: "2026-07-17T00:00:00Z", @@ -76,9 +93,11 @@ describe("parseOrderEvent", () => { it("extracts the issue-order fields", () => { expect(parseOrderEvent(validPayload)).toEqual({ amount: "100.5", + chain: "sepolia", currency: "eur", forwarderAddress: "0x1111111111111111111111111111111111111111", - orderId: "order-1", + orderId: ORDER_ID, + profileId: PROFILE_ID, state: "processed", txHash: "0xabc" }); @@ -99,7 +118,8 @@ describe("parseIbanEvent", () => { data: { address: "0x1111111111111111111111111111111111111111", chain: "ethereum", - iban: "EE08 7224 5745 6244 9516" + iban: "EE08 7224 5745 6244 9516", + profile: PROFILE_ID }, timestamp: "2026-07-17T00:00:00Z", type: "iban.updated" @@ -108,7 +128,9 @@ describe("parseIbanEvent", () => { it("extracts the IBAN and its linked address", () => { expect(parseIbanEvent(validPayload)).toEqual({ address: "0x1111111111111111111111111111111111111111", - iban: "EE08 7224 5745 6244 9516" + chain: "ethereum", + iban: "EE08 7224 5745 6244 9516", + profileId: PROFILE_ID }); }); @@ -137,10 +159,16 @@ describe("order-event inbox processing (end to end)", () => { data: { address: FORWARDER, amount: "100.5", + chain: "sepolia", + counterpart: { + identifier: { address: FORWARDER, chain: "sepolia", standard: "chain" } + }, currency: "eur", - id: "order-1", + id: ORDER_ID, kind: "issue", - meta: {}, + memo: "", + meta: { placedAt: "2026-08-26T00:00:00Z" }, + profile: PROFILE_ID, state, ...overrides }, @@ -155,7 +183,7 @@ describe("order-event inbox processing (end to end)", () => { fallbackAddress: "0x3333333333333333333333333333333333333333", feeBps: 0, forwarderAddress: FORWARDER, - profileId: crypto.randomUUID() + profileId: PROFILE_ID }); } @@ -163,9 +191,9 @@ describe("order-event inbox processing (end to end)", () => { const account = await createAccount(); await MoneriumWebhookEvent.create({ eventId: "evt-1", payload: orderEvent("placed") }); - expect(await processMoneriumWebhookInbox()).toBe(1); + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1); - const created = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: "order-1" } }); + const created = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: ORDER_ID } }); expect(created).toMatchObject({ accountId: account.id, amountRaw: (1005n * 10n ** 17n).toString(), @@ -173,43 +201,298 @@ describe("order-event inbox processing (end to end)", () => { }); // processed advances to minted and records the mint hash from meta. - await MoneriumWebhookEvent.create({ eventId: "evt-2", payload: orderEvent("processed", { meta: { txHash: "0xmint" } }) }); - await processMoneriumWebhookInbox(); + await MoneriumWebhookEvent.create({ + eventId: "evt-2", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); await created?.reload(); expect(created?.status).toBe(MoneriumFiatDepositStatus.Minted); expect(created?.txHash).toBe("0xmint"); // A delayed older state must never regress the row. await MoneriumWebhookEvent.create({ eventId: "evt-3", payload: orderEvent("pending") }); - await processMoneriumWebhookInbox(); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); await created?.reload(); expect(created?.status).toBe(MoneriumFiatDepositStatus.Minted); // Replayed deliveries of the same order never create a second row. await MoneriumWebhookEvent.create({ eventId: "evt-4", payload: orderEvent("processed") }); - await processMoneriumWebhookInbox(); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); expect(await MoneriumFiatDeposit.count()).toBe(1); expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + + await MoneriumWebhookEvent.create({ eventId: "evt-divergent-amount", payload: orderEvent("processed", { amount: "101" }) }); + await MoneriumWebhookEvent.create({ + eventId: "evt-divergent-hash", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xother"] } }) + }); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + await created?.reload(); + expect(created?.amountRaw).toBe((1005n * 10n ** 17n).toString()); + expect(created?.txHash).toBe("0xmint"); }); it("acks order events for unknown forwarders without creating deposits", async () => { await MoneriumWebhookEvent.create({ eventId: "evt-5", payload: orderEvent("placed") }); - expect(await processMoneriumWebhookInbox()).toBe(1); + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1); expect(await MoneriumFiatDeposit.count()).toBe(0); expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); }); - it("holds and releases a compliance-held order without regressions", async () => { + it("adopts an unattributed mint when its matching order webhook arrives late", async () => { + const account = await createAccount(); + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 101, + destination: account.destination, + eureInRaw: (1005n * 10n ** 17n).toString(), + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 4, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const unattributed = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw: (1005n * 10n ** 17n).toString(), + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 3, + moneriumOrderId: "unattr:late-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + const allocation = await MoneriumDepositAllocation.create({ + depositId: unattributed.id, + eureInRaw: unattributed.amountRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw as string + }); + await MoneriumWebhookEvent.create({ + eventId: "evt-late-order", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1); + expect(await MoneriumFiatDeposit.count()).toBe(1); + await unattributed.reload(); + await allocation.reload(); + expect(unattributed).toMatchObject({ + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + logIndex: 3, + moneriumOrderId: ORDER_ID, + txHash: "0xmint" + }); + expect(allocation.depositId).toBe(unattributed.id); + }); + + it("merges an unattributed mint when a tx hash resolves equal-amount order ambiguity", async () => { + const account = await createAccount(); + const otherOrderId = "33333333-3333-4333-8333-333333333333"; + await MoneriumWebhookEvent.bulkCreate([ + { eventId: "evt-ambiguous-order-a", payload: orderEvent("pending") }, + { eventId: "evt-ambiguous-order-b", payload: orderEvent("pending", { id: otherOrderId }) } + ]); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + const providerDeposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: ORDER_ID } }); + const otherDeposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: otherOrderId } }); + expect(providerDeposit).not.toBeNull(); + expect(otherDeposit).not.toBeNull(); + if (!providerDeposit || !otherDeposit) throw new Error("expected both provider deposits"); + + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 101, + destination: account.destination, + eureInRaw: (1005n * 10n ** 17n).toString(), + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 4, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const unattributed = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw: (1005n * 10n ** 17n).toString(), + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 3, + moneriumOrderId: "unattr:ambiguous-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + const allocation = await MoneriumDepositAllocation.create({ + depositId: unattributed.id, + eureInRaw: unattributed.amountRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw as string + }); + await MoneriumWebhookEvent.create({ + eventId: "evt-ambiguous-order-resolved", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + await providerDeposit.reload(); + await otherDeposit.reload(); + await allocation.reload(); + expect(await MoneriumFiatDeposit.count()).toBe(2); + expect(providerDeposit).toMatchObject({ + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + logIndex: 3, + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + expect(otherDeposit).toMatchObject({ blockNumber: null, status: MoneriumFiatDepositStatus.Pending, txHash: null }); + expect(allocation.depositId).toBe(providerDeposit.id); + }); + + it("never merges a quarantined mint into a terminal returned order", async () => { + const account = await createAccount(); + const amountRaw = (1005n * 10n ** 17n).toString(); + const providerDeposit = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw, + currency: "eur", + moneriumOrderId: ORDER_ID, + status: MoneriumFiatDepositStatus.Returned + }); + const unattributed = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw, + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 3, + moneriumOrderId: "unattr:returned-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 101, + destination: account.destination, + eureInRaw: amountRaw, + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 4, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const allocation = await MoneriumDepositAllocation.create({ + depositId: unattributed.id, + eureInRaw: amountRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw as string + }); + await MoneriumWebhookEvent.create({ + eventId: "evt-returned-order-mint", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + await providerDeposit.reload(); + await unattributed.reload(); + await allocation.reload(); + expect(providerDeposit).toMatchObject({ + blockHash: null, + blockNumber: null, + chainId: null, + logIndex: null, + status: MoneriumFiatDepositStatus.Returned, + txHash: null + }); + expect(unattributed.txHash).toBe("0xmint"); + expect(allocation.depositId).toBe(unattributed.id); + }); + + it("does not adopt an unattributed mint for a first-seen returned order", async () => { + const account = await createAccount(); + const amountRaw = (1005n * 10n ** 17n).toString(); + const unattributed = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw, + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 3, + moneriumOrderId: "unattr:first-seen-returned", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 101, + destination: account.destination, + eureInRaw: amountRaw, + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 4, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const allocation = await MoneriumDepositAllocation.create({ + depositId: unattributed.id, + eureInRaw: amountRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw as string + }); + await MoneriumWebhookEvent.create({ + eventId: "evt-first-seen-returned", + payload: orderEvent("rejected", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + const providerDeposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: ORDER_ID } }); + await unattributed.reload(); + await allocation.reload(); + expect(await MoneriumFiatDeposit.count()).toBe(2); + expect(providerDeposit).toMatchObject({ + blockNumber: null, + status: MoneriumFiatDepositStatus.Returned, + txHash: "0xmint" + }); + expect(unattributed.moneriumOrderId).toBe("unattr:first-seen-returned"); + expect(allocation.depositId).toBe(unattributed.id); + }); + + it("discards wrong-currency, wrong-chain, and foreign-profile orders", async () => { + await createAccount(); + const cases = [ + { currency: "usd", id: "33333333-3333-4333-8333-333333333333" }, + { chain: "ethereum", id: "44444444-4444-4444-8444-444444444444" }, + { id: "55555555-5555-4555-8555-555555555555", profile: "66666666-6666-4666-8666-666666666666" } + ]; + for (const [index, overrides] of cases.entries()) { + await MoneriumWebhookEvent.create({ eventId: `evt-scope-${index}`, payload: orderEvent("placed", overrides) }); + } + + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(cases.length); + expect(await MoneriumFiatDeposit.count()).toBe(0); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + }); + + it("terminally discards malformed amounts without delaying later orders", async () => { await createAccount(); - await MoneriumWebhookEvent.create({ eventId: "evt-6", payload: orderEvent("placed") }); - await MoneriumWebhookEvent.create({ eventId: "evt-7", payload: orderEvent("held") }); - await processMoneriumWebhookInbox(); - const deposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: "order-1" } }); - expect(deposit?.status).toBe(MoneriumFiatDepositStatus.Held); - - await MoneriumWebhookEvent.create({ eventId: "evt-8", payload: orderEvent("processed") }); - await processMoneriumWebhookInbox(); - await deposit?.reload(); - expect(deposit?.status).toBe(MoneriumFiatDepositStatus.Minted); + await MoneriumWebhookEvent.create({ + eventId: "evt-invalid-amount", + payload: orderEvent("placed", { + amount: "1.0000000000000000001", + id: "77777777-7777-4777-8777-777777777777" + }) + }); + await MoneriumWebhookEvent.create({ eventId: "evt-valid-after-invalid", payload: orderEvent("placed") }); + + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(2); + expect(await MoneriumFiatDeposit.count()).toBe(1); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(0); }); }); diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts index 7462da7e9..87b2c53f5 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -1,10 +1,17 @@ +import { + type MoneriumChain, + type MoneriumWebhookEvent as MoneriumWebhookPayload, + moneriumWebhookEventSchema +} from "@vortexfi/shared"; import { Op, Transaction } from "sequelize"; import { parseUnits } from "viem"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; +import { getChainId, moneriumChainForChainId } from "./chain"; /** * Asynchronous processor for the durable webhook inbox (plan §3): upserts @@ -76,14 +83,29 @@ interface ParsedOrderEvent { orderId: string; forwarderAddress: string; amount: string; - currency: string; + chain: MoneriumChain; + currency: "eur"; + profileId: string; state: string; txHash: string | null; } export interface ParsedIbanEvent { address: string; + chain: MoneriumChain; iban: string; + profileId: string; +} + +export interface DepositProcessorDeps { + getChainId(): Promise; +} + +const defaultDeps: DepositProcessorDeps = { getChainId }; + +function parseWebhookPayload(payload: unknown): MoneriumWebhookPayload | null { + const parsed = moneriumWebhookEventSchema.safeParse(payload); + return parsed.success ? parsed.data : null; } /** @@ -92,15 +114,21 @@ export interface ParsedIbanEvent { * that is not an IBAN event with both the IBAN and its linked address. */ export function parseIbanEvent(payload: unknown): ParsedIbanEvent | null { - const envelope = payload as { data?: unknown; type?: unknown } | null; - if (!envelope || typeof envelope !== "object") return null; - if (typeof envelope.type !== "string" || !envelope.type.startsWith("iban")) return null; - const data = (envelope.data ?? {}) as Record; - if (typeof data.iban !== "string" || typeof data.address !== "string" || data.iban.trim().length === 0) return null; - return { address: data.address, iban: data.iban.trim() }; + const event = parseWebhookPayload(payload); + if (!event || event.type !== "iban.updated") return null; + return { + address: event.data.address, + chain: event.data.chain, + iban: event.data.iban.trim(), + profileId: event.data.profile + }; } -async function processIbanEvent(row: MoneriumWebhookEvent, event: ParsedIbanEvent): Promise { +async function processIbanEvent( + row: MoneriumWebhookEvent, + event: ParsedIbanEvent, + expectedChain: MoneriumChain +): Promise { await withForwarderLock(event.address, async transaction => { const account = await MoneriumAccount.findOne({ transaction, @@ -108,6 +136,8 @@ async function processIbanEvent(row: MoneriumWebhookEvent, event: ParsedIbanEven }); if (!account) { logger.warn("monerium-b2b: iban.updated references an unknown forwarder address, skipping"); + } else if (event.chain !== expectedChain || event.profileId !== account.profileId) { + logger.error(`monerium-b2b: iban.updated scope mismatch for account ${account.id}, skipping`); } else if (account.iban === null) { await account.update({ iban: event.iban }, { transaction }); } else if (account.iban !== event.iban) { @@ -127,37 +157,67 @@ async function processIbanEvent(row: MoneriumWebhookEvent, event: ParsedIbanEven * not EURe issue orders — those are acked and marked processed without a deposit write. */ export function parseOrderEvent(payload: unknown): ParsedOrderEvent | null { - const envelope = payload as { data?: unknown; type?: unknown } | null; - if (!envelope || typeof envelope !== "object") return null; - if (typeof envelope.type === "string" && !envelope.type.startsWith("order")) return null; - const data = (envelope.data ?? envelope) as Record; - if (typeof data.kind === "string" && data.kind !== "issue") return null; - if (typeof data.id !== "string" || typeof data.address !== "string" || typeof data.amount !== "string") return null; - const state = typeof data.state === "string" ? data.state : ""; - const meta = (data.meta ?? {}) as Record; + const event = parseWebhookPayload(payload); + if (!event || (event.type !== "order.created" && event.type !== "order.updated")) return null; + const data = event.data; + if (data.kind !== "issue" || data.currency !== "eur") return null; return { amount: data.amount, - currency: typeof data.currency === "string" ? data.currency : "eur", + chain: data.chain, + currency: data.currency, forwarderAddress: data.address, orderId: data.id, - state, - txHash: typeof meta.txHash === "string" ? meta.txHash : null + profileId: data.profile, + state: data.state, + txHash: data.meta.txHashes?.length === 1 ? data.meta.txHashes[0] : null }; } -async function processInboxRow(row: MoneriumWebhookEvent): Promise { - const ibanEvent = parseIbanEvent(row.payload); - if (ibanEvent) { - await processIbanEvent(row, ibanEvent); +async function processInboxRow(row: MoneriumWebhookEvent, deps: DepositProcessorDeps): Promise { + const parsedPayload = parseWebhookPayload(row.payload); + if (!parsedPayload) { + logger.error(`monerium-b2b: authenticated webhook ${row.eventId} has an invalid payload, discarding`); + await row.update({ processedAt: new Date() }); + return; + } + + if ( + parsedPayload.type !== "iban.updated" && + parsedPayload.type !== "order.created" && + parsedPayload.type !== "order.updated" + ) { + await row.update({ processedAt: new Date() }); return; } - const event = parseOrderEvent(row.payload); + const numericChainId = await deps.getChainId(); + const expectedChain = moneriumChainForChainId(numericChainId); + if (!expectedChain) { + throw new Error(`No Monerium chain name is configured for chain id ${numericChainId}`); + } + + if (parsedPayload.type === "iban.updated") { + await processIbanEvent(row, parseIbanEvent(parsedPayload) as ParsedIbanEvent, expectedChain); + return; + } + + const event = parseOrderEvent(parsedPayload); if (!event) { await row.update({ processedAt: new Date() }); return; } + let amountRaw: string; + try { + const parsedAmount = parseUnits(event.amount, EURE_DECIMALS); + if (parsedAmount <= 0n) throw new Error("amount must be positive"); + amountRaw = parsedAmount.toString(); + } catch { + logger.error(`monerium-b2b: webhook order ${event.orderId} has an invalid EUR amount, discarding`); + await row.update({ processedAt: new Date() }); + return; + } + const forwarderKey = event.forwarderAddress.toLowerCase(); await withForwarderLock(forwarderKey, async transaction => { const account = await MoneriumAccount.findOne({ @@ -169,14 +229,112 @@ async function processInboxRow(row: MoneriumWebhookEvent): Promise { await row.update({ processedAt: new Date() }, { transaction }); return; } + if (event.chain !== expectedChain || event.profileId !== account.profileId) { + logger.error(`monerium-b2b: webhook order ${event.orderId} scope mismatch for account ${account.id}, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } const targetStatus = mapOrderStateToDepositStatus(event.state); - const existing = await MoneriumFiatDeposit.findOne({ transaction, where: { moneriumOrderId: event.orderId } }); + let existing = await MoneriumFiatDeposit.findOne({ transaction, where: { moneriumOrderId: event.orderId } }); + if (!existing && event.txHash && targetStatus === MoneriumFiatDepositStatus.Minted) { + const unattributed = await MoneriumFiatDeposit.findAll({ + limit: 2, + transaction, + where: { + [Op.and]: [sequelize.where(sequelize.fn("lower", sequelize.col("tx_hash")), event.txHash.toLowerCase())], + accountId: account.id, + amountRaw, + chainId: numericChainId, + moneriumOrderId: { [Op.like]: "unattr:%" }, + status: MoneriumFiatDepositStatus.Minted + } + }); + if (unattributed.length > 1) { + logger.error(`monerium-b2b: webhook order ${event.orderId} matches multiple unattributed mint rows, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + if (unattributed.length === 1) { + existing = unattributed[0]; + await existing.update({ moneriumOrderId: event.orderId }, { transaction }); + logger.info(`monerium-b2b: reconciled late order ${event.orderId} to mint ${event.txHash}`); + } + } + if (existing && existing.accountId !== account.id) { + logger.error(`monerium-b2b: webhook order ${event.orderId} is already bound to a different account, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + if (existing && existing.amountRaw !== amountRaw) { + logger.error(`monerium-b2b: webhook order ${event.orderId} changed amount, refusing divergent replay`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + if (existing?.txHash && event.txHash && existing.txHash.toLowerCase() !== event.txHash.toLowerCase()) { + logger.error(`monerium-b2b: webhook order ${event.orderId} changed mint transaction hash, refusing divergence`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + const canAcceptMint = + existing && + (existing.status === MoneriumFiatDepositStatus.Minted || + isForwardTransition(existing.status, MoneriumFiatDepositStatus.Minted)); + if ( + existing && + canAcceptMint && + event.txHash && + targetStatus === MoneriumFiatDepositStatus.Minted && + existing.chainId === null && + existing.blockHash === null && + existing.blockNumber === null && + existing.logIndex === null + ) { + const unattributed = await MoneriumFiatDeposit.findAll({ + limit: 2, + transaction, + where: { + [Op.and]: [sequelize.where(sequelize.fn("lower", sequelize.col("tx_hash")), event.txHash.toLowerCase())], + accountId: account.id, + amountRaw, + chainId: numericChainId, + id: { [Op.ne]: existing.id }, + moneriumOrderId: { [Op.like]: "unattr:%" }, + status: MoneriumFiatDepositStatus.Minted + } + }); + if (unattributed.length > 1) { + logger.error(`monerium-b2b: webhook order ${event.orderId} matches multiple unattributed mint rows, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + if (unattributed.length === 1) { + const mint = unattributed[0]; + if (await MoneriumDepositAllocation.count({ transaction, where: { depositId: existing.id } })) { + logger.error(`monerium-b2b: webhook order ${event.orderId} already has allocations, refusing identity merge`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + await MoneriumDepositAllocation.update({ depositId: existing.id }, { transaction, where: { depositId: mint.id } }); + await mint.destroy({ transaction }); + await existing.update( + { + blockHash: mint.blockHash, + blockNumber: mint.blockNumber, + chainId: mint.chainId, + logIndex: mint.logIndex, + txHash: mint.txHash + }, + { transaction } + ); + logger.info(`monerium-b2b: merged late order ${event.orderId} with mint ${event.txHash}`); + } + } if (!existing) { await MoneriumFiatDeposit.create( { accountId: account.id, - amountRaw: parseUnits(event.amount, EURE_DECIMALS).toString(), + amountRaw, currency: event.currency, moneriumOrderId: event.orderId, status: targetStatus ?? MoneriumFiatDepositStatus.Pending, @@ -184,18 +342,22 @@ async function processInboxRow(row: MoneriumWebhookEvent): Promise { }, { transaction } ); - } else if (targetStatus && targetStatus !== existing.status) { - if (isForwardTransition(existing.status, targetStatus)) { - await existing.update( - { status: targetStatus, ...(event.txHash && !existing.txHash ? { txHash: event.txHash } : {}) }, - { - transaction - } - ); - } else { - logger.warn( - `monerium-b2b: ignoring backward status transition ${existing.status} -> ${targetStatus} for order ${event.orderId}` - ); + } else { + const updates: { status?: MoneriumFiatDepositStatus; txHash?: string } = {}; + if (targetStatus && targetStatus !== existing.status) { + if (isForwardTransition(existing.status, targetStatus)) { + updates.status = targetStatus; + } else { + logger.warn( + `monerium-b2b: ignoring backward status transition ${existing.status} -> ${targetStatus} for order ${event.orderId}` + ); + } + } + if (targetStatus === MoneriumFiatDepositStatus.Minted && event.txHash && !existing.txHash && canAcceptMint) { + updates.txHash = event.txHash; + } + if (Object.keys(updates).length > 0) { + await existing.update(updates, { transaction }); } } @@ -208,7 +370,7 @@ async function processInboxRow(row: MoneriumWebhookEvent): Promise { * unprocessed and is retried on the next run; rows we recognize but choose to skip are * marked processed so they cannot poison the loop. */ -export async function processMoneriumWebhookInbox(): Promise { +export async function processMoneriumWebhookInbox(deps: DepositProcessorDeps = defaultDeps): Promise { const rows = await MoneriumWebhookEvent.findAll({ order: [["created_at", "ASC"]], where: { processedAt: null } @@ -216,7 +378,7 @@ export async function processMoneriumWebhookInbox(): Promise { let processed = 0; for (const row of rows) { try { - await processInboxRow(row); + await processInboxRow(row, deps); processed += 1; } catch (error) { logger.error(`monerium-b2b: failed to process webhook inbox row ${row.eventId}:`, error); diff --git a/apps/api/src/api/services/monerium-b2b/feature-gate.test.ts b/apps/api/src/api/services/monerium-b2b/feature-gate.test.ts new file mode 100644 index 000000000..b7c0b2667 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/feature-gate.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import os from "node:os"; +import { shouldStartMoneriumB2bWorker } from "./feature"; + +const expressModuleUrl = new URL("../../../config/express.ts", import.meta.url).href; +const routesModuleUrl = new URL("../../routes/v1/index.ts", import.meta.url).href; + +describe("Monerium B2B feature gate", () => { + it("starts the keeper only for an enabled mykobo process", () => { + expect(shouldStartMoneriumB2bWorker("mykobo", true)).toBe(true); + expect(shouldStartMoneriumB2bWorker("mykobo", false)).toBe(false); + expect(shouldStartMoneriumB2bWorker("monerium", true)).toBe(false); + }); + + it("does not mount the parser, public routes, or admin routes when disabled", async () => { + const script = ` + const { default: app } = await import(${JSON.stringify(expressModuleUrl)}); + const { default: routes } = await import(${JSON.stringify(routesModuleUrl)}); + const matches = path => routes.stack.some(layer => layer.matchers.some(matcher => matcher(path))); + console.log(JSON.stringify({ + adminMounted: matches("/admin/monerium-b2b/accounts"), + moneriumJsonParsers: app.router.stack.filter(layer => layer.name === "jsonParser").length - 1, + publicMounted: matches("/monerium-b2b/account") + })); + `; + const proc = Bun.spawn({ + cmd: [Bun.argv[0], "-e", script], + cwd: os.tmpdir(), + env: { + ADMIN_SECRET: "test-admin-secret", + FLOW_VARIANT: "mykobo", + MONERIUM_B2B_ENABLED: "false", + NODE_ENV: "test", + PATH: process.env.PATH ?? "", + SUPABASE_ANON_KEY: "test-anon-key", + SUPABASE_SERVICE_KEY: "test-service-key", + SUPABASE_URL: "https://example.supabase.co", + WEBHOOK_PRIVATE_KEY: "test-webhook-private-key" + }, + stderr: "pipe", + stdout: "pipe" + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text() + ]); + + const lastLine = stdout.trim().split("\n").at(-1); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(lastLine ? JSON.parse(lastLine) : null).toEqual({ + adminMounted: false, + moneriumJsonParsers: 0, + publicMounted: false + }); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/feature.ts b/apps/api/src/api/services/monerium-b2b/feature.ts new file mode 100644 index 000000000..3c0e232a0 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/feature.ts @@ -0,0 +1,4 @@ +/** Pure startup decision kept testable without importing the side-effectful entrypoint. */ +export function shouldStartMoneriumB2bWorker(flowVariant: string, enabled: boolean): boolean { + return flowVariant === "mykobo" && enabled; +} diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts index 2b5bb1899..6c20fa588 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts @@ -1,7 +1,9 @@ -import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { WebhookEventType } from "@vortexfi/shared"; +import { config } from "../../../config/vars"; import ManagedProfileManager from "../../../models/managedProfileManager.model"; import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import Webhook from "../../../models/webhook.model"; import WebhookDelivery from "../../../models/webhookDelivery.model"; @@ -17,10 +19,18 @@ const FALLBACK = "0x3333333333333333333333333333333333333333"; const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; describe("monerium b2b manager events", () => { + let originalRpcUrl: string | undefined; + beforeAll(async () => { + originalRpcUrl = config.moneriumB2b.rpcUrl; + config.moneriumB2b.rpcUrl = undefined; await setupTestDatabase(); }); + afterAll(() => { + config.moneriumB2b.rpcUrl = originalRpcUrl; + }); + beforeEach(async () => { await resetTestDatabase(); }); @@ -66,7 +76,10 @@ describe("monerium b2b manager events", () => { const deposit = await MoneriumFiatDeposit.create({ accountId: mapped.accountId, amountRaw: "100000000000000000000", + blockNumber: 100, + chainId: 11155111, currency: "eur", + logIndex: 1, moneriumOrderId: "order-1", status: MoneriumFiatDepositStatus.Minted, txHash: "0xmint" @@ -110,9 +123,13 @@ describe("monerium b2b manager events", () => { const deposit = await MoneriumFiatDeposit.create({ accountId: mapped.accountId, amountRaw: "100000000000000000000", + blockNumber: 100, + chainId: 11155111, currency: "eur", + logIndex: 1, moneriumOrderId: "order-1", - status: MoneriumFiatDepositStatus.Minted + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" }); await emitMoneriumDepositEvents(depsAtBlock(null)); @@ -121,35 +138,80 @@ describe("monerium b2b manager events", () => { expect(await WebhookDelivery.count()).toBe(0); }); - it("emits DEPOSIT_CONVERTED only at confirmation depth", async () => { + it("does not emit DEPOSIT_RECEIVED from a provider claim without chain identity", async () => { + const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + currency: "eur", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xprovider-claim" + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await deposit.reload(); + expect(deposit.receivedEventAt).toBeNull(); + expect(await WebhookDelivery.count()).toBe(0); + }); + + it("emits one aggregate DEPOSIT_CONVERTED only after every allocation reaches confirmation depth", async () => { const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_CONVERTED]); - const execution = await MoneriumConversionExecution.create({ + const firstExecution = await MoneriumConversionExecution.create({ accountId: mapped.accountId, blockNumber: 1000, destination: DESTINATION, - eureInRaw: "100000000000000000000", + eureInRaw: "60000000000000000000", status: MoneriumConversionExecutionStatus.Confirmed, - txHash: "0xswap", - usdcNetRaw: "108000000" + txHash: "0xswap1", + usdcNetRaw: "64800000" + }); + const secondExecution = await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1001, + destination: DESTINATION, + eureInRaw: "40000000000000000000", + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xswap2", + usdcNetRaw: "43200000" }); const deposit = await MoneriumFiatDeposit.create({ accountId: mapped.accountId, - allocatedExecutionId: execution.id, amountRaw: "100000000000000000000", + blockNumber: 999, + chainId: 11155111, currency: "eur", + logIndex: 1, moneriumOrderId: "order-1", receivedEventAt: new Date(), status: MoneriumFiatDepositStatus.Minted, txHash: "0xmint" }); + await MoneriumDepositAllocation.create({ + depositId: deposit.id, + eureInRaw: "60000000000000000000", + executionId: firstExecution.id, + usdcNetRaw: "64800000" + }); + + // A partially converted deposit must not produce a misleading final event. + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1000 + NOTIFY_CONFIRMATION_DEPTH))); + expect(await WebhookDelivery.count()).toBe(0); + + await MoneriumDepositAllocation.create({ + depositId: deposit.id, + eureInRaw: "40000000000000000000", + executionId: secondExecution.id, + usdcNetRaw: "43200000" + }); // One block short of the depth: nothing emitted, marker untouched. - await emitMoneriumDepositEvents(depsAtBlock(BigInt(1000 + NOTIFY_CONFIRMATION_DEPTH - 1))); + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH - 1))); expect(await WebhookDelivery.count()).toBe(0); await deposit.reload(); expect(deposit.convertedEventAt).toBeNull(); - await emitMoneriumDepositEvents(depsAtBlock(BigInt(1000 + NOTIFY_CONFIRMATION_DEPTH))); + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH))); const deliveries = await WebhookDelivery.findAll(); expect(deliveries).toHaveLength(1); expect(deliveries[0]).toMatchObject({ @@ -159,15 +221,19 @@ describe("monerium b2b manager events", () => { }); expect(deliveries[0].payload).toMatchObject({ payload: { - conversion: { executionId: execution.id, txHash: "0xswap", usdcNetRaw: "108000000" }, - depositId: deposit.id + conversions: [ + { eureInRaw: "60000000000000000000", executionId: firstExecution.id, txHash: "0xswap1", usdcNetRaw: "64800000" }, + { eureInRaw: "40000000000000000000", executionId: secondExecution.id, txHash: "0xswap2", usdcNetRaw: "43200000" } + ], + depositId: deposit.id, + usdcNetRaw: "108000000" } }); await deposit.reload(); expect(deposit.convertedEventAt).not.toBeNull(); // Replay is a no-op. - await emitMoneriumDepositEvents(depsAtBlock(BigInt(1000 + NOTIFY_CONFIRMATION_DEPTH))); + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH))); expect(await WebhookDelivery.count()).toBe(1); }); @@ -186,9 +252,13 @@ describe("monerium b2b manager events", () => { await MoneriumFiatDeposit.create({ accountId: mapped.accountId, amountRaw: "100000000000000000000", + blockNumber: 100, + chainId: 11155111, currency: "eur", + logIndex: 1, moneriumOrderId: "order-1", - status: MoneriumFiatDepositStatus.Minted + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" }); await emitMoneriumDepositEvents(depsAtBlock(null)); 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 a37e50c81..d50782c8c 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -1,5 +1,6 @@ import { DepositStatus, type DepositWebhookPayloadBase, WebhookEventType, type WebhookPayload } from "@vortexfi/shared"; import { Op } from "sequelize"; +import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import ManagedProfile from "../../../models/managedProfile.model"; @@ -7,6 +8,7 @@ import MoneriumAccount from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import webhookService from "../webhook/webhook.service"; import { enqueueWebhookDeliveries } from "../webhook/webhook-outbox.service"; @@ -68,9 +70,13 @@ async function emitReceivedEvents(): Promise { limit: BATCH_LIMIT, order: [["created_at", "ASC"]], where: { + blockNumber: { [Op.ne]: null }, + chainId: { [Op.ne]: null }, + logIndex: { [Op.ne]: null }, moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, receivedEventAt: null, - status: MoneriumFiatDepositStatus.Minted + status: MoneriumFiatDepositStatus.Minted, + txHash: { [Op.ne]: null } } }); @@ -102,9 +108,10 @@ async function emitConvertedEvents(deps: ManagerEventDeps): Promise { limit: BATCH_LIMIT, order: [["created_at", "ASC"]], where: { - allocatedExecutionId: { [Op.ne]: null }, convertedEventAt: null, - moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` } + id: { [Op.in]: sequelize.literal("(SELECT deposit_id FROM monerium_deposit_allocations)") }, + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + status: MoneriumFiatDepositStatus.Minted } }); if (deposits.length === 0) return; @@ -122,12 +129,30 @@ async function emitConvertedEvents(deps: ManagerEventDeps): Promise { } async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: bigint): Promise { - const execution = await MoneriumConversionExecution.findByPk(deposit.allocatedExecutionId as string); - if (!execution || execution.status !== MoneriumConversionExecutionStatus.Confirmed) return; + const allocations = await MoneriumDepositAllocation.findAll({ + order: [["created_at", "ASC"]], + where: { depositId: deposit.id } + }); + if (allocations.length === 0) return; + const allocatedEure = allocations.reduce((sum, allocation) => sum + BigInt(allocation.eureInRaw), 0n); + if (allocatedEure !== BigInt(deposit.amountRaw)) return; + + const executions = await MoneriumConversionExecution.findAll({ + where: { id: allocations.map(allocation => allocation.executionId) } + }); + const executionById = new Map(executions.map(execution => [execution.id, execution])); + if (executions.length !== allocations.length) return; + if (executions.some(execution => execution.status !== MoneriumConversionExecutionStatus.Confirmed)) return; // Confirmation-depth gate (plan §3, registry P9): only notify once the execution - // block is NOTIFY_CONFIRMATION_DEPTH below the head, so a shallow reorg cannot - // produce a delivered-then-vanished conversion event. - if (execution.blockNumber === null || head < BigInt(execution.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH)) return; + // blocks are NOTIFY_CONFIRMATION_DEPTH below the head, so a shallow reorg cannot + // produce a delivered-then-vanished aggregate conversion event. + if ( + executions.some( + execution => execution.blockNumber === null || head < BigInt(execution.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH) + ) + ) { + return; + } const account = await MoneriumAccount.findByPk(deposit.accountId); if (!account) return; @@ -137,11 +162,16 @@ async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: eventType: WebhookEventType.DEPOSIT_CONVERTED, payload: { ...depositPayloadBase(deposit, account), - conversion: { - executionId: execution.id, - txHash: execution.txHash, - usdcNetRaw: execution.usdcNetRaw - } + conversions: allocations.map(allocation => { + const execution = executionById.get(allocation.executionId) as MoneriumConversionExecution; + return { + eureInRaw: allocation.eureInRaw, + executionId: execution.id, + txHash: execution.txHash, + usdcNetRaw: allocation.usdcNetRaw + }; + }), + usdcNetRaw: allocations.reduce((sum, allocation) => sum + BigInt(allocation.usdcNetRaw), 0n).toString() }, timestamp: new Date().toISOString() }; @@ -151,9 +181,9 @@ async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: /** * Emits the manager-facing deposit events into the durable webhook outbox: - * DEPOSIT_RECEIVED once a deposit is minted, DEPOSIT_CONVERTED once its allocated - * execution is confirmed at notification depth. Emission markers on the deposit row - * make each event fire exactly once regardless of which component advanced the state. + * DEPOSIT_RECEIVED once a deposit is minted, DEPOSIT_CONVERTED once every portion is + * allocated and all of its executions are confirmed at notification depth. Emission + * markers make each event fire exactly once regardless of the advancing component. */ export async function emitMoneriumDepositEvents(deps: ManagerEventDeps = defaultDeps): Promise { try { diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts index 7476bfc18..8578b9f50 100644 --- a/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts @@ -26,15 +26,21 @@ function candidate(overrides: Partial & { id: string }): Match describe("matchMintLogToDeposit", () => { it("matches by tx hash when the webhook already recorded the mint hash (case-insensitive)", () => { + const amount = 5n; const deposits = [ candidate({ id: "other" }), - candidate({ id: "hash-match", status: Minted, txHash: TX_A.toLowerCase() }) + candidate({ amountRaw: amount.toString(), id: "hash-match", status: Minted, txHash: TX_A.toLowerCase() }) ]; - const match = matchMintLogToDeposit({ txHash: TX_A, valueRaw: 5n }, deposits); + const match = matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits); expect(match?.id).toBe("hash-match"); }); - it("matches the oldest pending deposit with the exact mint amount", () => { + it("rejects a hash match whose on-chain amount disagrees", () => { + const deposits = [candidate({ amountRaw: "5", id: "poisoned", status: Minted, txHash: TX_A })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: 6n }, deposits)).toBeNull(); + }); + + it("quarantines an ambiguous amount match instead of guessing an order", () => { const amount = 250n * 10n ** 18n; const deposits = [ candidate({ amountRaw: (100n * 10n ** 18n).toString(), id: "wrong-amount" }), @@ -42,7 +48,7 @@ describe("matchMintLogToDeposit", () => { candidate({ amountRaw: amount.toString(), id: "newer" }) ]; const match = matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits); - expect(match?.id).toBe("older"); + expect(match).toBeNull(); }); it("returns null when nothing matches (unattributed fallback)", () => { diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts index 565d907fa..c5c793f3a 100644 --- a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts @@ -44,7 +44,8 @@ export type MatchableDeposit = Pick deposit.txHash !== null && deposit.txHash.toLowerCase() === log.txHash.toLowerCase()); if (byHash) { - return byHash; + return BigInt(byHash.amountRaw) === log.valueRaw ? byHash : null; } - return open.find(deposit => deposit.txHash === null && BigInt(deposit.amountRaw) === log.valueRaw) ?? null; + const byAmount = open.filter(deposit => deposit.txHash === null && BigInt(deposit.amountRaw) === log.valueRaw); + return byAmount.length === 1 ? byAmount[0] : null; } interface ObservedMint { @@ -102,17 +104,16 @@ async function recordMint( transaction, where: { accountId: account.id, logIndex: null } }); + const mismatchedHashClaim = candidates.find( + deposit => + deposit.logIndex === null && + deposit.txHash?.toLowerCase() === mint.txHash.toLowerCase() && + BigInt(deposit.amountRaw) !== mint.valueRaw + ); const match = matchMintLogToDeposit({ txHash: mint.txHash, valueRaw: mint.valueRaw }, candidates); if (match) { const deposit = candidates.find(row => row.id === match.id) as MoneriumFiatDeposit; - if (deposit.txHash !== null && BigInt(deposit.amountRaw) !== mint.valueRaw) { - // Hash-matched but the webhook-reported amount disagrees with the on-chain - // value: detective alert, never a silent overwrite of accounting data. - logger.error( - `monerium-b2b: mint ${mint.txHash}#${mint.logIndex} value ${mint.valueRaw.toString()} disagrees with webhook amount ${deposit.amountRaw} on deposit ${deposit.id}` - ); - } await deposit.update( { blockHash: mint.blockHash, @@ -126,6 +127,12 @@ async function recordMint( { transaction } ); } else { + if (mismatchedHashClaim) { + logger.error( + `monerium-b2b: refusing mismatched mint ${mint.txHash}#${mint.logIndex}: chain value ${mint.valueRaw.toString()} ` + + `disagrees with webhook amount ${mismatchedHashClaim.amountRaw} on deposit ${mismatchedHashClaim.id}` + ); + } // R09-adjacent: EURe arrived without a matching Monerium order (direct transfer, // or the order webhook has not landed yet). Record it flagged as unattributed so // the balance stays accounted for; it is never presented as a customer deposit. diff --git a/apps/api/src/api/services/monerium-b2b/monerium-api.test.ts b/apps/api/src/api/services/monerium-b2b/monerium-api.test.ts new file mode 100644 index 000000000..8c7b7080d --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monerium-api.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import type { MoneriumAddress, MoneriumIban } from "@vortexfi/shared"; +import { selectAccountIban, selectProfileChainAddresses } from "./monerium-api"; + +const ADDRESS = "0x1111111111111111111111111111111111111111"; +const PROFILE = "11111111-1111-4111-8111-111111111111"; +const OTHER_PROFILE = "22222222-2222-4222-8222-222222222222"; + +function iban(overrides: Partial): MoneriumIban { + return { + address: ADDRESS, + bic: "AIBKIE2D", + chain: "ethereum", + iban: "EE08 7224 5745 6244 9516", + name: "Client Ltd", + profile: PROFILE, + ...overrides + }; +} + +describe("Monerium B2B provider scope selection", () => { + it("selects an IBAN only for the exact profile, chain, and address", () => { + const correct = iban({}); + const ibans = [ + iban({ chain: "sepolia", iban: "EE52 1273 8426 8857 1285" }), + iban({ iban: "EE24 2200 2210 2014 5685", profile: OTHER_PROFILE }), + correct + ]; + + expect(selectAccountIban(ibans, ADDRESS.toLowerCase(), "ethereum", PROFILE)).toBe(correct); + expect(selectAccountIban(ibans, ADDRESS, "sepolia", OTHER_PROFILE)).toBeNull(); + }); + + it("refuses an ambiguous exact IBAN match", () => { + expect(() => selectAccountIban([iban({}), iban({ iban: "EE52 1273 8426 8857 1285" })], ADDRESS, "ethereum", PROFILE)).toThrow( + "Multiple Monerium IBANs matched" + ); + }); + + it("keeps only addresses linked to the expected profile and chain", () => { + const entries: MoneriumAddress[] = [ + { address: ADDRESS, chains: ["sepolia"], profile: PROFILE }, + { address: ADDRESS, chains: ["ethereum"], profile: OTHER_PROFILE }, + { address: ADDRESS.toLowerCase(), chains: ["ethereum"], profile: PROFILE } + ]; + + expect(selectProfileChainAddresses(entries, PROFILE, "ethereum")).toEqual([ADDRESS.toLowerCase()]); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/monerium-api.ts b/apps/api/src/api/services/monerium-b2b/monerium-api.ts index 8e20cc460..519025fd5 100644 --- a/apps/api/src/api/services/monerium-b2b/monerium-api.ts +++ b/apps/api/src/api/services/monerium-b2b/monerium-api.ts @@ -1,5 +1,6 @@ import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + type MoneriumAddress, MoneriumApiService, type MoneriumChain, type MoneriumIban @@ -48,10 +49,29 @@ export async function listIbans(): Promise { return (await MoneriumApiService.getInstance().listIbans()).ibans; } -/** GET /ibans — the IBAN issued for an address, or null if none yet. */ -export async function getIbanForAddress(address: string): Promise { - const ibans = await listIbans(); - return ibans.find(entry => entry.address.toLowerCase() === address.toLowerCase()) ?? null; +/** Exact account-scoped IBAN match; never guesses across chain/profile duplicates. */ +export function selectAccountIban( + ibans: MoneriumIban[], + address: string, + chain: MoneriumChain, + profileId: string +): MoneriumIban | null { + const matches = ibans.filter( + entry => entry.address.toLowerCase() === address.toLowerCase() && entry.chain === chain && entry.profile === profileId + ); + if (matches.length > 1) { + throw new Error(`Multiple Monerium IBANs matched ${profileId}:${chain}:${address.toLowerCase()}`); + } + return matches[0] ?? null; +} + +/** GET /ibans — the IBAN issued for this exact profile/chain/address tuple. */ +export async function getIbanForAddress( + address: string, + chain: MoneriumChain, + profileId: string +): Promise { + return selectAccountIban(await listIbans(), address, chain, profileId); } /** @@ -59,7 +79,11 @@ export async function getIbanForAddress(address: string): Promise { +export function selectProfileChainAddresses(addresses: MoneriumAddress[], profileId: string, chain: MoneriumChain): string[] { + return addresses.filter(entry => entry.profile === profileId && entry.chains.includes(chain)).map(entry => entry.address); +} + +export async function getProfileAddresses(profileId: string, chain: MoneriumChain): Promise { const response = await MoneriumApiService.getInstance().listAddresses({ profile: profileId }); - return response.addresses.map(entry => entry.address); + return selectProfileChainAddresses(response.addresses, profileId, chain); } diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 63ab100e7..f0df89938 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -3,7 +3,15 @@ import { Address, encodePacked, Hex, parseAbi } from "viem"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; -import { erc20Abi, factoryAbi, forwarderAbi, getChainId, getForwarderImmutables, getPublicClient } from "./chain"; +import { + erc20Abi, + factoryAbi, + forwarderAbi, + getChainId, + getForwarderImmutables, + getPublicClient, + moneriumChainForChainId +} from "./chain"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; /** @@ -344,10 +352,19 @@ export async function runAssociationMonitor(): Promise { if (accounts.length === 0) { return; } - const ibans = (await listIbans()).map(entry => ({ address: entry.address, iban: entry.iban })); + const chainId = await getChainId(); + const chainName = moneriumChainForChainId(chainId); + if (!chainName) { + logger.error(`monerium-b2b: association monitor has no Monerium chain name for chain id ${chainId}`); + return; + } + const allIbans = await listIbans(); for (const account of accounts) { try { - const profileAddresses = await getProfileAddresses(account.profileId); + const ibans = allIbans + .filter(entry => entry.chain === chainName && entry.profile === account.profileId) + .map(entry => ({ address: entry.address, iban: entry.iban })); + const profileAddresses = await getProfileAddresses(account.profileId, chainName); const changes = diffAssociation( { forwarderAddress: account.forwarderAddress, iban: account.iban }, { ibans, profileAddresses } @@ -374,32 +391,52 @@ export async function runConfigReconciliation(): Promise { return; } const client = getPublicClient(); + const trustedFactory = config.moneriumB2b.forwarderFactoryAddress; + if (!trustedFactory) { + logger.error("monerium-b2b: config reconciliation skipped — trusted forwarder factory is not configured"); + return; + } const implementationByFactory = new Map(); for (const account of accounts) { try { const forwarder = account.forwarderAddress as Address; const { factory } = await getForwarderImmutables(forwarder); - let implementation = implementationByFactory.get(factory.toLowerCase()); + if (factory.toLowerCase() !== trustedFactory.toLowerCase()) { + logger.error( + `monerium-b2b: forwarder ${forwarder} (account ${account.id}) reports untrusted factory ${factory}; ` + + `expected ${trustedFactory}` + ); + continue; + } + const trustedFactoryAddress = trustedFactory as Address; + let implementation = implementationByFactory.get(trustedFactory.toLowerCase()); if (!implementation) { implementation = await client.readContract({ abi: factoryMonitoringAbi, - address: factory, + address: trustedFactoryAddress, functionName: "implementation" }); - implementationByFactory.set(factory.toLowerCase(), implementation); + implementationByFactory.set(trustedFactory.toLowerCase(), implementation); } const [destination, fallbackAddress, feeBps, isForwarder, code] = await Promise.all([ client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "destination" }), client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "fallbackAddress" }), client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "feeBps" }), - client.readContract({ abi: factoryMonitoringAbi, address: factory, args: [forwarder], functionName: "isForwarder" }), + client.readContract({ + abi: factoryMonitoringAbi, + address: trustedFactoryAddress, + args: [forwarder], + functionName: "isForwarder" + }), client.getCode({ address: forwarder }) ]); if (!isForwarder) { - logger.error(`monerium-b2b: forwarder ${forwarder} (account ${account.id}) is not registered on factory ${factory}`); + logger.error( + `monerium-b2b: forwarder ${forwarder} (account ${account.id}) is not registered on trusted factory ${trustedFactory}` + ); } if ((code ?? "0x").toLowerCase() !== eip1167RuntimeCode(implementation).toLowerCase()) { logger.error( diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts index 47fcf51ba..c3dbcfc67 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -15,6 +15,7 @@ const DESTINATION = "0x2222222222222222222222222222222222222222"; const FALLBACK = "0x3333333333333333333333333333333333333333"; const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; const IBAN = "EE08 7224 5745 6244 9516"; +const ETHEREUM_CHAIN = { getChainId: async () => 1 }; const savedConfig = { ...config.moneriumB2b }; @@ -263,19 +264,32 @@ describe("iban.updated inbox recording", () => { const account = await createMappedAccount(); await MoneriumWebhookEvent.create({ eventId: "evt-iban-1", - payload: { data: { address: FORWARDER, chain: "ethereum", iban: IBAN }, type: "iban.updated" } + payload: { + data: { address: FORWARDER, chain: "ethereum", iban: IBAN, profile: MONERIUM_PROFILE }, + timestamp: "2026-08-25T12:00:00Z", + type: "iban.updated" + } }); - await processMoneriumWebhookInbox(); + await processMoneriumWebhookInbox(ETHEREUM_CHAIN); await account.reload(); expect(account.iban).toBe(IBAN); // A later iban.updated with a different IBAN is an alert condition, not data. await MoneriumWebhookEvent.create({ eventId: "evt-iban-2", - payload: { data: { address: FORWARDER, chain: "ethereum", iban: "EE00 0000 0000 0000 0000" }, type: "iban.updated" } + payload: { + data: { + address: FORWARDER, + chain: "ethereum", + iban: "EE00 0000 0000 0000 0000", + profile: MONERIUM_PROFILE + }, + timestamp: "2026-08-25T12:00:00Z", + type: "iban.updated" + } }); - await processMoneriumWebhookInbox(); + await processMoneriumWebhookInbox(ETHEREUM_CHAIN); await account.reload(); expect(account.iban).toBe(IBAN); @@ -285,10 +299,19 @@ describe("iban.updated inbox recording", () => { it("acks iban events for unknown forwarders without failing the drain", async () => { await MoneriumWebhookEvent.create({ eventId: "evt-iban-3", - payload: { data: { address: "0x8888888888888888888888888888888888888888", iban: IBAN }, type: "iban.updated" } + payload: { + data: { + address: "0x8888888888888888888888888888888888888888", + chain: "ethereum", + iban: IBAN, + profile: MONERIUM_PROFILE + }, + timestamp: "2026-08-25T12:00:00Z", + type: "iban.updated" + } }); - expect(await processMoneriumWebhookInbox()).toBe(1); + expect(await processMoneriumWebhookInbox(ETHEREUM_CHAIN)).toBe(1); expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); }); }); diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.ts b/apps/api/src/api/services/monerium-b2b/onboarding.ts index eb42be807..8e3738e3e 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.ts @@ -6,22 +6,15 @@ import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import { FinancialOperationRejectedError, runFinancialOperation } from "../phases/blocks/core/financial-operation"; import { signLinkAttestation } from "./attestor"; -import { getChainId } from "./chain"; +import { getChainId, moneriumChainForChainId } from "./chain"; import { getIbanForAddress, getProfileAddresses, isWhitelabelConfigured, linkAddress, requestIban } from "./monerium-api"; const ONBOARDING_FLOW = { id: "monerium-b2b-onboarding", version: 1 } as const; -// Monerium's chain identifiers for the chains the forwarder deploys to -// (docs.monerium.com chain values; the attestation binds the numeric chain id). -const MONERIUM_CHAIN_NAMES: Record = { - 1: "ethereum", - 11155111: "sepolia" -}; - export interface OnboardingDeps { getChainId(): Promise; - getIbanForAddress(address: string): Promise<{ iban: string } | null>; - getProfileAddresses(profileId: string): Promise; + getIbanForAddress(address: string, chain: MoneriumChain, profileId: string): Promise<{ iban: string } | null>; + getProfileAddresses(profileId: string, chain: MoneriumChain): Promise; linkAddress(profileId: string, address: string, chain: MoneriumChain, signature: string): Promise; requestIban(address: string, chain: MoneriumChain): Promise; signLinkAttestation(chainId: bigint, forwarderAddress: Address): Promise<{ signature: string }>; @@ -43,9 +36,14 @@ export function isOnboardingConfigured(): boolean { let configWarned = false; -async function isForwarderLinked(deps: OnboardingDeps, moneriumProfileId: string, forwarderAddress: string): Promise { +async function isForwarderLinked( + deps: OnboardingDeps, + moneriumProfileId: string, + forwarderAddress: string, + chainName: MoneriumChain +): Promise { const forwarderKey = forwarderAddress.toLowerCase(); - const addresses = await deps.getProfileAddresses(moneriumProfileId); + const addresses = await deps.getProfileAddresses(moneriumProfileId, chainName); return addresses.some(address => address.toLowerCase() === forwarderKey); } @@ -55,7 +53,7 @@ async function ensureLinked( chainId: number, chainName: MoneriumChain ): Promise { - if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress)) return; + if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) return; await runFinancialOperation({ attemptClass: "provider-address-link", flow: ONBOARDING_FLOW, @@ -67,7 +65,7 @@ async function ensureLinked( // Linking is synchronous upstream: if the address is not linked after a // failure, the call had no side effect — signal that so the ledger allows a // clean retry next cycle instead of parking the row in `unknown` forever. - if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress)) { + if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) { return { linked: true }; } throw new FinancialOperationRejectedError( @@ -81,7 +79,7 @@ async function ensureLinked( // A crash between the POST and its confirmation resolves by re-reading the // profile's linked addresses instead of issuing a second link call. reconcile: async () => - (await isForwarderLinked(deps, account.profileId, account.forwarderAddress)) ? { linked: true } : null, + (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) ? { linked: true } : null, request: { address: account.forwarderAddress.toLowerCase(), chain: chainName, moneriumProfileId: account.profileId }, retryFailed: true, // vortexProfileId is non-null for every account this loop selects. @@ -92,7 +90,7 @@ async function ensureLinked( async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainName: MoneriumChain): Promise { if (account.iban) return; - const issued = await deps.getIbanForAddress(account.forwarderAddress); + const issued = await deps.getIbanForAddress(account.forwarderAddress, chainName, account.profileId); if (issued) { await account.update({ iban: issued.iban }); return; @@ -115,7 +113,8 @@ async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainN }, phase: "requestIban", provider: "monerium", - reconcile: async () => ((await deps.getIbanForAddress(account.forwarderAddress)) ? { requested: true } : null), + reconcile: async () => + (await deps.getIbanForAddress(account.forwarderAddress, chainName, account.profileId)) ? { requested: true } : null, request: { address: account.forwarderAddress.toLowerCase(), chain: chainName }, retryFailed: true, scopeId: account.vortexProfileId as string, @@ -150,7 +149,7 @@ export async function advanceOnboardingAccounts(deps: OnboardingDeps = defaultDe if (accounts.length === 0) return 0; const chainId = await deps.getChainId(); - const chainName = MONERIUM_CHAIN_NAMES[chainId]; + const chainName = moneriumChainForChainId(chainId); if (!chainName) { logger.error(`monerium-b2b: no Monerium chain name known for chain id ${chainId}; onboarding automation halted`); return 0; diff --git a/apps/api/src/api/services/monerium-b2b/webhook.test.ts b/apps/api/src/api/services/monerium-b2b/webhook.test.ts index 90748d80b..1982de76c 100644 --- a/apps/api/src/api/services/monerium-b2b/webhook.test.ts +++ b/apps/api/src/api/services/monerium-b2b/webhook.test.ts @@ -27,15 +27,34 @@ let webhook: typeof import("./webhook"); let controller: typeof import("../../controllers/monerium-b2b.controller"); let config: typeof import("../../../config/vars").config; -const SECRET = "test-webhook-secret"; - -function sign(rawBody: Buffer, secret: string, encoding: "base64" | "hex" = "hex"): string { - return crypto.createHmac("sha256", secret).update(rawBody).digest(encoding); +const SECRET = `whsec_${Buffer.from("01234567890123456789012345678901", "utf8").toString("base64")}`; +const WEBHOOK_ID = "msg_2LhLhM4Q6YwqZ1fX"; +const WEBHOOK_TIMESTAMP = "1789142400"; + +function sign( + rawBody: Buffer, + secret = SECRET, + webhookId = WEBHOOK_ID, + webhookTimestamp = WEBHOOK_TIMESTAMP +): string { + const key = Buffer.from(secret.slice("whsec_".length), "base64"); + const signedPayload = Buffer.concat([Buffer.from(`${webhookId}.${webhookTimestamp}.`, "utf8"), rawBody]); + return `v1,${crypto.createHmac("sha256", key).update(signedPayload).digest("base64")}`; } -function mockRequest(rawBody: Buffer | undefined, signature: string | undefined): never { +function mockRequest( + rawBody: Buffer | undefined, + signature: string | undefined, + webhookId: string | undefined = WEBHOOK_ID, + webhookTimestamp: string | undefined = WEBHOOK_TIMESTAMP +): never { return { - header: (name: string) => (name.toLowerCase() === "webhook-signature" ? signature : undefined), + header: (name: string) => { + if (name.toLowerCase() === "webhook-id") return webhookId; + if (name.toLowerCase() === "webhook-timestamp") return webhookTimestamp; + if (name.toLowerCase() === "webhook-signature") return signature; + return undefined; + }, rawBody } as never; } @@ -78,32 +97,26 @@ afterAll(() => { describe("verifyWebhookSignature", () => { const body = Buffer.from(JSON.stringify({ data: { id: "order-1" }, type: "order.updated" }), "utf8"); - it("accepts a correct HMAC-SHA256 in hex or base64 encoding", () => { - expect(webhook.verifyWebhookSignature(body, sign(body, SECRET, "hex"), SECRET)).toBe(true); - expect(webhook.verifyWebhookSignature(body, sign(body, SECRET, "base64"), SECRET)).toBe(true); + it("accepts the documented Monerium v1 signature", () => { + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body), SECRET)).toBe(true); }); - it("rejects a wrong secret, tampered bytes, missing header, and empty secret", () => { - expect(webhook.verifyWebhookSignature(body, sign(body, "other-secret"), SECRET)).toBe(false); - expect(webhook.verifyWebhookSignature(Buffer.concat([body, Buffer.from(" ")]), sign(body, SECRET), SECRET)).toBe(false); - expect(webhook.verifyWebhookSignature(body, undefined, SECRET)).toBe(false); - expect(webhook.verifyWebhookSignature(body, "", SECRET)).toBe(false); - expect(webhook.verifyWebhookSignature(body, sign(body, SECRET), "")).toBe(false); - expect(webhook.verifyWebhookSignature(body, "not-a-mac", SECRET)).toBe(false); - }); -}); - -describe("deriveEventId", () => { - it("uses a top-level payload id when present", () => { - expect(webhook.deriveEventId(Buffer.from("{}"), { id: "evt-1" })).toBe("evt-1"); - }); - - it("falls back to a digest of the raw bytes, stable across redeliveries", () => { - const raw = Buffer.from('{"type":"order.updated"}'); - const first = webhook.deriveEventId(raw, { type: "order.updated" }); - expect(first).toStartWith("sha256:"); - expect(webhook.deriveEventId(Buffer.from(raw), { type: "order.updated" })).toBe(first); - expect(webhook.deriveEventId(Buffer.from('{"type":"order.created"}'), { type: "order.created" })).not.toBe(first); + it("rejects tampered signed components and malformed credentials", () => { + const otherSecret = `whsec_${Buffer.from("other-secret", "utf8").toString("base64")}`; + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body, otherSecret), SECRET)).toBe(false); + expect( + webhook.verifyWebhookSignature(Buffer.concat([body, Buffer.from(" ")]), WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body), SECRET) + ).toBe(false); + expect(webhook.verifyWebhookSignature(body, `${WEBHOOK_ID}-tampered`, WEBHOOK_TIMESTAMP, sign(body), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, `${WEBHOOK_TIMESTAMP}1`, sign(body), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, undefined, WEBHOOK_TIMESTAMP, sign(body), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, undefined, sign(body), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, undefined, SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body), "test-webhook-secret")).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body), "whsec_not-base64")).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body).replace("v1,", "v2,"), SECRET)).toBe( + false + ); }); }); @@ -124,7 +137,7 @@ describe("POST /v1/monerium-b2b/webhook controller", () => { it("persists the delivery durably before responding 200 and processes async", async () => { const res = mockResponse(); const next = mock((_error: unknown) => undefined); - await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody)), res as never, next as never); expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); @@ -139,7 +152,8 @@ describe("POST /v1/monerium-b2b/webhook controller", () => { it("rejects an invalid signature with 401 and never touches the inbox", async () => { const res = mockResponse(); const next = mock((_error: unknown) => undefined); - await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, "wrong-secret")), res as never, next as never); + const wrongSecret = `whsec_${Buffer.from("wrong-secret", "utf8").toString("base64")}`; + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, wrongSecret)), res as never, next as never); expect(next).toHaveBeenCalledTimes(1); expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 401 }); @@ -150,7 +164,7 @@ describe("POST /v1/monerium-b2b/webhook controller", () => { it("rejects when the raw body was not captured", async () => { const res = mockResponse(); const next = mock((_error: unknown) => undefined); - await controller.handleWebhook(mockRequest(undefined, sign(rawBody, SECRET)), res as never, next as never); + await controller.handleWebhook(mockRequest(undefined, sign(rawBody)), res as never, next as never); expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 401 }); expect(bulkCreate).not.toHaveBeenCalled(); @@ -160,7 +174,7 @@ describe("POST /v1/monerium-b2b/webhook controller", () => { config.moneriumB2b.webhookSecret = ""; const res = mockResponse(); const next = mock((_error: unknown) => undefined); - await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody)), res as never, next as never); expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 503 }); expect(bulkCreate).not.toHaveBeenCalled(); @@ -169,14 +183,16 @@ describe("POST /v1/monerium-b2b/webhook controller", () => { it("acks a redelivery with 200 (insert is a dedup no-op)", async () => { const res = mockResponse(); const next = mock((_error: unknown) => undefined); - await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); - await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, SECRET)), res as never, next as never); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody)), res as never, next as never); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody)), res as never, next as never); expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenNthCalledWith(2, 200); // Same event id both times — the unique index makes the second insert a no-op. const firstRows = bulkCreate.mock.calls[0]?.[0] as Array<{ eventId: string }>; const secondRows = bulkCreate.mock.calls[1]?.[0] as Array<{ eventId: string }>; - expect(firstRows[0].eventId).toBe(secondRows[0].eventId); + expect(firstRows[0].eventId).toBe(WEBHOOK_ID); + expect(secondRows[0].eventId).toBe(WEBHOOK_ID); + await flushSetImmediate(); }); }); diff --git a/apps/api/src/api/services/monerium-b2b/webhook.ts b/apps/api/src/api/services/monerium-b2b/webhook.ts index b600f451f..6480deb80 100644 --- a/apps/api/src/api/services/monerium-b2b/webhook.ts +++ b/apps/api/src/api/services/monerium-b2b/webhook.ts @@ -4,17 +4,14 @@ import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; /** * Monerium B2B webhook authentication + durable inbox (plan §3, R06). * - * Monerium docs: each notification carries a `webhook-signature` header containing the - * HMAC-SHA256 of the minified JSON payload under the shared secret. We verify over the - * RAW request bytes exactly as delivered (never a re-serialization) with a - * constant-time compare. - * - * TODO(sandbox): the docs do not state the digest encoding — hex and base64 are both - * accepted here until the G0 sandbox spike pins it (both encode the same secret MAC, - * so accepting either does not widen the trust surface). + * Monerium signs `${webhook-id}.${webhook-timestamp}.${rawBody}` with the base64-decoded + * bytes after the `whsec_` prefix. The signature header is `v1,`. + * Raw bytes are load-bearing: parsing and re-serialising JSON changes the MAC input. */ +export const MONERIUM_ID_HEADER = "webhook-id"; export const MONERIUM_SIGNATURE_HEADER = "webhook-signature"; +export const MONERIUM_TIMESTAMP_HEADER = "webhook-timestamp"; function constantTimeEquals(a: Buffer, b: Buffer): boolean { if (a.length !== b.length) { @@ -25,25 +22,30 @@ function constantTimeEquals(a: Buffer, b: Buffer): boolean { return crypto.timingSafeEqual(a, b); } -export function verifyWebhookSignature(rawBody: Buffer, signatureHeader: string | undefined, secret: string): boolean { - if (!secret || !signatureHeader) return false; - const mac = crypto.createHmac("sha256", secret).update(rawBody).digest(); - const provided = Buffer.from(signatureHeader.trim(), "utf8"); - const hexMatch = constantTimeEquals(provided, Buffer.from(mac.toString("hex"), "utf8")); - const base64Match = constantTimeEquals(provided, Buffer.from(mac.toString("base64"), "utf8")); - return hexMatch || base64Match; +function decodeBase64(value: string, minBytes: number, maxBytes: number): Buffer | null { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null; + const decoded = Buffer.from(value, "base64"); + return decoded.length >= minBytes && decoded.length <= maxBytes ? decoded : null; } -/** - * Dedup identity for a delivery. Monerium's documented payload (`type`, `timestamp`, - * `data`) carries no delivery id, so redeliveries are identified by the digest of the - * raw bytes; a top-level `id` is honored if the payload ever grows one. - * TODO(sandbox): confirm whether deliveries carry an id field or header. - */ -export function deriveEventId(rawBody: Buffer, payload: unknown): string { - const id = (payload as { id?: unknown } | null)?.id; - if (typeof id === "string" && id.length > 0 && id.length <= 128) return id; - return `sha256:${crypto.createHash("sha256").update(rawBody).digest("hex")}`; +export function verifyWebhookSignature( + rawBody: Buffer, + webhookId: string | undefined, + webhookTimestamp: string | undefined, + signatureHeader: string | undefined, + secret: string +): boolean { + if (!webhookId || !webhookTimestamp || !signatureHeader || !secret.startsWith("whsec_")) return false; + if (webhookId.length > 128) return false; + + const secretBytes = decodeBase64(secret.slice("whsec_".length), 24, 64); + const signatureMatch = /^v1,([A-Za-z0-9+/]+={0,2})$/.exec(signatureHeader.trim()); + const provided = signatureMatch ? decodeBase64(signatureMatch[1], 32, 32) : null; + if (!secretBytes || !provided) return false; + + const signedPayload = Buffer.concat([Buffer.from(`${webhookId}.${webhookTimestamp}.`, "utf8"), rawBody]); + const expected = crypto.createHmac("sha256", secretBytes).update(signedPayload).digest(); + return constantTimeEquals(provided, expected); } /** diff --git a/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts b/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts index b2e970e06..82d59f24f 100644 --- a/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts +++ b/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts @@ -382,6 +382,23 @@ describe('WebhookService', () => { }); describe('registerWebhook (deposit events)', () => { + it('rejects deposit-event registration while Monerium B2B is disabled', async () => { + const disabledService = new WebhookService(false); + + const error = await disabledService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + url: 'https://example.com/webhook' + }, USER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); + expect((error as APIError).message).toContain('disabled'); + expect(createMock).not.toHaveBeenCalled(); + }); + it('registers an account-scoped deposit webhook without a quote or session', async () => { const mockWebhook = createMockWebhook({ events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED], diff --git a/apps/api/src/api/services/webhook/webhook.service.ts b/apps/api/src/api/services/webhook/webhook.service.ts index 4ca3f4fca..45aa575cc 100644 --- a/apps/api/src/api/services/webhook/webhook.service.ts +++ b/apps/api/src/api/services/webhook/webhook.service.ts @@ -7,6 +7,7 @@ import { import httpStatus from "http-status"; import { Op, WhereOptions } from "sequelize"; import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; import Webhook from "../../../models/webhook.model"; import { APIError } from "../../errors/api-error"; @@ -22,10 +23,24 @@ export interface WebhookOwner { } export class WebhookService { + constructor(private readonly moneriumB2bEnabled = config.moneriumB2b.enabled) {} + public async registerWebhook(request: RegisterWebhookRequest, owner: WebhookOwner): Promise { try { const { url, quoteId, sessionId, events } = request; + if ( + !this.moneriumB2bEnabled && + (events ?? []).some(event => + ACCOUNT_WEBHOOK_EVENT_TYPES.includes(event as (typeof ACCOUNT_WEBHOOK_EVENT_TYPES)[number]) + ) + ) { + throw new APIError({ + message: "Monerium B2B deposit webhooks are disabled", + status: httpStatus.BAD_REQUEST + }); + } + if (!owner.partnerId && !owner.userId) { throw new APIError({ message: "API key is not linked to a partner or user", diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 249d674db..204ce6c61 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -1,11 +1,10 @@ import { CronJob } from "cron"; -import { Op } from "sequelize"; -import { Address } from "viem"; +import { QueryTypes } from "sequelize"; +import sequelize from "../../config/database"; import logger from "../../config/logger"; -import MoneriumAccount, { MoneriumAccountStatus } from "../../models/moneriumAccount.model"; -import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; -import { erc20Abi, getForwarderImmutables, getPublicClient, isKeeperChainConfigured } from "../services/monerium-b2b/chain"; -import { runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; +import { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; +import { isKeeperChainConfigured } from "../services/monerium-b2b/chain"; +import { reconcileConfirmedExecutionAllocations, runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; import { processMoneriumWebhookInbox, pruneProcessedWebhookEvents } from "../services/monerium-b2b/deposit-processor"; import { runDormancyGate } from "../services/monerium-b2b/dormancy"; import { emitMoneriumDepositEvents } from "../services/monerium-b2b/manager-events"; @@ -61,6 +60,7 @@ class MoneriumB2bWorker { } } else { const mintedAccountIds = await runMintWatcher(); + await reconcileConfirmedExecutionAllocations(); const candidateIds = await this.conversionCandidates(mintedAccountIds); for (const accountId of candidateIds) { try { @@ -91,48 +91,27 @@ class MoneriumB2bWorker { } /** - * Accounts worth running the executor for: fresh mints from this cycle, accounts with - * minted-but-unallocated deposits, and accounts whose forwarder holds a nonzero EURe - * balance (covers inflows the watcher has not indexed yet). + * Accounts worth running the executor for: settled mints from this cycle and accounts + * with chain-indexed, minted-but-unallocated deposits. The executor never outruns the + * watcher's reorg-safety window merely because a live balance is visible. */ private async conversionCandidates(mintedAccountIds: string[]): Promise { const candidates = new Set(mintedAccountIds); - const unallocated = await MoneriumFiatDeposit.findAll({ - attributes: ["accountId"], - group: ["account_id"], - where: { allocatedExecutionId: null, status: MoneriumFiatDepositStatus.Minted } - }); - for (const row of unallocated) { + const outstanding = await sequelize.query<{ accountId: string }>( + `SELECT DISTINCT deposit.account_id AS "accountId" + FROM monerium_fiat_deposits AS deposit + LEFT JOIN monerium_deposit_allocations AS allocation ON allocation.deposit_id = deposit.id + WHERE deposit.status = :minted + AND deposit.block_number IS NOT NULL + GROUP BY deposit.id + HAVING COALESCE(SUM(allocation.eure_in_raw), 0) < deposit.amount_raw`, + { replacements: { minted: MoneriumFiatDepositStatus.Minted }, type: QueryTypes.SELECT } + ); + for (const row of outstanding) { candidates.add(row.accountId); } - const balanceCheckAccounts = await MoneriumAccount.findAll({ - where: { - dormantSince: null, - // Sequelize renders an empty NOT IN as NOT IN (NULL), which matches nothing. - ...(candidates.size > 0 ? { id: { [Op.notIn]: [...candidates] } } : {}), - status: { [Op.in]: [MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active] } - } - }); - for (const account of balanceCheckAccounts) { - try { - const forwarder = account.forwarderAddress as Address; - const { eure } = await getForwarderImmutables(forwarder); - const balance = await getPublicClient().readContract({ - abi: erc20Abi, - address: eure, - args: [forwarder], - functionName: "balanceOf" - }); - if (balance > 0n) { - candidates.add(account.id); - } - } catch (error) { - logger.warn(`monerium-b2b: balance check failed for account ${account.id}:`, error); - } - } - return [...candidates]; } } diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index ced2ad219..d87cc7354 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -68,15 +68,17 @@ app.use("/v1/webhooks/avenia", bodyParser.raw({ limit: "100kb", type: "*/*" }), // gets its own small limit instead of buffering the 20mb the JSON API allows before // the signature is even checked. Mounted ahead of the global JSON parser, which // skips bodies that are already parsed. -app.use( - "/v1/monerium-b2b/webhook", - bodyParser.json({ - limit: "100kb", - verify: (req, _res, buf) => { - (req as typeof req & { rawBody?: Buffer }).rawBody = buf; - } - }) -); +if (config.moneriumB2b.enabled) { + app.use( + "/v1/monerium-b2b/webhook", + bodyParser.json({ + limit: "100kb", + verify: (req, _res, buf) => { + (req as typeof req & { rawBody?: Buffer }).rawBody = buf; + } + }) + ); +} // parse body params and attach them to req.body app.use(bodyParser.json({ limit: REQUEST_BODY_LIMIT })); diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 2d42c2957..ad336025c 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -16,6 +16,20 @@ const requiredProductionEnv = { WEBHOOK_PRIVATE_KEY: "test-webhook-private-key" }; +const requiredMoneriumB2bEnv = { + FLOW_VARIANT: "mykobo", + MONERIUM_B2B_ATTESTOR_PRIVATE_KEY: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + MONERIUM_B2B_ENABLED: "true", + MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS: "0x0000000000000000000000000000000000000001", + MONERIUM_B2B_GUARDIAN_PRIVATE_KEY: "0x2222222222222222222222222222222222222222222222222222222222222222", + MONERIUM_B2B_KEEPER_PRIVATE_KEY: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + MONERIUM_B2B_PRIVATE_RPC_URL: "https://private-rpc.example.com", + MONERIUM_B2B_RPC_URL: "https://rpc.example.com", + MONERIUM_B2B_WEBHOOK_SECRET: "whsec_MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=", + MONERIUM_WHITELABEL_CLIENT_ID: "test-whitelabel-client-id", + MONERIUM_WHITELABEL_CLIENT_SECRET: "test-whitelabel-client-secret" +}; + async function importVarsWithEnv(env: Record) { const proc = Bun.spawn({ cmd: [ @@ -109,6 +123,73 @@ describe("vars deployment environment validation", () => { expect(result.stderr).toContain("MONERIUM_CLIENT_ID"); }); + it("keeps Monerium B2B disabled unless its flag is exactly true", async () => { + for (const enabled of ["", "TRUE", "1", "false"]) { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + MONERIUM_B2B_ENABLED: enabled, + NODE_ENV: "production" + }); + + expect(result).toEqual({ exitCode: 0, stderr: "", stdout: "ok\n" }); + } + }); + + it("requires the complete Monerium B2B configuration when enabled", async () => { + for (const name of Object.keys(requiredMoneriumB2bEnv).filter( + name => name !== "MONERIUM_B2B_ENABLED" && name !== "FLOW_VARIANT" + )) { + const result = await importVarsWithEnv({ + ...requiredMoneriumB2bEnv, + DEPLOYMENT_ENV: "production", + [name]: "", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(name); + } + }); + + it("accepts a complete Monerium B2B production configuration", async () => { + const result = await importVarsWithEnv({ + ...requiredMoneriumB2bEnv, + DEPLOYMENT_ENV: "production", + NODE_ENV: "production" + }); + + expect(result).toEqual({ exitCode: 0, stderr: "", stdout: "ok\n" }); + }); + + it("rejects malformed activation secrets and a zero factory", async () => { + for (const overrides of [ + { MONERIUM_B2B_ATTESTOR_PRIVATE_KEY: "not-a-key" }, + { MONERIUM_B2B_ATTESTOR_PRIVATE_KEY: requiredMoneriumB2bEnv.MONERIUM_B2B_KEEPER_PRIVATE_KEY }, + { MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS: "0x0000000000000000000000000000000000000000" }, + { MONERIUM_B2B_WEBHOOK_SECRET: "plain-text" } + ]) { + const result = await importVarsWithEnv({ + ...requiredMoneriumB2bEnv, + ...overrides, + DEPLOYMENT_ENV: "production", + NODE_ENV: "production" + }); + expect(result.exitCode).toBe(1); + } + }); + + it("requires the mykobo flow variant when Monerium B2B is enabled", async () => { + const result = await importVarsWithEnv({ + ...requiredMoneriumB2bEnv, + DEPLOYMENT_ENV: "production", + FLOW_VARIANT: "monerium", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("FLOW_VARIANT=mykobo"); + }); + it("accepts a lower recipient-invite discount ceiling", async () => { const result = await importVarsWithEnv({ DEPLOYMENT_ENV: "production", diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 989d7d1c4..7da9decb4 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -224,6 +224,8 @@ interface Config { // Separate credential set from the legacy consumer OAuth integration above. moneriumB2b: { attestorPrivateKey: string | undefined; + enabled: boolean; + forwarderFactoryAddress: string | undefined; guardianPrivateKey: string | undefined; keeperPrivateKey: string | undefined; privateRpcUrl: string | undefined; @@ -337,6 +339,8 @@ export const config: Config = { // (MONERIUM_WHITELABEL_CLIENT_ID/SECRET, MONERIUM_API_URL — @vortexfi/shared); // this block keeps only the chain/keeper-specific settings. attestorPrivateKey: process.env.MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, + enabled: process.env.MONERIUM_B2B_ENABLED === "true", + forwarderFactoryAddress: process.env.MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS, // Dormancy-gate pause key (guardian on the factory/forwarders). Distinct from the // keeper and attestor keys by design; unset = log-only mode for the dormancy gate. guardianPrivateKey: process.env.MONERIUM_B2B_GUARDIAN_PRIVATE_KEY, @@ -442,6 +446,62 @@ if (config.demoProviderEnabled && config.deploymentEnv !== "sandbox") { ); } +if (config.moneriumB2b.enabled) { + if (config.flowVariant !== "mykobo") { + throw new Error("MONERIUM_B2B_ENABLED=true requires FLOW_VARIANT=mykobo"); + } + + const missing: string[] = []; + if (!process.env.MONERIUM_WHITELABEL_CLIENT_ID) missing.push("MONERIUM_WHITELABEL_CLIENT_ID"); + if (!process.env.MONERIUM_WHITELABEL_CLIENT_SECRET) missing.push("MONERIUM_WHITELABEL_CLIENT_SECRET"); + if (!config.moneriumB2b.attestorPrivateKey) missing.push("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY"); + if (!config.moneriumB2b.guardianPrivateKey) missing.push("MONERIUM_B2B_GUARDIAN_PRIVATE_KEY"); + if (!config.moneriumB2b.keeperPrivateKey) missing.push("MONERIUM_B2B_KEEPER_PRIVATE_KEY"); + if (!config.moneriumB2b.rpcUrl) missing.push("MONERIUM_B2B_RPC_URL"); + if (!config.moneriumB2b.webhookSecret) missing.push("MONERIUM_B2B_WEBHOOK_SECRET"); + if (!config.moneriumB2b.forwarderFactoryAddress) missing.push("MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS"); + if (config.deploymentEnv === "production" && !config.moneriumB2b.privateRpcUrl) { + missing.push("MONERIUM_B2B_PRIVATE_RPC_URL"); + } + if (missing.length > 0) { + throw new Error(`Missing required environment variables for Monerium B2B: ${missing.join(", ")}`); + } + + if ( + !/^0x[0-9a-fA-F]{40}$/.test(config.moneriumB2b.forwarderFactoryAddress as string) || + /^0x0{40}$/i.test(config.moneriumB2b.forwarderFactoryAddress as string) + ) { + throw new Error("MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS must be a valid EVM address"); + } + for (const [name, value] of [ + ["MONERIUM_B2B_ATTESTOR_PRIVATE_KEY", config.moneriumB2b.attestorPrivateKey], + ["MONERIUM_B2B_GUARDIAN_PRIVATE_KEY", config.moneriumB2b.guardianPrivateKey], + ["MONERIUM_B2B_KEEPER_PRIVATE_KEY", config.moneriumB2b.keeperPrivateKey] + ] as const) { + if (!/^0x[0-9a-fA-F]{64}$/.test(value as string)) { + throw new Error(`${name} must be a 32-byte 0x-prefixed private key`); + } + } + const b2bKeys = [ + config.moneriumB2b.attestorPrivateKey, + config.moneriumB2b.guardianPrivateKey, + config.moneriumB2b.keeperPrivateKey + ].map(value => (value as string).toLowerCase()); + if (new Set(b2bKeys).size !== b2bKeys.length) { + throw new Error("Monerium B2B attestor, guardian, and keeper private keys must be distinct"); + } + const encodedWebhookSecret = config.moneriumB2b.webhookSecret.slice("whsec_".length); + const decodedWebhookSecret = Buffer.from(encodedWebhookSecret, "base64"); + if ( + !config.moneriumB2b.webhookSecret.startsWith("whsec_") || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encodedWebhookSecret) || + decodedWebhookSecret.length < 24 || + decodedWebhookSecret.length > 64 + ) { + throw new Error("MONERIUM_B2B_WEBHOOK_SECRET must encode 24-64 bytes using whsec_"); + } +} + if (config.env === "production") { const missing: string[] = []; diff --git a/apps/api/src/database/migrations/075-add-conversion-broadcast-block.ts b/apps/api/src/database/migrations/075-add-conversion-broadcast-block.ts new file mode 100644 index 000000000..6eb82379e --- /dev/null +++ b/apps/api/src/database/migrations/075-add-conversion-broadcast-block.ts @@ -0,0 +1,14 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Recovery scans from the block observed immediately before broadcast. Unlike a +// fixed lookback, this remains complete after an arbitrarily long worker outage. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "broadcast_block_number", { + allowNull: true, + type: DataTypes.INTEGER + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "broadcast_block_number"); +} diff --git a/apps/api/src/database/migrations/076-create-monerium-deposit-allocations.ts b/apps/api/src/database/migrations/076-create-monerium-deposit-allocations.ts new file mode 100644 index 000000000..1f59b0960 --- /dev/null +++ b/apps/api/src/database/migrations/076-create-monerium-deposit-allocations.ts @@ -0,0 +1,56 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// A deposit may span multiple capped swaps, and one swap may consume multiple +// deposits. The join table is the accounting source of truth for both directions. +export async function up(queryInterface: QueryInterface): Promise { + const [[existing]] = (await queryInterface.sequelize.query( + "SELECT COUNT(*)::integer AS count FROM monerium_fiat_deposits WHERE allocated_execution_id IS NOT NULL" + )) as [[{ count: number }], unknown]; + if (existing.count > 0) { + throw new Error("Cannot migrate existing Monerium deposit allocations automatically; reconcile them before deploying"); + } + + await queryInterface.createTable("monerium_deposit_allocations", { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + deposit_id: { + allowNull: false, + onDelete: "CASCADE", + references: { key: "id", model: "monerium_fiat_deposits" }, + type: DataTypes.UUID + }, + eure_in_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + execution_id: { + allowNull: false, + onDelete: "CASCADE", + references: { key: "id", model: "monerium_conversion_executions" }, + type: DataTypes.UUID + }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + usdc_net_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) } + }); + await queryInterface.sequelize.query( + "ALTER TABLE monerium_deposit_allocations ADD CONSTRAINT monerium_deposit_allocations_eure_positive CHECK (eure_in_raw > 0)" + ); + await queryInterface.sequelize.query( + "ALTER TABLE monerium_deposit_allocations ADD CONSTRAINT monerium_deposit_allocations_usdc_nonnegative CHECK (usdc_net_raw >= 0)" + ); + await queryInterface.addIndex("monerium_deposit_allocations", ["deposit_id", "execution_id"], { unique: true }); + await queryInterface.addIndex("monerium_deposit_allocations", ["execution_id"]); + await queryInterface.removeColumn("monerium_fiat_deposits", "allocated_execution_id"); +} + +export async function down(queryInterface: QueryInterface): Promise { + const [[existing]] = (await queryInterface.sequelize.query( + "SELECT COUNT(*)::integer AS count FROM monerium_deposit_allocations" + )) as [[{ count: number }], unknown]; + if (existing.count > 0) { + throw new Error("Cannot roll back Monerium deposit allocations after accounting rows have been created"); + } + + await queryInterface.addColumn("monerium_fiat_deposits", "allocated_execution_id", { + allowNull: true, + type: DataTypes.UUID + }); + await queryInterface.dropTable("monerium_deposit_allocations", {}); +} diff --git a/apps/api/src/database/migrations/077-add-conversion-swap-log-index.ts b/apps/api/src/database/migrations/077-add-conversion-swap-log-index.ts new file mode 100644 index 000000000..eb806f816 --- /dev/null +++ b/apps/api/src/database/migrations/077-add-conversion-swap-log-index.ts @@ -0,0 +1,14 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Allocation waits for the mint cursor to cover the swap and needs the event's +// block-global position so same-block deposits after the swap are not attributed to it. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "swap_log_index", { + allowNull: true, + type: DataTypes.INTEGER + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "swap_log_index"); +} diff --git a/apps/api/src/database/monerium-deposit-allocation-migration.test.ts b/apps/api/src/database/monerium-deposit-allocation-migration.test.ts new file mode 100644 index 000000000..c8e72825d --- /dev/null +++ b/apps/api/src/database/monerium-deposit-allocation-migration.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { QueryInterface } from "sequelize"; +import { down } from "./migrations/076-create-monerium-deposit-allocations"; + +describe("Monerium deposit allocation migration rollback", () => { + it("refuses to discard allocation accounting", async () => { + const schemaChanges: string[] = []; + const queryInterface = { + addColumn: async () => { + schemaChanges.push("addColumn"); + }, + dropTable: async () => { + schemaChanges.push("dropTable"); + }, + sequelize: { + query: async () => [[{ count: 1 }], undefined] + } + } as unknown as QueryInterface; + + await expect(down(queryInterface)).rejects.toThrow( + "Cannot roll back Monerium deposit allocations after accounting rows have been created" + ); + expect(schemaChanges).toEqual([]); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9e2701e32..15e69885d 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,6 +12,7 @@ import "./models"; // Initialize models import { AlfredpayLimitsService } from "./api/services/alfredpay/alfredpay-limits.service"; import { assertApiCredentialSchemaReady } from "./api/services/apiCredential.service"; import { installDemoProviders } from "./api/services/demo/demo-alfredpay.provider"; +import { shouldStartMoneriumB2bWorker } from "./api/services/monerium-b2b/feature"; import { assertPersistedBlockFlowVersionsSupported, registerBlockFlowHandlers @@ -95,7 +96,11 @@ const initializeApp = async () => { if (config.flowVariant === "mykobo") { new KybStatusWorker().start(); new AlfredpayStatusWorker().start(); - new MoneriumB2bWorker().start(); + if (shouldStartMoneriumB2bWorker(config.flowVariant, config.moneriumB2b.enabled)) { + new MoneriumB2bWorker().start(); + } else { + logger.info("Monerium B2B onramp is disabled"); + } } else { logger.info("Provider status workers and the Monerium keeper are owned by the mykobo backend"); } diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 117f71c2a..2e322ab07 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -13,6 +13,7 @@ import ManagedProfileManager from "./managedProfileManager.model"; import MoneriumAccount from "./moneriumAccount.model"; import MoneriumChainCursor from "./moneriumChainCursor.model"; import MoneriumConversionExecution from "./moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "./moneriumDepositAllocation.model"; import MoneriumFiatDeposit from "./moneriumFiatDeposit.model"; import MoneriumWebhookEvent from "./moneriumWebhookEvent.model"; import Notification from "./notification.model"; @@ -38,6 +39,10 @@ MoneriumAccount.hasMany(MoneriumFiatDeposit, { as: "fiatDeposits", foreignKey: " MoneriumFiatDeposit.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); MoneriumAccount.hasMany(MoneriumConversionExecution, { as: "conversionExecutions", foreignKey: "accountId" }); MoneriumConversionExecution.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); +MoneriumFiatDeposit.hasMany(MoneriumDepositAllocation, { as: "allocations", foreignKey: "depositId" }); +MoneriumDepositAllocation.belongsTo(MoneriumFiatDeposit, { as: "deposit", foreignKey: "depositId" }); +MoneriumConversionExecution.hasMany(MoneriumDepositAllocation, { as: "allocations", foreignKey: "executionId" }); +MoneriumDepositAllocation.belongsTo(MoneriumConversionExecution, { as: "execution", foreignKey: "executionId" }); MoneriumAccount.belongsTo(User, { as: "vortexProfile", foreignKey: "vortexProfileId" }); User.hasOne(MoneriumAccount, { as: "moneriumAccount", foreignKey: "vortexProfileId" }); Webhook.hasMany(WebhookDelivery, { as: "deliveries", foreignKey: "webhookId" }); @@ -141,6 +146,7 @@ const models = { MoneriumAccount, MoneriumChainCursor, MoneriumConversionExecution, + MoneriumDepositAllocation, MoneriumFiatDeposit, MoneriumWebhookEvent, Notification, diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index 3d2f659e3..d905b534d 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -8,8 +8,9 @@ export enum MoneriumConversionExecutionStatus { } // One row per swapAndForward execution (or intentional batch). Allocation to deposits -// is snapshot-based (plan §3, R04): included deposits are those with mint block <= -// execution block not yet allocated; pro-rata by amount, remainder to largest. +// is cursor-gated and snapshot-based (plan §3, R04): included deposits precede the +// execution's exact block/log position and are not yet allocated; pro-rata by amount, +// remainder to largest. export interface MoneriumConversionExecutionAttributes { id: string; accountId: string; @@ -21,7 +22,11 @@ export interface MoneriumConversionExecutionAttributes { txHash: string | null; /** The swap's transaction nonce, persisted BEFORE broadcast (crash-recovery identity). */ nonce: number | null; + /** Chain head observed with the nonce, persisted before broadcast for complete recovery scans. */ + broadcastBlockNumber: number | null; blockNumber: number | null; + /** Block-global SwapExecuted log position used as the deposit snapshot boundary. */ + swapLogIndex: number | null; status: MoneriumConversionExecutionStatus; error: string | null; createdAt: Date; @@ -36,7 +41,9 @@ type MoneriumConversionExecutionCreationAttributes = Optional< | "usdcNetRaw" | "txHash" | "nonce" + | "broadcastBlockNumber" | "blockNumber" + | "swapLogIndex" | "status" | "error" | "createdAt" @@ -56,7 +63,9 @@ class MoneriumConversionExecution declare destination: string; declare txHash: string | null; declare nonce: number | null; + declare broadcastBlockNumber: number | null; declare blockNumber: number | null; + declare swapLogIndex: number | null; declare status: MoneriumConversionExecutionStatus; declare error: string | null; declare createdAt: Date; @@ -75,6 +84,11 @@ MoneriumConversionExecution.init( field: "block_number", type: DataTypes.INTEGER }, + broadcastBlockNumber: { + allowNull: true, + field: "broadcast_block_number", + type: DataTypes.INTEGER + }, createdAt: { allowNull: false, defaultValue: DataTypes.NOW, @@ -113,6 +127,11 @@ MoneriumConversionExecution.init( defaultValue: MoneriumConversionExecutionStatus.Pending, type: DataTypes.ENUM(...Object.values(MoneriumConversionExecutionStatus)) }, + swapLogIndex: { + allowNull: true, + field: "swap_log_index", + type: DataTypes.INTEGER + }, txHash: { allowNull: true, field: "tx_hash", diff --git a/apps/api/src/models/moneriumDepositAllocation.model.ts b/apps/api/src/models/moneriumDepositAllocation.model.ts new file mode 100644 index 000000000..2fb0f2138 --- /dev/null +++ b/apps/api/src/models/moneriumDepositAllocation.model.ts @@ -0,0 +1,82 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export interface MoneriumDepositAllocationAttributes { + id: string; + depositId: string; + executionId: string; + /** Portion of the deposit consumed by this execution (EURe, 18 decimals). */ + eureInRaw: string; + /** Portion of this execution's net swap output attributed to this deposit (6 decimals). */ + usdcNetRaw: string; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumDepositAllocationCreationAttributes = Optional< + MoneriumDepositAllocationAttributes, + "id" | "createdAt" | "updatedAt" +>; + +class MoneriumDepositAllocation + extends Model + implements MoneriumDepositAllocationAttributes +{ + declare id: string; + declare depositId: string; + declare executionId: string; + declare eureInRaw: string; + declare usdcNetRaw: string; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumDepositAllocation.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + depositId: { + allowNull: false, + field: "deposit_id", + type: DataTypes.UUID + }, + eureInRaw: { + allowNull: false, + field: "eure_in_raw", + type: DataTypes.DECIMAL(38, 0) + }, + executionId: { + allowNull: false, + field: "execution_id", + type: DataTypes.UUID + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + usdcNetRaw: { + allowNull: false, + field: "usdc_net_raw", + type: DataTypes.DECIMAL(38, 0) + } + }, + { + indexes: [{ fields: ["deposit_id", "execution_id"], unique: true }, { fields: ["execution_id"] }], + modelName: "MoneriumDepositAllocation", + sequelize, + tableName: "monerium_deposit_allocations" + } +); + +export default MoneriumDepositAllocation; diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts index 715acc558..dfca1552d 100644 --- a/apps/api/src/models/moneriumFiatDeposit.model.ts +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -23,7 +23,6 @@ export interface MoneriumFiatDepositAttributes { logIndex: number | null; blockHash: string | null; blockNumber: number | null; - allocatedExecutionId: string | null; receivedEventAt: Date | null; convertedEventAt: Date | null; createdAt: Date; @@ -39,7 +38,6 @@ type MoneriumFiatDepositCreationAttributes = Optional< | "logIndex" | "blockHash" | "blockNumber" - | "allocatedExecutionId" | "receivedEventAt" | "convertedEventAt" | "createdAt" @@ -61,7 +59,6 @@ class MoneriumFiatDeposit declare logIndex: number | null; declare blockHash: string | null; declare blockNumber: number | null; - declare allocatedExecutionId: string | null; declare receivedEventAt: Date | null; declare convertedEventAt: Date | null; declare createdAt: Date; @@ -75,11 +72,6 @@ MoneriumFiatDeposit.init( field: "account_id", type: DataTypes.UUID }, - allocatedExecutionId: { - allowNull: true, - field: "allocated_execution_id", - type: DataTypes.UUID - }, amountRaw: { allowNull: false, field: "amount_raw", diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 6596c1df6..41914c250 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -33,6 +33,14 @@ if (!process.env.RUN_LIVE_TESTS) { process.env.MONERIUM_API_URL = "http://monerium.invalid"; process.env.MONERIUM_WHITELABEL_CLIENT_ID = "test-monerium-whitelabel-client-id"; process.env.MONERIUM_WHITELABEL_CLIENT_SECRET = "test-monerium-whitelabel-client-secret"; + process.env.MONERIUM_B2B_ENABLED = "true"; + process.env.MONERIUM_B2B_ATTESTOR_PRIVATE_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + process.env.MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000001"; + process.env.MONERIUM_B2B_GUARDIAN_PRIVATE_KEY = "0x2222222222222222222222222222222222222222222222222222222222222222"; + process.env.MONERIUM_B2B_KEEPER_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + process.env.MONERIUM_B2B_RPC_URL = "http://evm.invalid"; + process.env.MONERIUM_B2B_PRIVATE_RPC_URL = "http://evm-private.invalid"; + process.env.MONERIUM_B2B_WEBHOOK_SECRET = "whsec_dGVzdC1tb25lcml1bS13ZWJob29rLXNlY3JldA=="; // COINGECKO_API_URL is deliberately NOT overridden: priceFeed config tests // assert its default, and the fetch guard blocks real calls anyway. process.env.ALCHEMY_API_KEY = ""; diff --git a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts index aebbb04c5..cbcaef1e9 100644 --- a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts +++ b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts @@ -1,7 +1,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import type { CorridorCountry } from "@vortexfi/shared"; +import { config } from "../config/vars"; import ManagedProfileManager from "../models/managedProfileManager.model"; import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../models/moneriumFiatDeposit.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestApiKey, createTestUser } from "../test-utils/factories"; @@ -17,14 +19,18 @@ const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; describe("monerium b2b account read surface", () => { let app: TestApp; let world: FakeWorld; + let originalRpcUrl: string | undefined; beforeAll(async () => { + originalRpcUrl = config.moneriumB2b.rpcUrl; + config.moneriumB2b.rpcUrl = undefined; world = installFakeWorld(); await setupTestDatabase(); app = await startTestApp(); }); afterAll(async () => { + config.moneriumB2b.rpcUrl = originalRpcUrl; await app?.close(); world?.restore(); }); @@ -87,15 +93,20 @@ describe("monerium b2b account read surface", () => { txHash: "0xswap", usdcNetRaw: "108000000" }); - await MoneriumFiatDeposit.create({ + const convertedDeposit = await MoneriumFiatDeposit.create({ accountId: mapped.accountId, currency: "eur", - allocatedExecutionId: execution.id, amountRaw: "100000000000000000000", moneriumOrderId: "order-1", status: MoneriumFiatDepositStatus.Minted, txHash: "0xmint" }); + await MoneriumDepositAllocation.create({ + depositId: convertedDeposit.id, + eureInRaw: "100000000000000000000", + executionId: execution.id, + usdcNetRaw: "108000000" + }); await MoneriumFiatDeposit.create({ accountId: mapped.accountId, currency: "eur", @@ -119,10 +130,19 @@ describe("monerium b2b account read surface", () => { expect(rows.map(row => row.status)).toEqual(["pending", "minted"]); expect(rows[1]).toMatchObject({ amountRaw: "100000000000000000000", - conversion: { executionId: execution.id, status: "confirmed", txHash: "0xswap", usdcNetRaw: "108000000" }, - txHash: "0xmint" + conversions: [ + { + eureInRaw: "100000000000000000000", + executionId: execution.id, + status: "confirmed", + txHash: "0xswap", + usdcNetRaw: "108000000" + } + ], + txHash: "0xmint", + usdcNetRaw: "108000000" }); - expect(rows[0].conversion).toBeNull(); + expect(rows[0]).toMatchObject({ conversions: [], usdcNetRaw: "0" }); expect(deposits.body.pagination).toMatchObject({ total: 2 }); }); diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 02e507f49..b582acc8f 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -69,7 +69,9 @@ Supporting decisions, all in force: `monerium_*` (the legacy OAuth integration owns no tables; no collision). - **Deposit webhooks as a generic event family** (`DEPOSIT_RECEIVED` / `DEPOSIT_CONVERTED`) on the public webhook contract, delivered durably (outbox, - at-least-once) to the partner manager. + at-least-once) to the partner manager. A cap-split deposit emits one final + `DEPOSIT_CONVERTED` after all portions settle, with `conversions[]` and aggregate + attributed USDC rather than a misleading event per chunk. ## Final parameters (decided 2026-08-26 unless noted) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 65e192895..6b7fb2e70 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -979,7 +979,7 @@ export interface paths { }; /** * List the acting profile's EUR deposits - * @description Returns the acting profile's EUR deposits newest first, each with its allocated conversion execution once the swap has run. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. + * @description Returns the acting profile's EUR deposits newest first, with every allocated conversion portion and aggregate attributed USDC. A per-swap cap can split one deposit across multiple executions. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. * * **Auth:** `X-API-Key` or Supabase Bearer. */ @@ -2850,8 +2850,10 @@ export interface components { MoneriumB2bDeposit: { /** @description Deposit amount in 18-decimal base units of the deposit currency. */ amountRaw: string; - /** @description The allocated conversion execution once the swap has run; null while the deposit awaits conversion. */ - conversion: { + /** @description Conversion portions allocated to this deposit, oldest first. Empty while the deposit awaits conversion; multiple entries are returned when a per-swap cap splits the deposit. */ + conversions: { + /** @description EURe from this deposit consumed by the execution in 18-decimal base units. */ + eureInRaw: string; executionId: string; /** * @description Execution status. @@ -2860,9 +2862,9 @@ export interface components { status: "pending" | "confirmed" | "failed"; /** @description The swap-and-forward transaction hash. */ txHash: string | null; - /** @description Net USDC forwarded for the whole execution in 6-decimal base units. When one execution batches several deposits this is the execution total; per-deposit shares are proportional to amountRaw. */ - usdcNetRaw: string | null; - } | null; + /** @description Net USDC from this execution attributed to this deposit in 6-decimal base units. */ + usdcNetRaw: string; + }[]; /** Format: date-time */ createdAt: string; currency: string; @@ -2874,6 +2876,8 @@ export interface components { status: "pending" | "minted" | "held" | "returned"; /** @description The on-chain mint transaction, when observed. */ txHash: string | null; + /** @description Aggregate net USDC attributed to this deposit so far in 6-decimal base units. */ + usdcNetRaw: string; }; MoneriumB2bDepositsResponse: { deposits: components["schemas"]["MoneriumB2bDeposit"][]; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index c938823cd..dbc43b835 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2465,28 +2465,35 @@ "description": "Deposit amount in 18-decimal base units of the deposit currency.", "type": "string" }, - "conversion": { - "description": "The allocated conversion execution once the swap has run; null while the deposit awaits conversion.", - "properties": { - "executionId": { - "type": "string" - }, - "status": { - "description": "Execution status.", - "enum": ["pending", "confirmed", "failed"], - "type": "string" - }, - "txHash": { - "description": "The swap-and-forward transaction hash.", - "type": ["string", "null"] + "conversions": { + "description": "Conversion portions allocated to this deposit, oldest first. Empty while the deposit awaits conversion; multiple entries are returned when a per-swap cap splits the deposit.", + "items": { + "properties": { + "eureInRaw": { + "description": "EURe from this deposit consumed by the execution in 18-decimal base units.", + "type": "string" + }, + "executionId": { + "type": "string" + }, + "status": { + "description": "Execution status.", + "enum": ["pending", "confirmed", "failed"], + "type": "string" + }, + "txHash": { + "description": "The swap-and-forward transaction hash.", + "type": ["string", "null"] + }, + "usdcNetRaw": { + "description": "Net USDC from this execution attributed to this deposit in 6-decimal base units.", + "type": "string" + } }, - "usdcNetRaw": { - "description": "Net USDC forwarded for the whole execution in 6-decimal base units. When one execution batches several deposits this is the execution total; per-deposit shares are proportional to amountRaw.", - "type": ["string", "null"] - } + "required": ["eureInRaw", "executionId", "status", "txHash", "usdcNetRaw"], + "type": "object" }, - "required": ["executionId", "status", "txHash", "usdcNetRaw"], - "type": ["object", "null"] + "type": "array" }, "createdAt": { "format": "date-time", @@ -2506,9 +2513,13 @@ "txHash": { "description": "The on-chain mint transaction, when observed.", "type": ["string", "null"] + }, + "usdcNetRaw": { + "description": "Aggregate net USDC attributed to this deposit so far in 6-decimal base units.", + "type": "string" } }, - "required": ["amountRaw", "conversion", "createdAt", "currency", "depositId", "status", "txHash"], + "required": ["amountRaw", "conversions", "createdAt", "currency", "depositId", "status", "txHash", "usdcNetRaw"], "type": "object" }, "MoneriumB2bDepositsResponse": { @@ -8229,7 +8240,7 @@ "/v1/monerium-b2b/deposits": { "get": { "deprecated": false, - "description": "Returns the acting profile's EUR deposits newest first, each with its allocated conversion execution once the swap has run. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", + "description": "Returns the acting profile's EUR deposits newest first, with every allocated conversion portion and aggregate attributed USDC. A per-swap cap can split one deposit across multiple executions. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", "operationId": "listMoneriumB2bDeposits", "parameters": [ { diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 526974184..0522f7cc2 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -119,7 +119,7 @@ Managers whose business clients hold EUR onramp accounts can subscribe to deposi ### `DEPOSIT_RECEIVED` -Fired once when a client's EUR deposit has been received and the corresponding funds landed in the account's on-chain forwarding contract. +Fired once when a client's EUR deposit has been matched to the corresponding on-chain mint. A provider-reported order without verified chain identity does not emit this event. ```json { @@ -142,7 +142,7 @@ Fired once when a client's EUR deposit has been received and the corresponding f ### `DEPOSIT_CONVERTED` -Fired once per deposit after its conversion has executed and reached a safe confirmation depth on chain. +Fired once per deposit after the full deposit has been converted and every contributing execution has reached a safe confirmation depth on chain. A deposit split by the per-swap cap still produces one final aggregate event. ```json { @@ -157,16 +157,26 @@ Fired once per deposit after its conversion has executed and reached a safe conf "currency": "eur", "status": "minted", "txHash": "0x...", - "conversion": { - "executionId": "e77a...", - "txHash": "0x...", - "usdcNetRaw": "108000000" - } + "conversions": [ + { + "eureInRaw": "60000000000000000000", + "executionId": "e77a...", + "txHash": "0x...", + "usdcNetRaw": "64800000" + }, + { + "eureInRaw": "40000000000000000000", + "executionId": "f88b...", + "txHash": "0x...", + "usdcNetRaw": "43200000" + } + ], + "usdcNetRaw": "108000000" } } ``` -`usdcNetRaw` (6-decimal base units) is the net USDC forwarded by the conversion execution; when one execution batches several deposits it is the execution total, with per-deposit shares proportional to `amountRaw`. +Each `conversions[]` entry contains the EURe portion consumed and the net USDC attributed to this deposit by that execution. The payload-level `usdcNetRaw` is their aggregate. When one execution consumes several deposits, its output is divided proportionally by allocated EURe; floor dust goes to the largest allocation. ### Delivery Semantics diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 56d44207e..dcc074ee7 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -445,11 +445,13 @@ DepositConvertedWebhookPayload: { status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; txHash: null | string; } & { - conversion: { + conversions: Array<{ + eureInRaw: string; executionId: string; txHash: null | string; - usdcNetRaw: null | string; - }; + usdcNetRaw: string; + }>; + usdcNetRaw: string; }; timestamp: string; } @@ -2431,11 +2433,13 @@ WebhookDeliveryAttempt: { status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; txHash: null | string; } & { - conversion: { + conversions: Array<{ + eureInRaw: string; executionId: string; txHash: null | string; - usdcNetRaw: null | string; - }; + usdcNetRaw: string; + }>; + usdcNetRaw: string; }; timestamp: string; } | { @@ -2492,11 +2496,13 @@ WebhookPayload: { status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; txHash: null | string; } & { - conversion: { + conversions: Array<{ + eureInRaw: string; executionId: string; txHash: null | string; - usdcNetRaw: null | string; - }; + usdcNetRaw: string; + }>; + usdcNetRaw: string; }; timestamp: string; } | { diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index c3c49766d..a8a922f8a 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -24,6 +24,12 @@ repeatedly funded. Inside Vortex the client is a **managed child profile** under partner manager, which is what carries KYB records, API credentials, the read API, and webhook tenancy. +The module is dark by default. Unless `MONERIUM_B2B_ENABLED=true`, its public/admin +routes, raw-body webhook parser, and keeper worker are not mounted; account-scoped +deposit webhook registration is rejected. Existing generic outbox deliveries continue +to drain. Enabling it is fail-fast and requires the complete credential/key/RPC set, +the trusted factory address, and `FLOW_VARIANT=mykobo`. + ## System map ```mermaid @@ -93,8 +99,8 @@ sequenceDiagram Note over M: Monerium onboards the corporate under partner reliance - profile "approved" Op->>C: deployForwarder(destination, fallback, feeBps) via factory Op->>Adm: POST /v1/admin/monerium-b2b/accounts - Adm->>C: verify clone (isForwarder + config read-back) - Adm->>Adm: managed child + KYB mirror + monerium_accounts row (onboarding) + Adm->>C: verify clone against configured trusted factory + config read-back + Adm->>Adm: atomically commit managed child + KYB mirror + account K->>M: POST /addresses (attestor-signed link) [exactly-once] K->>M: POST /ibans for the forwarder address [exactly-once] M-->>K: iban.updated webhook -> IBAN recorded @@ -111,8 +117,9 @@ Steps in prose: self-custodied `fallbackAddress`, and initial `feeBps`; manifest generated and verified. 3. **Admin mapping** — one idempotent call provisions the managed child, mirrors the - approved KYB into `provider_customers` + `kyc_cases`, verifies the clone on chain, - and creates the account row bound via `vortex_profile_id`. + approved KYB into `provider_customers` + `kyc_cases`, verifies the clone against the + configured trusted factory on chain, and creates the account row bound via + `vortex_profile_id`. All local records commit in one database transaction. 4. **Keeper automation** links the forwarder (attestor signature) and requests the IBAN, each exactly-once through the profile-scoped `financial_operations` ledger; the `iban.updated` webhook records the IBAN. @@ -136,17 +143,19 @@ sequenceDiagram V->>F: swapAndForward() [execution row committed first] F->>F: swap min(balance, perSwapCap) via Uniswap, Chainlink minOut F->>P: USDC - fee to client wallet (fee to treasury) - V->>V: finalize from SwapExecuted event + R04 attribution + V->>V: finalize from SwapExecuted event + Note over V: mint cursor reaches the swap block + V->>V: R04 attribution through the exact swap log position Note over V: 32 blocks later V->>P: DEPOSIT_CONVERTED -> outbox -> partner webhook ``` -Vortex learns about a mint on **three redundant channels**, which all converge on the -same per-forwarder advisory lock: the **webhooks** carry the order accounting (amount, -order id, compliance holds), the **mint watcher** stamps the on-chain identity that -attribution needs, and a plain **balance check** in the worker makes any funded -forwarder a conversion candidate even if both other channels lag. Any one channel alone -is enough to get funds converted. +Vortex learns about a deposit through two complementary channels, which converge on the +same per-forwarder advisory lock: the **webhooks** carry the provider order accounting +(amount, order id, compliance holds), while the **mint watcher** proves the on-chain +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. ## How the mint watcher walks the chain @@ -160,7 +169,7 @@ flowchart TD C -- no --> D["bootstrap: create cursor at safeHead\n(history is covered by webhook-recorded orders)"] C -- yes --> E["fromBlock = cursor + 1\ntoBlock = min(safeHead, fromBlock + 2000)"] E --> F["getLogs: EURe Transfer -> any known forwarder"] - F --> G["per log, under the forwarder lock:\nmatch to an open deposit (by tx hash, else amount)\nor record a flagged unattr: row"] + F --> G["per log, under the forwarder lock:\nmatch to an open deposit (by tx hash + amount, else amount)\nor record a flagged unattr: row"] G --> H["advance cursor to toBlock\n(only after processing)"] H --> A ``` @@ -174,14 +183,14 @@ The mechanics that matter: is a unique index: already-recorded mints are skipped. - The scan stops **12 blocks below the head**: that identity is not reorg-stable (a dropped transaction re-mines with a different block and log index), so only - settled blocks are read. The lag costs ~2.5 minutes of latency on the *chain-identity* - channel only — the webhook channel and balance check are not delayed by it, so - conversion itself is not slowed. + settled blocks are read. Conversion intentionally inherits this ~2.5-minute safety + delay rather than acting on an unindexed live balance. - Ranges are capped at 2,000 blocks per cycle, so after downtime the watcher catches up in bounded chunks instead of one unbounded `getLogs`. - On first run there is no cursor: it bootstraps at the current settled head and scans - only forward. Historic mints are already represented by webhook-recorded orders; - back-filling their chain fields is a manual operation. + only forward. Historic mints are outside the automatic path even when a webhook row + exists; back-filling their chain fields is a manual operation. The rollout therefore + requires zero EURe balances on mapped forwarders before first enablement. ## Lifecycles @@ -226,10 +235,16 @@ stateDiagram-v2 ``` Deposit statuses are **forward-only** (a delayed or replayed webhook can never regress a -row), and a hashless pending execution is resolved by nonce classification against the -chain rather than guesswork. The account additionally carries a `dormant_since` marker -(guardian-paused after 60 days without a conversion; conversion stops, the protective -stranding marker still arms). +row). Account statuses follow only the arrows above; `closed` is terminal and a repeated +write of the current status is idempotent. A nonce-less execution row is a five-minute +pre-send reservation; expiry uses a compare-and-set so its original owner can no longer +broadcast. Once the swap nonce is persisted, time alone never fails the execution. +Recovery scans bounded 2,000-block pages from the pre-broadcast block and adopts only +one transaction matching the keeper sender, nonce, forwarder target, exact +`swapAndForward()` calldata, and emitted event; incomplete or ambiguous evidence stays +pending for manual reconciliation. The account additionally carries a `dormant_since` +marker (guardian-paused after 60 days without a conversion; conversion stops, the +protective stranding marker still arms). ## Batching and large deposits @@ -244,10 +259,18 @@ Batching happens in both directions, automatically: - **Several small deposits merge.** The contract swaps the balance, not a deposit: two €5k deposits sitting on the forwarder convert in a single execution, and R04 attribution splits the USDC back across both deposit rows pro-rata. A deposit that - would only partially fit under the cap waits intact for the next execution — unless - it is alone larger than the cap itself, in which case it attaches to the execution - that begins converting it (it could never fit a later, smaller one), with its share - clamped to the swapped amount. + only partially fits under the cap is split into an allocation for this execution and + an outstanding remainder for the next. Partners receive one `DEPOSIT_CONVERTED` + event only after the whole deposit is allocated and every contributing execution is + deep enough; its `conversions[]` lists each portion and `usdcNetRaw` is the aggregate + of each swap's `usdcOut - fee`. It excludes unsolicited USDC that the contract sweeps + to the same destination alongside a swap. + +Allocation is intentionally deferred after the swap receipt. The mint watcher must +first advance through the execution block; the reconciler then includes deposits from +earlier blocks and only deposits whose `Transfer` log precedes `SwapExecuted` in the +same block. This exact boundary also captures a mint that lands between the executor's +balance read and its swap transaction without attributing a later mint to that swap. In normal operation merging is rare: the keeper runs every minute, so deposits share an execution only when they arrive within about a minute of each other or during downtime. @@ -304,7 +327,8 @@ erDiagram profiles ||--o| monerium_accounts : "vortex_profile_id (managed child)" monerium_accounts ||--o{ monerium_fiat_deposits : "account_id" monerium_accounts ||--o{ monerium_conversion_executions : "account_id" - monerium_conversion_executions |o--o{ monerium_fiat_deposits : "allocated_execution_id (R04)" + monerium_fiat_deposits ||--o{ monerium_deposit_allocations : "deposit_id" + monerium_conversion_executions ||--o{ monerium_deposit_allocations : "execution_id (R04)" webhooks ||--o{ webhook_deliveries : "webhook_id (deposit events)" monerium_accounts { @@ -323,22 +347,30 @@ erDiagram enum status string tx_hash int log_index - uuid allocated_execution_id FK } monerium_conversion_executions { decimal eure_in_raw decimal usdc_net_raw string tx_hash int nonce + int broadcast_block_number + int swap_log_index enum status } + monerium_deposit_allocations { + uuid deposit_id FK + uuid execution_id FK + decimal eure_in_raw + decimal usdc_net_raw + } ``` | Table | Purpose | |---|---| | `monerium_accounts` (069, 071) | One row per client account: Monerium profile UUID, IBAN, forwarder/destination/fallback addresses, `fee_bps`, lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | -| `monerium_fiat_deposits` (069, 070, 073) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status, on-chain mint identity, allocation link to its execution, and the two webhook-emission markers | -| `monerium_conversion_executions` (069, 074) | One row per `swapAndForward()`, created before broadcast: EURe in, USDC gross/fee/net from the event, tx hash, planned nonce (crash recovery), status | +| `monerium_fiat_deposits` (069, 070, 073, 076) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status, on-chain mint identity, and two webhook-emission markers | +| `monerium_conversion_executions` (069, 074, 075, 077) | One row per `swapAndForward()`, created before broadcast: EURe in, USDC gross + fee from the event, conversion net (`usdcOut - fee`, excluding unrelated USDC swept by `forwarded`), tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and `SwapExecuted` log index (allocation boundary), status | +| `monerium_deposit_allocations` (076) | N:M accounting join: the EURe portion and attributed net USDC for each deposit/execution pair | | `monerium_webhook_events` (069) | Durable persist-before-200 inbox for Monerium deliveries, dedup by event id, 30-day retention after processing | | `monerium_chain_cursors` (070) | Persisted block cursors for the mint watcher | | `webhook_deliveries` (072) | Generic durable outbox for the deposit-event webhook family: one row per (webhook, event), claim-based dispatch with backoff, 30-day retention after settling | @@ -352,10 +384,13 @@ the exactly-once link/IBAN calls, and — registered by the partner — a user-o ## Failure posture (pointers) -Webhook deliveries survive crashes (persist-before-200 inbox); provider onboarding calls -are exactly-once (`financial_operations`); a broadcast whose hash was lost is recovered -from the persisted nonce plus unclaimed `SwapExecuted` logs rather than re-sent; all -per-account writes serialize on one advisory lock; and the client always has two exits +Webhook deliveries survive crashes (persist-before-200 inbox); a late provider webhook +reconciles the exact same-account unattributed mint into the provider order, including +when that order row already exists, without duplicating chain identity or allocations; +provider onboarding calls are exactly-once (`financial_operations`) and their reads are +bound to the configured profile and chain; a broadcast whose hash was lost is recovered +from its persisted nonce/block plus an exact transaction-and-event match rather than +re-sent; all per-account writes serialize on one advisory lock; and the client always has two exits that no operator failure can block — the fallback-address sweep and, past the trigger delay, permissionless swap execution. Full invariants and threat model: [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md). diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 30b515340..8ba3cf968 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -42,30 +42,44 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control ## Deploy checklist (mainnet bring-up) -1. **Treasury first (O2):** create the dedicated fee Safe multisig — `FEE_RECIPIENT` is +1. Apply database migrations from exactly one deployment instance. Migration execution + is not serialized across replicas; do not let multiple instances run the migrator + concurrently. Migrations 076/077 install allocation accounting and its exact + same-block boundary. Treat 076 as forward-only after activation: its `down` migration + refuses to discard any existing allocation rows, so restore from backup instead of + forcing a rollback once conversions have been attributed. +2. **Treasury first (O2):** create the dedicated fee Safe multisig — `FEE_RECIPIENT` is immutable in the implementation. Confirm guardian key custody plan (EOA acceptable for pilot; hardware/multisig at GA). -2. Re-verify the pinned pools and fee tiers at the deploy block (P10) and re-run the +3. Re-verify the pinned pools and fee tiers at the deploy block (P10) and re-run the liquidity baseline quote methodology (T6); confirm `perSwapCap` €25k still executes within the slippage bound. -3. Deploy implementation + factory with the final parameters (ADR table: 52 h oracle +4. Deploy implementation + factory with the final parameters (ADR table: 52 h oracle age, 100 bps slippage/fee cap, 60 d/24 h/60 d delays, €25 floor/€50k ceiling); set operational `minSwapAmount` €250 and `perSwapCap` €25k; register the keeper key. -4. Verify factory + implementation source on the block explorer; generate, verify, and +5. Verify factory + implementation source on the block explorer; generate, verify, and publish the manifest. -5. Production whitelabel credentials from Monerium; configure the keeper backend (the +6. Production whitelabel credentials from Monerium; configure the keeper backend (the mykobo flow variant only): credentials, attestor/keeper/guardian keys (three distinct; - keeper funded), read RPC + private orderflow RPC, webhook secret. -6. Register the webhook endpoint at Monerium (`profile.updated`, `iban.updated`, + keeper funded), read RPC + private orderflow RPC, webhook secret, and + `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`. Keep `MONERIUM_B2B_ENABLED=false` until + every remaining gate is complete. +7. Register the webhook endpoint at Monerium (`profile.updated`, `iban.updated`, `order.created`, `order.updated`). -7. Sandbox residue before first production onboarding: simulate a SEPA deposit end to - end (dashboard → Receive → "Simulate bank transfer") and pin the three - `TODO(sandbox)` items (webhook digest encoding, delivery id field, order-state - vocabulary). -8. SulPayments side: manager profile configured (EU corridor, business type), secret +8. Before first production onboarding, simulate a SEPA deposit end to end (dashboard → + Receive → "Simulate bank transfer") and re-verify the signed `webhook-id`, + `webhook-timestamp`, and `webhook-signature: v1,` fixture against a real + production delivery. +9. SulPayments side: manager profile configured (EU corridor, business type), secret credential issued, deposit-event webhook registered and verifying signatures against `GET /v1/public-key`. -9. Per client: runbook §1 (deploy clone → map → automated link/IBAN → penny test → +10. Confirm every mapped forwarder has a zero EURe balance before the first enablement. + The mint cursor bootstraps at the current settled head and intentionally does not + convert historic, unindexed balances; reconcile any pre-existing balance manually. +11. Set `MONERIUM_B2B_ENABLED=true` on only the designated `mykobo` keeper backend and + restart. Startup must fail if any required B2B setting is absent. Confirm the routes, + raw webhook parser, and keeper are active before accepting a deposit. +12. Per client: runbook §1 (deploy clone → map → automated link/IBAN → penny test → activate). ## Terms & disclosure inputs (engineering-accurate; G2/partner own final wording) diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 32e0503e7..a8fdce910 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -17,12 +17,18 @@ Ground rules that shape every procedure here: otherwise in comms. - **Never send raw EURe to a CEX destination.** EURe recovery targets are `fallbackAddress` only. +- **Treat allocation migrations as forward-only after use.** Run migrations from one + deployment instance only. Migration 076 refuses rollback when any + `monerium_deposit_allocations` row exists; take a database backup and roll forward + rather than deleting financial attribution records. ## 1. Client onboarding Deploy → manifest → verify → map → (automated: link + IBAN) → penny test → activate. One pass per client. Prerequisites: guardian key funded on the target chain; -`MONERIUM_B2B_*` env set on the keeper backend; partner paperwork complete; the client +`MONERIUM_B2B_ENABLED=true` and the complete `MONERIUM_B2B_*` env set on the one +`mykobo` keeper backend (including the trusted factory address, read/private RPCs, +webhook secret, and three keys); partner paperwork complete; the client company onboarded and KYB-approved on Monerium's side (partner KYC reliance) with its Monerium profile UUID at hand; the partner configured as a managed-profile manager (`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type @@ -204,7 +210,7 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- | `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, address linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | | `stranded EURe on forwarder` (warn ≥12h) | Keeper is not converting | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts) | | `stranded EURe ... past TRIGGER_DELAY` | Permissionless trigger now live; SLA long broken | Escalate the keeper outage; anyone may call `swapAndForward()` (same policy applies); communicate the delay | -| `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on factory` | Should-be-impossible state | Full incident: global pause, manifest verifier, compare against manifest history | +| `untrusted factory` / `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on trusted factory` | Should-be-impossible state | Full incident: global pause, verify `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, run the manifest verifier, compare against manifest history | | `reconciled owner-authorized config change` | Client rotated destination/fallback, or a guardian fee change applied — expected, DB updated | No incident. Unexpected destination change → confirm with the partner; a surprise suggests a compromised fallback key (client should `setClientPaused(true)` and rotate) | | `onboarding advance failed` (repeating for one account) | Link/IBAN automation stuck | Check the `financial_operations` row: `failed` retries itself; `unknown` needs manual reconciliation (compare Monerium-side state, then update the row) | | `delivery ... abandoned after N attempts` | Partner webhook endpoint down > backoff horizon | Contact partner; deliveries are not retried after abandonment — partner should poll `GET /v1/monerium-b2b/deposits` to catch up | @@ -487,10 +493,11 @@ MONERIUM_B2B_ATTESTOR_PRIVATE_KEY="$ANVIL_ACCOUNT_2_KEY" \ bun run --cwd apps/api dev ``` -The log must contain `Starting Monerium B2B keeper worker`. Wait for one worker cycle -and verify `monerium_chain_cursors` contains `eure-mints:1` before sending the deposit; +The log must contain `Starting Monerium B2B keeper worker`. Before this first start, +verify every mapped forwarder has zero EURe balance. Then wait for one worker cycle and +verify `monerium_chain_cursors` contains `eure-mints:1` before sending the deposit; otherwise the watcher's first-run bootstrap intentionally starts at the current settled -head and treats earlier chain history as out of scope. +head and treats earlier chain history and balances as out of scope. ### 7.6 Send and settle the deposit @@ -519,23 +526,29 @@ log should show an unattributed EURe mint followed by an execution allocation. Verify the durable records: ```sql -SELECT monerium_order_id, amount_raw, status, tx_hash, log_index, - block_number, allocated_execution_id +SELECT monerium_order_id, amount_raw, status, tx_hash, log_index, block_number FROM monerium_fiat_deposits WHERE account_id = ''; SELECT eure_in_raw, usdc_gross_raw, fee_raw, usdc_net_raw, destination, - tx_hash, nonce, block_number, status, error + tx_hash, nonce, broadcast_block_number, block_number, swap_log_index, status, error FROM monerium_conversion_executions WHERE account_id = ''; + +SELECT deposit_id, execution_id, eure_in_raw, usdc_net_raw +FROM monerium_deposit_allocations +WHERE deposit_id IN ( + SELECT id FROM monerium_fiat_deposits WHERE account_id = '' +); ``` Required results: -- One `minted` deposit with an `unattr:` order id, the real transfer hash and log index, - and a non-null `allocated_execution_id`. -- One `confirmed` execution with the 25 EURe input, zero fee, non-null nonce/hash/block, - destination matching the clone, and `error IS NULL`. +- One `minted` deposit with an `unattr:` order id and the real transfer hash and log index. +- One allocation joining that deposit and execution with the 25 EURe input and the + attributed net USDC. +- One `confirmed` execution with the 25 EURe input, zero fee, non-null + nonce/hash/block/swap-log-index, destination matching the clone, and `error IS NULL`. - The forwarder's EURe balance is zero. - The destination's USDC balance increased by `usdc_net_raw`. - The conversion receipt contains `SwapExecuted` from the clone and a USDC `Transfer` @@ -556,14 +569,15 @@ Required results: - A direct transfer to a known forwarder is durably recorded as an unattributed mint, not silently presented as a Monerium customer order. - The executor's durable path leaves a confirmed execution with its nonce, transaction - hash, block number, amounts, destination, and deposit allocation recorded. + hash, block number, swap log index, amounts, and destination recorded; allocation is + added only after the mint cursor covers that execution block. - The real contract accepts the current Chainlink EUR/USD answer and swaps successfully through the pinned EURe -> EURC -> USDC 5-bps Uniswap V3 path. - Keeper authorization, the 25 EURe minimum, allowance reset, full EURe consumption, zero-fee accounting, and forwarding to the immutable per-client destination work together. -- Snapshot allocation links the observed deposit to the confirmed execution and assigns - the full USDC output. +- Cursor-gated snapshot allocation links the observed deposit to the confirmed execution + at the exact `SwapExecuted` log boundary and assigns the full USDC output. ### 7.8 What this exercise does not validate diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 55a238b7a..395284b0f 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -12,22 +12,24 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each ## Security Invariants +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. 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. 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 over raw bytes, constant-time** — the `webhook-signature` header is verified as HMAC-SHA256 of the RAW request bytes (captured by a body-parser `verify` hook scoped to this route, never a re-serialization of parsed JSON) using `crypto.timingSafeEqual`, with a self-compare on length mismatch so timing does not leak the mismatch position. Unverified requests are rejected 401 before any database write. +4. **Webhook HMAC follows Monerium's current v1 protocol** — `webhook-signature` must contain exactly `v1,`. The HMAC-SHA256 input is `..` and the key is the decoded 24–64-byte payload of the configured `whsec_` secret. The raw bytes are captured by a route-scoped body-parser hook and compared with `crypto.timingSafeEqual`; malformed or unverified requests are rejected 401 before any database write. 5. **Durable persist before 200 (R06)** — every verified delivery is inserted into `monerium_webhook_events` before the 200 response is sent. Processing happens strictly after the response; a crash between insert and processing loses nothing because the inbox row survives. -6. **Delivery dedup is enforced by the database** — inserts use `ON CONFLICT DO NOTHING` on the unique `event_id` (payload id when present, else sha256 of the raw bytes), so Monerium retries and duplicate deliveries can never double-create or double-apply a deposit event. +6. **Delivery dedup is enforced by the database** — inserts use `ON CONFLICT DO NOTHING` on the unique `event_id`, populated from the signed `webhook-id` header, so retries of a delivery can never double-create or double-apply a deposit event. 7. **Deposit status transitions are forward-only** — `pending → {minted, held, returned}`, `held → {minted, returned}`; `minted` and `returned` are terminal. Out-of-order or replayed webhook events can never regress a deposit status; regressive transitions are logged and ignored. Guarded by `isForwardTransition` (unit-tested). 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 is the Monerium order id** — `monerium_order_id` is unique; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are stored as 18-decimal base-unit strings converted from the provider decimal, never floats. +9. **Deposit identity and scope are verified** — authenticated payloads must pass the shared Monerium wire schema. Only EUR issue orders on the configured chain and the mapped account's Monerium profile are accepted; `meta.txHashes` is used only when it contains exactly one hash. `monerium_order_id` is unique and cannot move between accounts; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are positive 18-decimal base-unit strings converted from provider decimals, never floats. An amount-only mint match is accepted only when exactly one same-account candidate exists. A late real order in a minted provider state reconciles its unique exact same-account unattributed mint by amount and transaction hash: a missing provider row adopts the synthetic row, while an existing provider row receives the chain identity and allocations atomically before the synthetic row is removed. Pending or terminal provider states never adopt a synthetic mint. Ambiguity is quarantined and alerted, never guessed. Malformed authenticated deliveries are terminally discarded so they cannot poison the inbox. 10. **Client credentials are env-only and requests are bounded** — all provider calls go through the shared white-label client ([monerium.md](./monerium.md)): credentials come from env (`MONERIUM_WHITELABEL_CLIENT_ID/SECRET`), every call carries an explicit timeout, HTTPS base URLs only, successful responses are validated against the consumed wire schemas, and upstream failures surface with redacted response bodies. The B2B adapter (`monerium-api.ts`) adds no transport of its own. 11. **No KYB submission path exists** — the whitelabel KYB mechanism is contractually unsettled (adr-0005 registry T3), so no identity-data submission code path exists in the B2B module or the shared client. Pilot corporates do not need one: they are onboarded and approved by Monerium under the partner's KYC reliance, and the admin mapping imports that outcome as an approved `kyc_cases` row. -12. **Account mapping is admin-only, idempotent, and conflict-on-divergence** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) provisions the managed child (business entity, via the managed-profile provisioning service and its manager checks), mirrors the approved KYB, and binds the operator-deployed forwarder. When a read RPC is configured, the submitted forwarder is verified against the chain before anything is persisted (factory `isForwarder` registration plus destination/fallbackAddress/feeBps read-back) so a wrong clone address can never become a mapped account. Replaying identical input returns the existing records; any divergence (same Monerium profile with different forwarder/destination/child/feeBps, same child with a second Monerium profile, a Monerium profile already bound to a different customer entity) is a 409, never an overwrite. A Monerium profile, a forwarder address, and a managed profile can each back at most one account (unique + partial-unique indexes, migrations 069/071). -13. **Onboarding provider writes are exactly-once** — 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. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. +12. **Account mapping is admin-only, atomic, and rooted in a trusted factory** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) verifies that the forwarder's immutable `FACTORY()` equals `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, queries `isForwarder` on that configured factory (never a self-reported address), and reads back destination/fallbackAddress/feeBps before persistence. The managed child, customer entity, approved KYB mirror, and account then commit in one database transaction, so a late uniqueness conflict leaves no orphan identity records. Identical replay returns existing records; any divergence is 409, never an overwrite. A Monerium profile, forwarder, and managed profile can each back at most one account (migrations 069/071). +13. **Onboarding provider writes are exactly-once and provider reads are scoped** — the automated link (`POST /addresses`) and IBAN request (`POST /ibans`) run through the profile-scoped `financial_operations` ledger (flow `monerium-b2b-onboarding`), so crashes and retries never repeat a claimed provider call; interrupted calls reconcile by re-reading the linked addresses / issued IBANs. Every linked-address and IBAN selection requires the exact mapped profile, configured Monerium chain, and forwarder address; multiple exact IBAN matches are rejected rather than selected arbitrarily. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. 15. **The read surface is effective-user scoped and accepts no selectors** — `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` resolve the account strictly from the acting profile (manager delegation via `X-Managed-Profile-Id` under the standard managed-profile authorization with EU corridor + business policy, or the child's own credential); no caller-supplied account, profile, or IBAN identifier is accepted, a foreign manager gets the uniform managed-profile 403, and R09 `unattr:` synthetic deposit rows are never returned (`monerium-b2b-account-read.integration.test.ts`). -16. **Manager deposit events are exactly-once-emitted and manager-only** — `manager-events.ts` emits `DEPOSIT_RECEIVED` when a deposit is minted and `DEPOSIT_CONVERTED` once its confirmed execution is `NOTIFY_CONFIRMATION_DEPTH` blocks below the head (registry P9 reorg guard), guarded by per-deposit emission markers so each event fires once regardless of which component advanced the state and history never replays to late subscribers. Deliveries go only to webhooks owned by the account's controlling manager (server-side-signing invariant 9), through the durable `webhook_deliveries` outbox; R09 `unattr:` rows never produce events (`manager-events.test.ts`). +16. **Manager deposit events are final, chain-backed, and manager-only** — `DEPOSIT_RECEIVED` requires the real chain id, transaction hash, log index, and block number, so a provider order alone cannot claim that funds landed. `DEPOSIT_CONVERTED` fires once only after allocations cover the deposit's full EURe amount and every contributing execution is `NOTIFY_CONFIRMATION_DEPTH` blocks deep; its `conversions[]` contains per-execution EURe/USDC portions and payload `usdcNetRaw` is the aggregate. Per-deposit markers prevent replay to late subscribers. Deliveries go only to the controlling manager's webhooks through the durable outbox; `unattr:` rows never emit (`manager-events.test.ts`). +17. **Account lifecycle transitions are explicit** — `onboarding → active`, `active → {suspended, closed}`, and `suspended → {active, closed}` are the only state changes; `closed` is terminal. Repeating the current status is idempotent. The admin controller returns 409 for every invalid edge, including reopening a closed account or moving an active account back to onboarding (`moneriumB2b.controller.test.ts`). ## Keeper @@ -35,9 +37,9 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox 1. **Three-way key separation** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`/`swapAndForward()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can move funds: `swapAndForward` only executes the contract-constrained oracle-checked swap to the client's own `destination`; `setGuardianPaused` is protective-only by contract invariant; the attestor signs the fixed link statement. All three are env-only and never logged. 2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. -3. **Execution record before send** — a `monerium_conversion_executions` row (status `pending`, `eureInRaw = min(balance, perSwapCap)`, snapshot destination) is durably committed BEFORE any transaction is broadcast — in the same forwarder-lock transaction as the pending-check, so two concurrent executors cannot both pass the check — and the swap's nonce is persisted before the broadcast, the tx hash immediately after. Leftover pendings are resolved next cycle: with a hash via receipt lookup (an RPC failure is distinguished from a genuine not-found and never runs the stale clock); without a hash via nonce classification against the chain (nonce absent: never sent; nonce unconsumed and not pending: never reached the mempool; nonce consumed with an unclaimed SwapExecuted: the lost hash is adopted and finalized; consumed without one: reverted/replaced). A crash or DB error between broadcast and the hash update therefore can never silently fail a mined swap or double-execute. Nonce derivation and broadcasts additionally serialize across processes via a keeper-send advisory lock. +3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly no-arg `swapAndForward()`, and receipt emits `SwapExecuted` from the forwarder. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, execution slot check/creation, finalization, R04 allocation) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. -5. **Attribution is snapshot-based and idempotent (R04)** — on confirmation, unallocated minted deposits with mint block ≤ execution block are selected oldest-first up to `eureInRaw` — with one exception: an oversized oldest deposit (alone larger than the swapped amount, i.e. larger than `perSwapCap`) is attributed to the execution that begins converting it, since it could never fit a later execution and would otherwise permanently wedge attribution for itself and every deposit behind it — USDC attribution is pro-rata by the deposit amount clamped to `eureInRaw`, floor division, remainder to the largest deposit (unit-tested), and `allocated_execution_id` links them. Mint identity is the `(chain_id, tx_hash, log_index)` partial unique index, so watcher re-scans after a crash cannot double-record, and the watcher scans only blocks at least 12 confirmations below the head because that identity is not reorg-stable; non-Monerium EURe inflows become `unattr:`-prefixed deposit rows (R09) and are never presented as customer deposits, while a hash-matched mint whose on-chain value disagrees with the webhook amount is an error-level alert, never a silent overwrite. +5. **Attribution is N:M, cursor-gated, exact-snapshot, and idempotent (R04)** — a confirmed execution records the block and block-global `SwapExecuted` log index but is not allocated immediately. Reconciliation starts only after the persisted mint cursor has processed that block, then consumes outstanding portions of deposits minted in earlier blocks or earlier log positions in the same block, oldest-first up to `eureInRaw`. This covers a mint that lands between the executor's balance read and swap without assigning a later same-block mint to the execution. A cap-cut deposit receives a partial `monerium_deposit_allocations` row and its remainder participates in the next execution; one execution may likewise allocate across many deposits. Each row records its EURe portion and proportional net USDC; execution net is computed as `usdcOut - fee`, never the event's `forwarded` full-balance sweep, so pre-existing unsolicited USDC is not misreported as this deposit's yield. Floor dust goes to the largest allocation only when indexed deposits cover the whole execution, so missing inflow cannot inflate a customer's share. Mint identity is `(chain_id, tx_hash, log_index)` and the watcher scans 12-deep blocks. Only chain-indexed deposits make an account a conversion candidate; a raw forwarder balance never bypasses the watcher. Non-Monerium inflows become `unattr:` rows and never surface as customer claims. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. 6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. ## Monitoring @@ -47,14 +49,19 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per `docs/operations-monerium-b2b-runbook.md`). 2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on the pinned EURe→EURC→USDC path at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline). Impact above `SLIPPAGE_BPS` at `minSwapAmount` size logs the error-level PAUSE THRESHOLD line; at `perSwapCap` size a warning. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. 3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal). -4. **Association monitor (S1 detective control)** — per active account, re-reads the profile's linked addresses (`GET /addresses?profile=`) and the partner-context IBAN list (`GET /ibans`) and error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. -5. **Config reconciliation (R07)** — re-reads per-clone config and bytecode. `destination`/`fallbackAddress` drift is owner-authorized by construction (`onlyFallback` in the contract) and `feeBps` drift is guardian-authorized by construction (the P11 timelocked setter: increases announce on-chain and apply permissionlessly only after `FEE_INCREASE_TIMELOCK` = 24 h, decreases immediate, always bounded by the immutable `MAX_FEE_BPS`): both are reconciled into the DB (with a `configVersion` bump) and logged at warn, never alarmed. A clone whose bytecode is not the EIP-1167 proxy of the factory's implementation, or a missing `isForwarder` registration, are error-level should-be-impossible states. Mirrors the standalone manifest verifier (`contracts/monerium-forwarder/script/verify-manifest.ts`), which is documented as consistency evidence, not a trust root (R01). +4. **Association monitor (S1 detective control)** — per active account, re-reads linked addresses and IBANs scoped to the exact mapped profile and configured chain, then error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. +5. **Config reconciliation (R07)** — first requires the clone's immutable `FACTORY()` to equal the configured trusted factory, then reads `implementation()` and `isForwarder()` only from that trusted address. A mismatch is an error and no mutable fields are reconciled. For trusted clones, destination/fallback and timelocked fee changes are authorized transitions reconciled with a version bump; proxy bytecode or registration drift is an incident. The standalone manifest verifier remains consistency evidence, not the trust root. ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| -| **Webhook spoofing** | Attacker posts fabricated order events to `/v1/monerium-b2b/webhook` to invent or advance deposits | HMAC-SHA256 over raw bytes with constant-time compare; 401 before any persistence; 503 (no acceptance) if the secret is unconfigured | +| **Webhook spoofing** | Attacker posts fabricated order events to `/v1/monerium-b2b/webhook` to invent or advance deposits | Monerium v1 HMAC over signed id + timestamp + raw bytes, constant-time compare; 401 before persistence; enabled startup refuses a missing secret | +| **Cross-account/provider poisoning** | A valid provider event names another chain/profile, or a claimed mint hash carries a different amount | Strict wire/chain/profile/currency checks; hash+amount must both match before chain identity or `DEPOSIT_RECEIVED`; conflicting chain logs are isolated as `unattr:` | +| **Lost or replaced keeper transaction** | A slow/hidden transaction is declared stale and a second swap sends the same funds | Compare-and-set pre-send reservation; no time-based failure after nonce persistence; fail-closed nonce state; bounded complete persisted-block scan plus exact sender/nonce/target/calldata/event identity before adopt/fail | +| **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving attribution permanently incomplete | Conversion candidates require chain-indexed deposits; allocation waits until the mint cursor covers the swap's exact block/log boundary | +| **Unsolicited USDC inflates deposit reporting** | The contract sweeps a pre-existing USDC balance with a later swap and the backend credits the whole transfer to that deposit | Execution net and allocations use `SwapExecuted.usdcOut - fee`; `forwarded` is deliberately excluded from conversion accounting | +| **Untrusted forwarder factory** | Admin-secret holder submits a contract whose self-reported factory blesses it and redirects mints | Configured factory is the trust root for provisioning, execution, and monitoring; local provisioning is atomic | | **Webhook replay / duplicate delivery** | A captured valid delivery is replayed to double-count a deposit | Durable inbox dedup on unique `event_id` (`ON CONFLICT DO NOTHING`); forward-only transitions make a replayed older state a no-op | | **Out-of-order events regress state** | A delayed `pending` event arrives after `minted` | Forward-only transition lattice; regressions logged and dropped | | **Attestor key leak** | Attacker obtains `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` | Blast radius is bounded by design: the key can only produce link attestations for the fixed message, never move funds (contract-side invariant); rotate key + re-deploy forwarders with new ATTESTOR immutable | @@ -70,18 +77,23 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY`, `MONERIUM_WHITELABEL_CLIENT_SECRET`, `MONERIUM_B2B_WEBHOOK_SECRET` loaded from env only; grep confirms no logging of their values - [ ] Attestor signs only the bound link hash (`attestor.ts` has no arbitrary-hash signing entry point) - [ ] `attestor.test.ts` pins the signature layout against `VortexForwarder.isValidSignature` (65 bytes, v in 27/28, low-s, bound to forwarder address) -- [ ] Webhook HMAC verified over raw captured bytes (`config/express.ts` verify hook), constant-time compare +- [ ] Webhook HMAC fixture covers signed `webhook-id`, `webhook-timestamp`, raw bytes, decoded `whsec_` key, and `v1,` constant-time comparison - [ ] Inbox insert (`ON CONFLICT DO NOTHING` on `event_id`) happens before the 200 response in `monerium-b2b.controller.ts` - [ ] Forward-only transition guard covers all four statuses; regressive events are dropped, not applied - [ ] All deposit writes run under `pg_advisory_xact_lock` keyed by lower-cased forwarder address - [ ] `monerium_order_id` unique constraint present; mint-log partial unique index present (migration 069) - [ ] `monerium_accounts.vortex_profile_id` partial unique index present (migration 071); admin mapping rejects divergence with 409 (`moneriumB2b.controller.test.ts`) - [ ] Onboarding link/IBAN calls wrapped in profile-scoped `financial_operations`; replay never repeats a provider write (`onboarding.test.ts`) +- [ ] Provider address/IBAN selection requires the exact profile + chain + forwarder tuple and rejects ambiguous matches (`monerium-api.test.ts`) - [ ] No KYB submission code path exists unless registry item T3 has been resolved and this spec updated - [ ] HTTPS enforcement, timeouts, and wire-schema validation on every provider call are delivered by the shared client ([monerium.md](./monerium.md)); `monerium-api.ts` adds no transport of its own -- [ ] Sandbox-verification TODOs resolved before production: exact `webhook-signature` digest encoding, delivery id field, upstream order-state vocabulary, EIP-191 vs raw link-hash variant (registry T4) +- [ ] Current webhook signature/id protocol and upstream order-state vocabulary re-verified from a production delivery before first mainnet deposit (registry T4) - [ ] Keeper, guardian, and attestor private keys are three distinct keys in production; none logged - [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) -- [ ] Conversion execution rows are created before broadcast and every terminal row has status confirmed/failed with a cause; R04 allocation math covered by `conversion-executor.test.ts` +- [ ] Conversion execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the swap; no elapsed-time failure exists after nonce persistence; exact recovery identity, bounded paging, and R04 N:M allocation math are covered by `conversion-executor.test.ts` +- [ ] Confirmed executions carry `swap_log_index` (migration 077); `conversion-allocation.test.ts` proves allocation waits for the mint cursor and applies the exact same-block log boundary idempotently +- [ ] Execution/allocation `usdcNetRaw` is `SwapExecuted.usdcOut - fee`, never the full-balance `forwarded` field (`conversion-executor.test.ts`) +- [ ] Migration 076 refuses a lossy rollback once any deposit allocation exists (`monerium-deposit-allocation-migration.test.ts`) +- [ ] `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` is the factory queried during provisioning/monitoring, and a self-reported mismatch is rejected before local persistence - [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 owner-authorized config reconciliation; quote-impact, stranding, association-diff and drift classification covered by `monitoring.test.ts` - [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; owner-authorized config changes (R07) are warn-level reconciliations, never incidents diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index ec050c12e..26f45752a 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -106,13 +106,18 @@ export interface DepositConvertedWebhookPayload { eventType: WebhookEventType.DEPOSIT_CONVERTED; timestamp: string; payload: DepositWebhookPayloadBase & { - conversion: { + /** Every confirmed conversion portion that consumed this deposit, oldest first. */ + conversions: Array<{ + /** EURe from this deposit consumed by this execution (18-decimal base units). */ + eureInRaw: string; executionId: string; /** The swap-and-forward transaction. */ txHash: string | null; - /** Net USDC forwarded for the whole execution, 6-decimal base units. */ - usdcNetRaw: string | null; - }; + /** Net USDC from this execution attributed to this deposit (6-decimal base units). */ + usdcNetRaw: string; + }>; + /** Aggregate net USDC attributed to the complete deposit (6-decimal base units). */ + usdcNetRaw: string; }; } From abc569332c4e54ad0e430cb7ea16ebe5ffbfbf97 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 11 Sep 2026 12:13:38 +0200 Subject: [PATCH 59/59] fix(frontend): render og image from the vector wordmark The placeholder was the 32px circle icon upscaled to 1200x630, so link previews were visibly pixelated. Render the navbar wordmark (blue.svg, recolored white) on the landing page's blue-950 background instead. --- apps/frontend/public/og-image.png | Bin 159188 -> 18060 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/frontend/public/og-image.png b/apps/frontend/public/og-image.png index abbc591e72803f49c2b3704d6f515142facecafc..c9e103910f61a86174d3a2b1e47a457a729f82ef 100644 GIT binary patch literal 18060 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