Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/api/src/api/services/monerium-b2b/monitoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
20 changes: 15 additions & 5 deletions apps/api/src/api/services/monerium-b2b/monitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -181,6 +183,8 @@ export function classifyVaultRunway(state: Pick<SubsidyVaultState, "balance" | "
export interface AssociationDbRecord {
forwarderAddress: string;
iban: string | null;
/** The client's derived refund wallet, linked at onboarding; the only other expected address. */
refundAddress?: string | null;
}

export interface LiveAssociationState {
Expand All @@ -197,17 +201,19 @@ export function normalizeIban(iban: string): string {
/**
* 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.
* the profile (the client's own refund wallet excepted), 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();
const refund = db.refundAddress?.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) {
if (address.toLowerCase() !== forwarder && address.toLowerCase() !== refund) {
changes.push(`unexpected address linked to the profile: ${address}`);
}
}
Expand Down Expand Up @@ -431,8 +437,12 @@ export async function runAssociationMonitor(): Promise<void> {
.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) {
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture-monerium-b2b-onramp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion docs/operations-monerium-b2b-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/security-spec/05-integrations/monerium-b2b.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading