From 6fbf7745c2a199b060d1986e1969ddd6f6b6a516 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 14:53:15 +0200 Subject: [PATCH] fix(api): expect the client's refund wallet on its monerium profile Onboarding links each client's derived refund wallet to the client's Monerium profile, but the association monitor treated every address other than the forwarder as unexpected. Every active account therefore raised an error-level ASSOCIATION CHANGE (the S1 incident alert) on every monitoring pass. The monitor now derives the same refund wallet onboarding links (refundAccountFor(profileId)) and excuses exactly that address. Any other extra address still alerts, as do the forwarder unlinked and the IBAN moved or unrecorded checks. If the refund seed is missing nothing is excused, so the check fails loud instead of going quiet. --- .../services/monerium-b2b/monitoring.test.ts | 24 +++++++++++++++++++ .../api/services/monerium-b2b/monitoring.ts | 20 ++++++++++++---- docs/architecture-monerium-b2b-onramp.md | 4 ++-- docs/operations-monerium-b2b-runbook.md | 2 +- .../05-integrations/monerium-b2b.md | 2 +- 5 files changed, 43 insertions(+), 9 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 a3a5b9f44..9a472292f 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -158,6 +158,30 @@ describe("diffAssociation", () => { expect(changes).toEqual([`unexpected address linked to the profile: ${intruder}`]); }); + it("expects the client's refund wallet on the profile, but nothing else", () => { + const refund = "0x7777777777777777777777777777777777777777"; + const intruder = "0x9999999999999999999999999999999999999999"; + const withRefund = { ...db, refundAddress: refund }; + const ibans = [{ address: FORWARDER, iban: IBAN }]; + expect(diffAssociation(withRefund, { ibans, profileAddresses: [FORWARDER, refund.toLowerCase()] })).toEqual([]); + expect(diffAssociation(withRefund, { ibans, profileAddresses: [FORWARDER, refund, intruder] })).toEqual([ + `unexpected address linked to the profile: ${intruder}` + ]); + // Without a derived refund address (seed not configured) the wallet is not excused. + expect(diffAssociation(db, { ibans, profileAddresses: [FORWARDER, refund] })).toEqual([ + `unexpected address linked to the profile: ${refund}` + ]); + }); + + it("does not excuse the IBAN moving to the refund wallet", () => { + const refund = "0x7777777777777777777777777777777777777777"; + const changes = diffAssociation( + { ...db, refundAddress: refund }, + { ibans: [{ address: refund, iban: IBAN }], profileAddresses: [FORWARDER, refund] } + ); + expect(changes).toEqual([`IBAN ${IBAN} moved to address ${refund}`]); + }); + it("detects the IBAN moving to another address (PATCH /ibans scenario)", () => { const elsewhere = "0x8888888888888888888888888888888888888888"; const changes = diffAssociation(db, { diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 4b1bb2cf9..cd1baa021 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -21,6 +21,7 @@ import { } from "./chain"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; +import { refundAccountFor } from "./refund-wallet"; /** * Monitoring pass for the Monerium B2B onramp (implementation plan D3 / phase 3), run @@ -42,8 +43,9 @@ import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProduc * 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. + * linked; the client's own derived refund wallet, linked at onboarding, is expected). + * 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. Guardian fee-policy changes (P11) are reconciled into the DB * and logged, not alarmed; the destination has no setter, so a change there, like @@ -181,6 +183,8 @@ export function classifyVaultRunway(state: Pick 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) { + if (address.toLowerCase() !== forwarder && address.toLowerCase() !== refund) { changes.push(`unexpected address linked to the profile: ${address}`); } } @@ -431,8 +437,12 @@ export async function runAssociationMonitor(): Promise { .filter(entry => entry.chain === chainName && entry.profile === account.profileId) .map(entry => ({ address: entry.address, iban: entry.iban })); const profileAddresses = await getProfileAddresses(account.profileId, chainName); + // Onboarding links the client's derived refund wallet to the profile. Without the seed + // (module misconfigured) nothing is excused, so the wallet alerts instead of the check + // going quiet. + const refundAddress = config.moneriumB2b.refundSeed ? refundAccountFor(account.profileId).address : null; const changes = diffAssociation( - { forwarderAddress: account.forwarderAddress, iban: account.iban }, + { forwarderAddress: account.forwarderAddress, iban: account.iban, refundAddress }, { ibans, profileAddresses } ); if (changes.length > 0) { diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 18edd766e..20680526f 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -435,8 +435,8 @@ 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 + forwarder no longer linked, an extra address linked to the profile (the client's own + refund wallet is expected), 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 every enabled route vs Chainlink; diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index be2112c79..36def5101 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -325,7 +325,7 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- | `subsidy vault ... refill before below-floor swaps start deferring` | Less than a day of budget left, or today's budget spent | §2.6 refill; consider the budget vs. observed spreads | | `no subsidy vault is configured on the factory` | `setSubsidyVault` never ran; below-floor swaps defer | §2.6 | | `route ... could not be quoted` | One whitelisted route's pool is unquotable (drained, removed) | Disable it (§2.1) so the keeper stops trying; keep at least one healthy route | -| `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) | +| `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, an address other than the forwarder and the client's own refund wallet linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | | `stranded funds on forwarder ... past RECOVERY_DELAY` (warn) | A batch has been open longer than the promised 2 h window and is neither forwarded nor recovering | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts), `deferring conversion` lines; if the payment cannot complete, mark it for recovery (§2.7) | | `stranded funds ... past TRIGGER_DELAY` (error) | Permissionless path now live; SLA long broken (keeper outage or a persistent deferral) | Escalate; anyone may call `swap(reference, route, amountIn)` and `forwardAll()` — that path prices against Chainlink and pays no subsidy; communicate the delay | | `REFERENCE VENUE —` (error) | The Coinbase product the reference reads is delisted or halted; every keeper swap defers silently | Change `COINBASE_REFERENCE_PRODUCT` (a live EURC market), redeploy the backend; the venue is an operational, not an on-chain, setting | diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 41be13729..5b24c7e9e 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -51,7 +51,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim 1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per `docs/operations-monerium-b2b-runbook.md`). 2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on every enabled factory route at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline); the best route decides. Settlement enforces `SLIPPAGE_BPS` on the client's net, so a raw impact above it means every keeper swap of that size draws a vault subsidy (deferring past the per-swap cap) and the permissionless path would revert; at `minSwapAmount` size that logs the error-level DEPTH BELOW FLOOR line, at `perSwapCap` size a warning (`classifyExecutableDepth`). The keeper's own `projectSwap` deferral remains the authoritative gate. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. 3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe or any USDC whose on-chain batch marker has been open longer than `RECOVERY_DELAY` (2 h, registry P3) warn — the promised window was missed and the deposit should be forwarded or recovering; past `TRIGGER_DELAY` they error (the permissionless swap/forward path is then live — a keeper-outage signal, not a fund-risk signal). -4. **Association monitor (S1 detective control)** — per active account, re-reads linked addresses and IBANs scoped to the exact mapped profile and configured chain, then error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. +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). The only address besides the forwarder that is expected on the profile is the client's own derived refund wallet (`refundAccountFor(profileId)`, linked at onboarding); any other address still alerts, and with no refund seed configured no wallet is excused. This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. 5. **Config reconciliation (R07)** — first requires the clone's immutable `FACTORY()` to equal the configured trusted factory, then reads `implementation()` and `isForwarder()` only from that trusted address. A mismatch is an error and no mutable fields are reconciled. For trusted clones, timelocked fee-policy changes are authorized transitions reconciled with a version bump; a destination change (the clone has no setter), proxy bytecode or registration drift is an incident. The standalone manifest verifier remains consistency evidence, not the trust root. 6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. 8. **Refund monitor** — with automated refunds configured, the oldest active recovery warns after an hour and errors after four or on a failed step (`classifyRefundQueue`, unit-tested); the float's EURe balance errors when empty and warns below 1,000 EURe.