From 966f067b21d7fb367f843fefce7ddc7394bb2970 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 11:01:04 +0200 Subject: [PATCH 1/7] docs(api): mark b2b sandbox test payments as not yet available --- docs/api/pages/14-managed-profiles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 2d356f1be..f77f4ce19 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -181,7 +181,7 @@ X-API-Key: sk_test_... ``` 4. Wait for an `ACCOUNT_UPDATED` event whose `payload.status` is `"active"` and whose `payload.iban` is set. In sandbox, an account activates on its own once its IBAN is issued. The event's `payload.profileId` is the client's managed profile: send it as `X-Managed-Profile-Id` in the reads below. `GET /v1/monerium-b2b/accounts` with your manager key lists the same accounts if you missed an event. -5. Ask Vortex to send a test payment of at least EUR 1 to the client's IBAN. Currently, test payments run in a joint session with Vortex: a normal one, one large enough to convert in several chunks, and one that is refunded. `DEPOSIT_UPDATED` events follow each payment until the deposit is `forwarded` to the destination, or `refunded` for the refunded one. +5. Test payments are not available yet: sandbox cannot currently send EUR to a client's IBAN, and Vortex will tell you once it can. When a payment arrives, `DEPOSIT_UPDATED` events follow it until the deposit is `forwarded` to the destination, or `refunded` if it is not converted within the refund window. 6. Read the client's account and deposits. They return `{ "account": { ... } }` and `{ "deposits": [ ... ], "pagination": { "limit", "offset", "total" } }`, each deposit being the same snapshot `DEPOSIT_UPDATED` delivers. A managed child without a business EUR account returns `404 MONERIUM_B2B_ACCOUNT_NOT_FOUND`; an ID that is not your child returns `403 MANAGED_PROFILE_ACCESS_DENIED`. ```http From 14a08c7728c1ff821050584d981d94a70f025366 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 11:01:05 +0200 Subject: [PATCH 2/7] docs(repo): add the partner-held white-label credential threat ADR-0005 S1 and the rollout ledger already record that the partner holds the app's credentials and may be able to read the webhook secret; the security spec and the key table still treated the HMAC as a complete defence, while the refund target comes from the first delivery. --- docs/operations-monerium-b2b-runbook.md | 2 +- docs/security-spec/05-integrations/monerium-b2b.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index ffe99be24..4a3519a25 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -569,7 +569,7 @@ payment"). | Deployer (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`) | Deploys a registered clone for any destination and refund address; no power over existing clones, funds or settings. Each such clone counts as a forwarder, so the vault pays it, up to its `dailyBudget` and `maxSubsidyPpm`, once its batch delay allows a swap | Guardian: `setDeployer(old,false)`, then `revokeForwarder` for its rogue clones (below); fresh key, fund it, `setDeployer(new,true)`, update the env | | Whitelabel API credentials | Control-plane: can re-link/move IBANs (future mints only) — S1 | §2.5 full sequence | | `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 | +| Webhook HMAC secret | Fabricated inbound order events: accounting noise, and a forged delivery that lands before Monerium's sets that deposit's refund target (forward-only lattice + mint watcher bound the rest) | Rotate at both ends; reconcile deposits against chain; check each open deposit's payer IBAN against its order at Monerium before refunding | **Leaked deployer key (guardian key).** The factory registry is what the vault and the backend's account mapping trust, so a rogue clone is cleaned out of it: diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index a2c2bab7b..794e75c7f 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -61,7 +61,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | Threat | Attack Scenario | Mitigation | |---|---|---| -| **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 | +| **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. The HMAC holds only against parties without the secret (see **Partner-held white-label credentials**) | | **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; an idle, never-mined reservation is re-sent identically at its own nonce, or that nonce is consumed by a no-op when the call no longer executes, never reused for another call; bounded complete persisted-block scan plus exact sender/nonce/target/calldata/event identity before adopt/fail | | **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving a swap with no deposit to belong to | Conversion candidates require chain-indexed deposits; every swap is bound to one deposit before it is sent, with an explicit `amountIn` | @@ -85,7 +85,8 @@ 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; 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 | +| **API credential compromise** | White-label client id/secret leak, from Vortex's env or from the partner, who holds the same credentials | Env-only storage at Vortex; the whitelabel credential set (`MONERIUM_WHITELABEL_*`) is distinct from the legacy OAuth auth-code client (`MONERIUM_CLIENT_ID`); rotate in the partner's app at Monerium, coordinated with the partner; the S1 association monitor detects unauthorized use | +| **Partner-held white-label credentials** | The partner owns the white-label app and holds its credentials with the same control-plane power as Vortex (ADR-0005 S1): it can re-link addresses, move IBANs and close profiles. Any holder can also list the app's webhook subscriptions, Vortex's included. If that listing returns the subscription secret (Monerium's guide says so; its response schema has no secret field), the holder can sign order events that pass the HMAC, and a forged delivery that lands before Monerium's sets the deposit's refund target, which is recorded from the first delivery and never overwritten | Accepted trust in the partner (ADR-0005 S1): the association monitor alerts on a re-linked address or a moved IBAN, and any such change is an incident (runbook §2.5); forged events can create or advance deposit records but cannot make the keeper convert or forward anything that did not mint to the clone, because conversion needs the chain-indexed mint with a matching hash and amount. **Open:** confirm in the sandbox whether the subscription listing returns secrets, and read the payer IBAN and name back from Monerium's API instead of relying on the webhook alone (rollout ledger, "Monerium read-back") | | **Provider unavailability** | Monerium API down | Client calls have explicit timeouts and surface 502; webhook inbox is unaffected (processing is local) | ## Audit Checklist From 33e620a99b42f4a36980a7a83ecfcf93253efcd1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 11:02:45 +0200 Subject: [PATCH 3/7] docs(repo): describe key storage and fund movement in plain terms --- .../api/src/api/services/phases/blocks/README.md | 4 ++-- docs/adr-0005-monerium-b2b-onramp.md | 16 ++++++++-------- ...adr-0007-monerium-b2b-partner-registration.md | 6 +++--- docs/operations-monerium-b2b-rollout.md | 2 +- docs/operations-monerium-b2b-runbook.md | 8 ++++---- docs/product-dashboard.md | 2 +- docs/proposal-api-driven-kyc-kyb.md | 2 +- docs/proposal-mcp-server.md | 2 +- docs/proposal-monerium-consumer-onramp.md | 8 ++++---- docs/security-spec/01-auth/supabase-otp.md | 4 ++-- .../02-signing-keys/ephemeral-accounts.md | 2 +- .../03-ramp-engine/fee-integrity.md | 2 +- .../03-ramp-engine/quote-lifecycle.md | 2 +- .../03-ramp-engine/ramp-phase-flows.md | 6 +++--- docs/security-spec/05-integrations/alfredpay.md | 2 +- .../security-spec/06-cross-chain/fund-routing.md | 2 +- docs/security-spec/RISK-REGISTER.md | 2 +- 17 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/README.md b/apps/api/src/api/services/phases/blocks/README.md index 1711802ae..362174832 100644 --- a/apps/api/src/api/services/phases/blocks/README.md +++ b/apps/api/src/api/services/phases/blocks/README.md @@ -109,7 +109,7 @@ apps/api/src/api/services/phases/blocks/ avenia-{direct-mint,mint,moonbeam-mint}/ # BRL onramp variants avenia-{offramp-fee,offramp-payout,pendulum-offramp}/ mykobo-{mint,offramp-fee,offramp-payout}/ # legacy EUR recovery phases - monerium-{issue,self-transfer}/ # active EUR issue/custody phases + monerium-{issue,self-transfer}/ # active EUR issue/self-transfer phases uniswap-v3-fixed-swap/ # pinned Polygon EURe/USDC conversion {evm,assethub}-offramp-source/ # source validation and tx plans fund-ephemeral/ # EVM/Substrate funding and source-hash checks @@ -607,7 +607,7 @@ Unmapped cases fail at quote resolution; there is no alternate engine: the persisted subsidy. Provider orders remain bound to those persisted terms; if an expired order cannot be replaced without degrading them, execution pauses before the single-use provider transfer - and leaves the funds on the client-custodied Polygon ephemeral for reconciliation. + and leaves the funds on the client-held Polygon ephemeral for reconciliation. ### Runtime ownership diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 4351ddded..439d1662d 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -38,7 +38,7 @@ 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. +a fiat theft path. The whole design follows from closing it. Supporting decisions, all in force: @@ -52,11 +52,11 @@ Supporting decisions, all in force: treasury. - **No upgradeability, ever.** Immutable-and-migratable: evolution (new tokens, a new router, contract fixes) happens by deploying a new implementation + factory and - migrating clients clone-by-clone — never by mutating deployed code. The custody - argument depends on it. Routes, the fee policy and the vault limits are bounded + migrating clients clone-by-clone — never by mutating deployed code. The design's + guarantees depend on it. Routes, the fee policy and the vault limits are bounded *data* the guardian may change within immutable validation, not code. - **No client key on the clone** (amended 2026-09-17; superseded the mandatory - self-custodied `fallbackAddress` of 2026-07-17 and its `sweep`/config functions and + client-controlled `fallbackAddress` of 2026-07-17 and its `sweep`/config functions and dead-man sweep). The only exits are the client's fixed `destination` and, for a payment the promised window was missed on, the client's refund wallet held by Vortex — see the amendments of 2026-09-17 and 2026-10-01. A destination change means a new clone (runbook §5). @@ -315,7 +315,7 @@ the promised window. Decisions: | 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 | **Identical to the link message** — already whitelisted, recovery works as built; `RECOVERY_HASH` stays 0 | | O1 | Client-migration tooling | Build when first needed; manual procedure in the runbook meanwhile | -| O2 | `FEE_RECIPIENT` treasury | **New dedicated Safe multisig** (immutable at implementation deploy); guardian key to hardware/multisig custody at GA | +| O2 | `FEE_RECIPIENT` treasury | **New dedicated Safe multisig** (immutable at implementation deploy); guardian key moves to hardware/multisig at GA | ## Review history (details in git history) @@ -352,8 +352,8 @@ example (oversized-deposit allocation). dormancy gate turns a rotation after 60 idle days into a pause, and an optional penny test catches a wrong address when run; a rotation on an account that keeps converting is not detected, and its forwards keep going to the old address. -- **Vortex custody on the refund path** (amendment 2026-09-17). A recovered payment - sits in Vortex's own wallet until the bank refund goes out; a compromised keeper plus +- **Refund path** (amendment 2026-09-17). A recovered payment waits on the client's + refund wallet until the bank refund goes out; a compromised keeper plus recovery key could divert a payment the window was missed on. Bounded by the immutable wallet, the on-chain delay, explicit amounts, a dedicated linked address holding nothing else, and the association monitor. @@ -388,7 +388,7 @@ example (oversized-deposit allocation). Zero-touch onboarding works end to end (validated against the Monerium sandbox: link accepted, IBAN issued, no client interaction). A Vortex outage can never trap converted funds (the permissionless path), and a payment the promised window was missed on is -refunded rather than parked, at the price of Vortex custody on that path. The cost: every rescue path must be designed in upfront +refunded rather than parked. The cost: every rescue path must be designed in upfront (no universal owner key), fee-policy increases are timelocked and venue changes are bounded by on-chain route validation rather than admin switches, the client's rate guarantee is enforced by the contract at the cost of a treasury-funded subsidy budget, diff --git a/docs/adr-0007-monerium-b2b-partner-registration.md b/docs/adr-0007-monerium-b2b-partner-registration.md index 0adacaa5e..a6dd978c1 100644 --- a/docs/adr-0007-monerium-b2b-partner-registration.md +++ b/docs/adr-0007-monerium-b2b-partner-registration.md @@ -14,7 +14,7 @@ The partner creates its clients' profiles and submits their KYB in its own Moner white-label app, so it holds each new client's Monerium profile ID; only it knows the client's payout wallet. Until now a Vortex operator deployed every forwarder with `cast` and mapped the account with an admin call, so every client waited on a manual step, and -the guardian key, which ADR-0005 intends to move to cold custody, was the only key that +the guardian key, which ADR-0005 intends to move to cold storage, was the only key that could deploy. ## Decision @@ -73,7 +73,7 @@ could deploy. it sends with implicit nonces like the keeper and the float wallet. Added before any Sepolia or mainnet factory exists, because the factory is not upgradeable. This does not make the guardian key cold: the backend still requires it at boot and signs the - dormancy pause (`setGuardianPaused`) with it, so that pause is the open custody item + dormancy pause (`setGuardianPaused`) with it, so that pause is the open guardian-key item of ADR-0005 O2 (a pause-only role, or an operator-run pause, before a cold guardian). The guardian can also revoke a clone: `revokeForwarder` is one-way and removes it from the registry (below). @@ -112,7 +112,7 @@ could deploy. Alternatives rejected: a `requested` status inside `monerium_accounts` (would weaken the invariant every account row relies on); an operator deploying from a registration queue -(keeps the manual step); deploying with the guardian or keeper key (custody goal, nonce +(keeps the manual step); deploying with the guardian or keeper key (cold-key goal, nonce interference); merging registrations into the account list (would change a typed response partners already read). diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 16d40f4dc..519cefaa0 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -25,7 +25,7 @@ The Sepolia sandbox follows its own procedure in the runbook (§8). (it would stay in flight forever and block its account) and no deposit-converted outbox delivery is still pending (it would replay without the `execution` block). 2. **Treasury first (O2):** create the dedicated fee Safe multisig — `FEE_RECIPIENT` is - immutable in the implementation. Confirm guardian key custody plan (EOA acceptable + immutable in the implementation. Confirm the guardian key storage plan (EOA acceptable for pilot; hardware/multisig at GA). 3. Re-verify the initial route's pools and fee tiers at the deploy block (P10) and re-run the liquidity baseline quote methodology (T6); confirm `perSwapCap` €10k still diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 4a3519a25..a4cab1df2 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -9,7 +9,7 @@ security invariants: Ground rules that shape every procedure here: -- **Vortex powers are bounded, not custodial by default.** Guardian/keeper can pause, +- **Vortex powers are bounded.** Guardian/keeper can pause, execute the policy (chunk swaps, one forward per payment) and — only for a payment whose batch has been open for `RECOVERY_DELAY` (2 h) — move that payment to the client's refund wallet fixed in the clone for a bank refund (§2.7). Nothing else can @@ -359,7 +359,7 @@ Trigger: a deposit the promised window was missed on (the deadline, `minSwapAmount`, a compliance decision, or a critical incident (§2.4). Prerequisites: the client's refund wallet (the clone's `recoveryAddress()`) is linked to the client's Monerium profile (onboarding does this, §1.5), `MONERIUM_B2B_REFUND_SEED` and the EURe -float wallet's key are in the operator's custody, and the float holds EURe and some ETH +float wallet's key are held by the operator, and the float holds EURe and some ETH (it also pays the refund wallet's gas). **Automation.** `MONERIUM_B2B_AUTO_RECOVERY` selects the mode: `off` (default) leaves @@ -553,7 +553,7 @@ monitor's alerts are expected, then: 5. Update the `monerium_accounts` row (forwarder address), re-activate (§1.7), then optionally penny-test the new clone: only an active account converts. -There is no unlink at Monerium and no custodial parking position: EURe mints to the +There is no unlink at Monerium and no parking position: EURe mints to the IBAN's current default address. The old clone stays linked, so a payer who names it in the SEPA memo still mints there; that payment is refunded by hand (§2.7, "Memo-routed payment"). @@ -936,7 +936,7 @@ Required results: - 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, +- It does not test production key storage, 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. diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 1e74e5a1c..6540c5ddf 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -317,7 +317,7 @@ provider-shaped rather than UI-shaped. `SubmittingUserTxs`. Once registration and signing updates are durably accepted, its owner-scoped recovery snapshot or backend transaction record survives selection changes and is available again when that owner is selected. - Ramp ephemeral storage is independent recovery custody and is never pruned or cleared by + Ramp ephemeral storage is independent recovery storage and is never pruned or cleared by manager/child selection. The customer can return to the same instructions while the payment window remains open. Once the instructions expire, **Get a new quote** clears only the local transfer state. Starting an diff --git a/docs/proposal-api-driven-kyc-kyb.md b/docs/proposal-api-driven-kyc-kyb.md index dc7c8a58d..8a72722fd 100644 --- a/docs/proposal-api-driven-kyc-kyb.md +++ b/docs/proposal-api-driven-kyc-kyb.md @@ -178,7 +178,7 @@ The requirements response is an executable workflow index, not a second schema s is authoritative for each operation's complete request, response, and error contract. Discovery adds only sequencing and fixed/derived value bindings to fields that OpenAPI already defines. The corridor-specific guide remains authoritative for behavioral details such as branching, retries, -custody, and asynchronous completion. +key handling, and asynchronous completion. The OpenAPI document covers the existing Avenia and Alfredpay KYC/KYB operations advertised by discovery, including API credential and managed-profile authentication. Discovery links to the diff --git a/docs/proposal-mcp-server.md b/docs/proposal-mcp-server.md index d8b936e37..e07ae7a51 100644 --- a/docs/proposal-mcp-server.md +++ b/docs/proposal-mcp-server.md @@ -154,7 +154,7 @@ Agent → start_ramp → {"phase":"prepareTransactions"} Agent → get_ramp_status (polls; renders phase in plain English) ``` -The security property: all dangerous mechanics (`sk_*` custody, ephemeral keys, the +The security property: all dangerous mechanics (`sk_*` handling, ephemeral keys, the presigned-variant rule) happen inside a process on the user's machine, invisible to the agent and never sent to us. diff --git a/docs/proposal-monerium-consumer-onramp.md b/docs/proposal-monerium-consumer-onramp.md index 114058b6f..a8735cc1a 100644 --- a/docs/proposal-monerium-consumer-onramp.md +++ b/docs/proposal-monerium-consumer-onramp.md @@ -195,7 +195,7 @@ The passkey works only via an origin under Vortex's RP ID. Mitigations: (a) the ### 8.3 Removed from v1 (F05) -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). +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 over the user's EURe. Stranded-EURe recovery in v1 = owner-signed redeem or withdrawal. Residual: loss of both owners + paused route strands EURe (disclosed; accepted). ## 9. Fees (F10) @@ -263,7 +263,7 @@ The public API exposes `MoneriumAccount` (long-lived) and per-deposit `FiatDepos - **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. +- **G2 — Legal/compliance sign-off (F15):** 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. @@ -281,11 +281,11 @@ The public API exposes `MoneriumAccount` (long-lived) and per-deposit `FiatDepos | ID | Disposition | Response / change | |---|---|---| -| 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 | +| 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 | | 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 | +| F05 | **Accept** | Correct on all points (module ≠ fallback handler; hash-only 1271 input; IBAN canonicalization; replay; link-message weakening; unilateral-disposal 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 | diff --git a/docs/security-spec/01-auth/supabase-otp.md b/docs/security-spec/01-auth/supabase-otp.md index b87284edf..9917a8e9b 100644 --- a/docs/security-spec/01-auth/supabase-otp.md +++ b/docs/security-spec/01-auth/supabase-otp.md @@ -30,7 +30,7 @@ Routes that accept either a Supabase session or a secret API credential verify t 8. **JWT expiry MUST be enforced** — Supabase tokens have a configurable expiry. The verification MUST reject expired tokens, not just validate the signature. 9. **Session teardown MUST happen only on confirmed-invalid refresh** — The frontend clears the stored session (and forces re-login) only when `/v1/auth/refresh` returns `401` (refresh token invalid/revoked). Transient failures (network errors, 5xx, timeouts) MUST NOT clear the session; they are retried while the existing session is preserved. The backend enforces this contract: `/v1/auth/refresh` returns `401` only for a definite invalid-token error from Supabase and returns `503` for transient/transport failures (and any unexpected error), so a Supabase outage cannot masquerade as an invalid token and log users out. 10. **Managed profiles MUST NOT have an OTP identity** — `profiles.kind = managed` requires a null email and represents no Supabase Auth identity. Provisioning must never create or associate a Supabase user for a managed profile; bearer middleware continues to trust authoritative Supabase token validation. -11. **Prototype browser token storage MUST be treated as XSS-readable** — The browser SDK demo stores access and refresh tokens in plaintext same-origin localStorage. It MUST NOT claim HttpOnly isolation. Its deployment origin requires CSP, dependency integrity, and XSS prevention, and it is not the hosted production-custody recommendation. +11. **Prototype browser token storage MUST be treated as XSS-readable** — The browser SDK demo stores access and refresh tokens in plaintext same-origin localStorage. It MUST NOT claim HttpOnly isolation. Its deployment origin requires CSP, dependency integrity, and XSS prevention, and it is not the hosted production key-storage recommendation. 12. **The sandbox demo restore MUST NOT alter authentication outcomes** — `verifyOTP` calls `restoreDemoAccountOnLogin(email)` after Supabase has already verified the OTP and the profile has been resolved. The hook returns immediately unless `DEPLOYMENT_ENV=sandbox` **and** the verified email equals `DEMO_ACCOUNT_EMAIL` (compared trimmed and lowercased), it never creates or elevates an identity — the profile must already exist, because `profiles.id` is the Supabase Auth UUID and cannot be forged — and every error it raises is caught and logged rather than propagated, so it can neither grant nor deny a login. See `docs/adr-0004-sandbox-demo-environment.md`. ## Threat Vectors & Mitigations @@ -38,7 +38,7 @@ Routes that accept either a Supabase session or a secret API credential verify t | Threat | Attack Scenario | Mitigation | |---|---|---| | **Stolen JWT** | Attacker intercepts a user's JWT (XSS, network sniffing) and replays it | Configured token expiry (1 week); TLS enforcement; HttpOnly cookies if applicable | -| **Browser demo XSS** | Same-origin script reads the demo refresh token, PIX instructions, ramp history, or SDK ephemeral backups from localStorage | This is an explicit prototype limitation. Restrict allowed origins, deploy a strict CSP, minimize same-origin third-party code, and use the hosted Widget when this custody model is unacceptable. | +| **Browser demo XSS** | Same-origin script reads the demo refresh token, PIX instructions, ramp history, or SDK ephemeral backups from localStorage | This is an explicit prototype limitation. Restrict allowed origins, deploy a strict CSP, minimize same-origin third-party code, and use the hosted Widget when this storage model is unacceptable. | | **Supabase service key leak** | Attacker obtains `SUPABASE_SERVICE_KEY` and gains broad administrative privileges | Access-token verification uses the least-privileged Auth client; service-role use is limited to administrative operations. The key remains server-only and independently rotatable. | | **Supabase outage** | Supabase is unreachable — verification calls fail | Both middleware variants fail closed with `503`; no fallback to anonymous or unverified access and no false invalid-token signal. | | **Email enumeration** | Attacker probes `/v1/auth/request-otp` or `/v1/auth/verify-otp` to discover registered emails | OTP requests pass through the Vortex API, which proxies to Supabase with `shouldCreateUser: true`, so `request-otp` returns the same success response whether or not the email is registered; the API's global per-IP rate limit and Supabase's OTP rate limits bound probing volume | diff --git a/docs/security-spec/02-signing-keys/ephemeral-accounts.md b/docs/security-spec/02-signing-keys/ephemeral-accounts.md index 1cfe9695e..f5bd6c0fa 100644 --- a/docs/security-spec/02-signing-keys/ephemeral-accounts.md +++ b/docs/security-spec/02-signing-keys/ephemeral-accounts.md @@ -20,7 +20,7 @@ Frontend and SDK Substrate RPC clients are initialized lazily. Creating/importin 1. **Ephemeral private keys MUST be generated client-side** — The API MUST never generate, receive, store, or have access to ephemeral private keys. Only addresses (`accountMetas`) are sent to the API. 2. **Ephemeral accounts MUST be used for a single ramp only** — Each ramp gets fresh accounts. Reusing ephemerals across ramps creates cross-contamination risk. 3. **The API MUST validate that submitted addresses are well-formed** — Before using an ephemeral address in transactions, the API must validate the address format for the respective chain (Substrate SS58, EVM hex). -4. **Ephemeral key storage (SDK) MUST fail closed, and built-in storage MUST be local-only** — The built-in `storeEphemeralKeys` function writes to the local filesystem in Node.js or same-origin localStorage in browsers; it MUST NOT transmit keys to the API, log them, or store them in any remote database. When the integrator configures `storeEphemeralKeysCallback`, the SDK hands the recovery material to that callback instead and custody of the destination shifts to the integrator; the SDK itself still MUST NOT transmit or log the keys. After the API creates a ramp, the SDK MUST await successful persistence (built-in or callback) before signing ephemeral-owned transactions or submitting the ramp update. A storage failure or callback rejection MUST reject `registerRamp()` and MUST NOT be swallowed; leaving the backend registration incomplete is safer than advancing without recoverable key material. +4. **Ephemeral key storage (SDK) MUST fail closed, and built-in storage MUST be local-only** — The built-in `storeEphemeralKeys` function writes to the local filesystem in Node.js or same-origin localStorage in browsers; it MUST NOT transmit keys to the API, log them, or store them in any remote database. When the integrator configures `storeEphemeralKeysCallback`, the SDK hands the recovery material to that callback instead and responsibility for the destination shifts to the integrator; the SDK itself still MUST NOT transmit or log the keys. After the API creates a ramp, the SDK MUST await successful persistence (built-in or callback) before signing ephemeral-owned transactions or submitting the ramp update. A storage failure or callback rejection MUST reject `registerRamp()` and MUST NOT be swallowed; leaving the backend registration incomplete is safer than advancing without recoverable key material. 5. **The API MUST NOT assume the ephemeral address belongs to an honest user** — An attacker could register a ramp with an address they don't control or an address that's a contract (on EVM). Phase handlers must account for this. 6. **Pre-signed transactions MUST be bound to the specific ephemeral address** — Transactions generated by the API for client signing must include the ephemeral address as the source/signer, not a wildcard. 7. **Ephemeral addresses MUST be proven fresh on every chain the ramp will sign on, at ramp registration time** — Before building any transactions, the API MUST verify on-chain that each submitted ephemeral address is fresh on every chain the ramp's route actually signs on. Freshness is chain-appropriate but MUST cover both nonce and balance: Substrate requires `nonce === 0 && free === 0`; EVM requires `nonce === 0 && native balance === 0` (a nonce-0 EVM account can still hold a funded native balance, so a nonce-only check is insufficient). The chain set MUST be derived from the quote (`quoteToSigningNetworks`), not the full supported list: validating chains the route never touches makes an unrelated RPC outage able to block every registration (an availability-hostility the earlier all-chains rule created). The route-to-chains mapping MUST be kept in sync with the route builders — under-listing a chain the ephemeral signs on silently reopens the freshness gap — and is pinned by `ephemeral-freshness.test.ts`. Freshness checks MUST fail closed: any RPC error rejects the registration with `503`. Reused ephemerals cause mid-ramp halt because the server assumes a clean nonce. **Known limitation:** only the native balance is checked on EVM; a nonce-0 account pre-loaded with ERC-20 tokens is not detected (enumerating tokens per chain is out of scope). diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index 170fdc1f3..962e80199 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -155,7 +155,7 @@ always occur only after all user-facing phases is incorrect. excludes the platform-funded settlement top-up, so it cannot leak subsidy to the user. Automated expired-order recovery does not broadcast that same-nonce contingency: when replacement cannot preserve the quote, execution pauses before - the provider transfer and leaves principal/top-up on the client-custodied Polygon + the provider transfer and leaves principal/top-up on the client-held Polygon ephemeral for authorized reconciliation. The onramp mint fallback stays full-mint. - **Rollout** — the fee phase shipped as flow version 2 of the three Alfredpay flows with a drain-then-deploy gate; persisted v1 identities fail closed at diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index a35d2beb0..0f5ff5bdf 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -51,7 +51,7 @@ AlfredPay's upstream provider quote is short-lived (~30 seconds). Block flows pr 2. **At ramp registration / prep time:** `AlfredpayMint.register` resolves the authenticated user's KYC-approved customer and stores it as phase-owned facts; it does not create an order. `AlfredpayOfframp.register` re-fetches a provider quote and creates the off-ramp order. - **Off-ramp:** the phase registration hook compares the currency pair, `fromAmount`, `toAmount`, and fee exactly. If identical, only that phase's `quoteId` and `expirationDate` are replaced within the registration transaction before the order is created. Proven pre-order drift is recorded as a retryable rejected financial operation; provider-order creation alone is the ambiguous side effect. The created order is validated against the same persisted pair/input/output before registration can commit. 3. **At on-ramp start:** the resolved flow's optional `start` lifecycle invokes `AlfredpayMint.start`, which refreshes the provider quote immediately before order creation. If the new response is byte-identical on `toAmount` and `fee`, the platform transactionally substitutes the new provider `quoteId`; otherwise it retains the original ID and preserves the bounded fallback behavior (see `alfredOnrampMintFallback`). The same transaction persists the provider transaction ID and payment instructions. Update returns newly created payment instructions; repeated update/start calls are no-ops once the transaction ID exists. -4. **Offramp expired-quote recovery** (`phases/blocks/phases/alfredpay-offramp/execution.ts`): if the registered provider order expires before transfer, the executor requests a replacement quote for the immutable provider input/output stored on the Vortex quote; the mutable expired-order response and flattened compatibility identity projections cannot weaken the block-owned floor. It creates a replacement order only when transaction ID, MATIC chain, customer, fiat account, deposit address, currencies/input, and payout remain bound, both create and re-read responses remain `CREATED`, and both retain at least two minutes of safe lifetime. Replacement creation uses a bounded, transaction-ID-derived financial-operation key so confirmed responses replay before inspecting the stale predecessor after a local persistence failure, while unknown provider outcomes pause for reconciliation instead of creating duplicates; the active provider ID changes only after every validation succeeds. If no safe replacement exists, execution pauses before the single-use provider transfer and leaves funds on the client-custodied Polygon ephemeral. A confirmed main-transfer journal is replayed before any mutable provider-term or lifecycle inspection, so a crash before hash persistence cannot trigger a competing same-nonce contingency; without such a journal, only a `CREATED` order may receive the first transfer, and post-transfer polling continues to validate the same immutable identity and payout. +4. **Offramp expired-quote recovery** (`phases/blocks/phases/alfredpay-offramp/execution.ts`): if the registered provider order expires before transfer, the executor requests a replacement quote for the immutable provider input/output stored on the Vortex quote; the mutable expired-order response and flattened compatibility identity projections cannot weaken the block-owned floor. It creates a replacement order only when transaction ID, MATIC chain, customer, fiat account, deposit address, currencies/input, and payout remain bound, both create and re-read responses remain `CREATED`, and both retain at least two minutes of safe lifetime. Replacement creation uses a bounded, transaction-ID-derived financial-operation key so confirmed responses replay before inspecting the stale predecessor after a local persistence failure, while unknown provider outcomes pause for reconciliation instead of creating duplicates; the active provider ID changes only after every validation succeeds. If no safe replacement exists, execution pauses before the single-use provider transfer and leaves funds on the client-held Polygon ephemeral. A confirmed main-transfer journal is replayed before any mutable provider-term or lifecycle inspection, so a crash before hash persistence cannot trigger a competing same-nonce contingency; without such a journal, only a `CREATED` order may receive the first transfer, and post-transfer polling continues to validate the same immutable identity and payout. 5. **Dashboard client refresh:** dashboard quote queries derive their refresh time from the response's `createdAt` and `expiresAt`, refreshing when 60% of validity remains and on window focus. The transfer machine independently repeats this freshness check before registration and registers with the replacement when one is required, preventing an expired quote from reaching registration after a background-tab delay or confirmation race. The refresh policy is intentionally strict. Onramps require byte-identical `toAmount` and fee. Offramp registration additionally requires byte-identical `fromAmount`; expired-order replacement may improve but never reduce the persisted fiat payout. Drift forces the on-ramp into the bounded fallback path, aborts off-ramp registration, or rejects degraded execution-time recovery. diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index d9c4aee8e..50392dbff 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -5,7 +5,7 @@ Each ramp operation executes as a sequence of phases, where each phase performs one discrete action: a swap, a bridge transfer, an XCM message, a payment, or a subsidization top-up. The phase sequence determines the exact path tokens take from source to destination. Different ramp corridors (e.g., EUR→ARS, BRL→USDC, EUR→BRL) use different phase sequences because they traverse different chains and integrations. Understanding the complete token flow for each corridor is critical for security because: -1. **Funds change custody at each phase** — tokens move between user ephemeral accounts, platform funding accounts, DEX contracts, bridge vaults, and integration provider wallets. +1. **Funds change hands at each phase** — tokens move between user ephemeral accounts, platform funding accounts, DEX contracts, bridge vaults, and integration provider wallets. 2. **Each phase handler submits presigned or server-signed transactions** — incorrect ordering or skipped phases can leave funds in intermediate accounts. 3. **Subsidy phases inject platform funds** — the platform tops up ephemeral accounts to cover gas, bridging fees, or amount shortfalls, creating a direct drain vector if amounts are unchecked. @@ -52,9 +52,9 @@ Local manual flow testing may set `MOCK_ANCHOR_OPERATIONS=true`. In development, executors replace partner polling with an on-chain ephemeral balance wait for the exact simulated mint amount. The offramp block executors raise a recoverable, zero-retry pause at `brlaPayoutOnBase` or `alfredpayOfframpTransfer` before reading partner state or broadcasting the anchor-bound transfer. The ramp remains in the payout phase and is -not cleanup-eligible, leaving the client-custodied ephemeral key available for fund recovery. The switch is active +not cleanup-eligible, leaving the client-held ephemeral key available for fund recovery. The switch is active only when `NODE_ENV=development`. -- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow v3/context schema 2). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Final transfer and contingency fallback share Polygon nonce 0, with fee transfers following on the main nonce lane. Metadata keeps source-labelled Vortex reference, provider, and customer all-in observations. The Vortex reference remains the target source; the block uses AlfredPay's executable exact-output terms locally to solve the provider deposit, then best-effort caps the actual settlement top-up. If expired-order replacement cannot preserve the persisted promise, execution pauses before the provider transfer with funds on the client-custodied ephemeral. +- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow v3/context schema 2). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Final transfer and contingency fallback share Polygon nonce 0, with fee transfers following on the main nonce lane. Metadata keeps source-labelled Vortex reference, provider, and customer all-in observations. The Vortex reference remains the target source; the block uses AlfredPay's executable exact-output terms locally to solve the provider deposit, then best-effort caps the actual settlement top-up. If expired-order replacement cannot preserve the persisted promise, execution pauses before the provider transfer with funds on the client-held ephemeral. - **Degenerate Polygon same-token onramp case:** Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) on Polygon. `AlfredpayOnrampDirect` composes a Squid passthrough block when the requested output is that same token and a same-chain Squid block for every other Polygon output. Both continue through `finalSettlementSubsidy`, `destinationTransfer`, and `distributeFees` (flow version 2). See `05-integrations/alfredpay.md`. - **Amount precision on routed Alfredpay onramps:** when Alfredpay mints on Polygon and the user requests a different EVM output token, the routed Squid output is the final settlement amount. `evmToEvm.inputAmountRaw` remains the Polygon source-token raw amount, while `evmToEvm.outputAmountRaw` and `quote.outputAmount` MUST use the final destination token's raw/decimal precision. The direct Polygon same-token case remains at the minted token's precision. - **Alfredpay offramp always runs `finalSettlementSubsidy`:** `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. No executor short-circuits this sequence. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 27a08402e..b0212137d 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -57,7 +57,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 6. **Alfredpay API responses MUST be validated** — Status codes, transaction IDs, and amounts confirmed before phase advancement. 7. **Alfredpay interactions MUST be retryable** — Transient failures should use `RecoverablePhaseError`. 8. **Provider quote refresh MUST be strict** — At on-ramp start, immediately before order creation, `AlfredpayMint.start` re-binds the provider `quoteId` only when the new provider response is byte-identical on `toAmount` and `fee`. Any drift keeps the original quote ID and preserves the bounded fallback path. -9. **Off-ramp expired-quote recovery MUST preserve the persisted promised payout and re-create only the AlfredPay order** — `phases/blocks/phases/alfredpay-offramp/execution.ts` re-quotes the immutable provider input/output stored on the Vortex quote; neither the mutable expired transaction nor a replacement order may weaken that floor. It may re-issue `createOfframp` only when transaction ID, MATIC chain, canonical block-owned customer and fiat-account identity, deposit address, currencies/input, and `toAmount >= promised toAmount` remain bound, both the create response and re-read order remain `CREATED`, and both retain at least two minutes of safe lifetime, with the funding origin fixed to the EVM ephemeral. Replacement creation is journaled under a bounded hash-derived attempt key, and a confirmed replacement replays before mutable observations of its stale predecessor; the active transaction ID is updated only after every validation passes. If terms degrade, it MUST NOT broadcast either same-nonce transfer: the phase pauses with funds on the client-custodied ephemeral. Once the main transfer operation is submitted/confirmed, its deterministic journal replay takes priority over all later provider-term observations; every post-transfer poll still validates the immutable order identity and payout before completion. +9. **Off-ramp expired-quote recovery MUST preserve the persisted promised payout and re-create only the AlfredPay order** — `phases/blocks/phases/alfredpay-offramp/execution.ts` re-quotes the immutable provider input/output stored on the Vortex quote; neither the mutable expired transaction nor a replacement order may weaken that floor. It may re-issue `createOfframp` only when transaction ID, MATIC chain, canonical block-owned customer and fiat-account identity, deposit address, currencies/input, and `toAmount >= promised toAmount` remain bound, both the create response and re-read order remain `CREATED`, and both retain at least two minutes of safe lifetime, with the funding origin fixed to the EVM ephemeral. Replacement creation is journaled under a bounded hash-derived attempt key, and a confirmed replacement replays before mutable observations of its stale predecessor; the active transaction ID is updated only after every validation passes. If terms degrade, it MUST NOT broadcast either same-nonce transfer: the phase pauses with funds on the client-held ephemeral. Once the main transfer operation is submitted/confirmed, its deterministic journal replay takes priority over all later provider-term observations; every post-transfer poll still validates the immutable order identity and payout before completion. 10. **KYB and KYC status mapping MUST be branched by `AlfredpayCustomerType`** — Business customers use `mapKybStatus`; individuals use `mapKycStatus`. Treating one as the other would allow incomplete due-diligence states to pass as `Success`. 11. **Polygon passthrough MUST preserve amount integrity** — `AlfredpayOnrampDirect` selects `SquidRouterPassthrough` only for the same-chain same-token case. The passthrough MUST round down (`toFixed(0, 0)`) and use its phase-owned input amount as the source of truth. 12. **The Polygon passthrough MUST be gated on the output token, not on the destination network alone** — Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) directly on Polygon, so `flows/alfredpay-onramp-direct.ts` composes `SquidRouterPassthrough` only when the requested output token is `ALFREDPAY_EVM_TOKEN`. Any other Polygon output composes `SameChainSquidRouterSwap`; gating on network alone would mis-deliver USDT instead of the requested asset. diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index 3e2550ad4..0e6839c3f 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -17,7 +17,7 @@ The block catalog owns the subsidization and settlement executors across Substra The pre/post executors dispatch by the block's chain context. The generic EVM post-swap block persists its typed source network and uses it consistently for balances, funding, transfer, operation identity, and bookkeeping; only legacy metadata without that field falls back to Base. The EVM pre-swap branch tops the ephemeral up before `nablaSwap` and enforces a quote-relative cap of the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`) × quote output. The EVM post-swap branch splits the required top-up into a swap-discrepancy component and a discount component: the actual-vs-quoted swap-output discrepancy is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output. Discount-derived top-ups below $1 bypass the separate runtime percentage safety cap; top-ups of $1 or more are capped by `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (default `0.05`). Quote-time partner `maxSubsidy` remains enforced for every amount. -AlfredPay SELL pre-calculates its expected final-settlement top-up from the executable provider input, canonical fee reserve, and the existing quoted Squid output. Quote simulation limits the top-up to the lower of the partner allowance and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`; when the target needs more, it lowers the provider input/output and logs a capped-target warning. Runtime settlement funds the quote's persisted subsidy plus the same bridge shortfall the arrival threshold already accepts, and refuses anything beyond that, so unexpected bridge under-delivery cannot consume treasury past the delivery gate; `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` remains the absolute bound on the total. If provider terms can no longer preserve the promised payout, execution pauses before the provider transfer and leaves funds on the client-custodied Polygon ephemeral for reconciliation. +AlfredPay SELL pre-calculates its expected final-settlement top-up from the executable provider input, canonical fee reserve, and the existing quoted Squid output. Quote simulation limits the top-up to the lower of the partner allowance and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`; when the target needs more, it lowers the provider input/output and logs a capped-target warning. Runtime settlement funds the quote's persisted subsidy plus the same bridge shortfall the arrival threshold already accepts, and refuses anything beyond that, so unexpected bridge under-delivery cannot consume treasury past the delivery gate; `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` remains the absolute bound on the total. If provider terms can no longer preserve the promised payout, execution pauses before the provider transfer and leaves funds on the client-held Polygon ephemeral for reconciliation. **How subsidization works:** 1. Read the ephemeral account's current balance diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 846772c2f..55b55342e 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -27,7 +27,7 @@ register and the owning module specification. | RISK-003 | Accepted | Medium | Product + Security | Pending recipient invitations retain the raw bearer token so the sender can re-copy the link. | 192-bit random token, 14-day TTL, hash-only redemption lookup, sender-scoped listing, optional email binding, first-redeemer binding, raw token cleared on acceptance/observed expiry. | Revisit if invitations gain money-movement authority or threat exposure changes. | | RISK-004 | Deferred | High | Product + Payments Architecture | Recipient eligibility is advisory; recipient-directed payout is unsupported. Ramp registration is a sender self-offramp and rejects common recipient-context fields. | Authenticated/entity-scoped recipient APIs; explicit registration rejection prevents accidental reliance on ignored fields. | A separate PR must define the second principal, relationship ownership, hard eligibility gate, and provider-side payout reference resolution before enabling recipient payout. | | RISK-005 | Accepted | Medium | Product + Operations | The product promises the exact quoted amount. A ramp does not downgrade that promise or report a lesser amount as successful when automated delivery cannot complete. A funded EVM SELL ramp that the client never started is started by the recovery worker roughly 16-21 minutes after registration (up to three days when starts retry), so the platform absorbs Nabla price drift over that longer gap only within the caps below; a SELL whose user broadcast after the deadline or never reported the hash is not started and needs manual recovery. | Exact quote-bound targets, balance checks, capped subsidy paths (per-ramp subsidy caps, Nabla 5% on-chain hard minimum, pre-broadcast dry-run), recoverable/terminal phase states, reconciliation data. Worker-driven start requires a reported source hash that `fundEphemeral` verifies on-chain before any platform spend. | Add a formal deadline and automatic return of in-transit funds without weakening the exact-amount promise. | -| RISK-006 | Accepted | Medium | Client Platform | Widget/dashboard recovery keys are retained until a terminal ramp state is observed, then for 90 days; unresolved ramps are retained indefinitely. The prototype browser SDK backup has no automatic terminal pruning and remains in plaintext same-origin localStorage until the integrator removes it. | Route-scoped freshness; widget/dashboard pruning on storage access; explicit browser-SDK documentation and origin allowlisting. | Add a browser storage adapter and terminal-aware pruning before presenting browser SDK custody as a hardened production default, or sooner if storage pressure or client compromise data warrants it. | +| RISK-006 | Accepted | Medium | Client Platform | Widget/dashboard recovery keys are retained until a terminal ramp state is observed, then for 90 days; unresolved ramps are retained indefinitely. The prototype browser SDK backup has no automatic terminal pruning and remains in plaintext same-origin localStorage until the integrator removes it. | Route-scoped freshness; widget/dashboard pruning on storage access; explicit browser-SDK documentation and origin allowlisting. | Add a browser storage adapter and terminal-aware pruning before presenting browser SDK key storage as a hardened production default, or sooner if storage pressure or client compromise data warrants it. | | RISK-007 | Deployment pending | High | Smart Contracts + Operations | TokenRelayer source rejects fee-on-transfer shortfalls, partial consumption, codeless destinations, and cross-execution balance subsidy. Existing deployed addresses do not inherit the fix. Automatic discrepancy subsidy is intentionally absent because no immutable cap/funding policy has been approved. | Execution-local token/native balance accounting, exact transient allowance, refund attribution, events, contract tests. | Redeploy and verify bytecode on every supported chain, update the address registry, retire old deployments, and record rollout evidence. | | RISK-008 | Accepted | High | Payments Platform | Squid/Axelar terminal status is preferred, but an EVM destination-balance fallback remains necessary because provider indexing can miss real arrivals. The fallback waits for baseline plus 90% of the exact route output; a remainder racing the final pre-claim balance read and broadcast can still overfund the ephemeral. | Route/source/token/amount/baseline-bound persisted evidence, explicit fallback kind and ratio, structured logging, per-ramp settlement cap, and two live shortfall reads inside the funding FIFO before the durable claim. This prevents queue delay from making the subsidy stale. | Add provider receipt proof or late-arrival reconciliation/recovery before raising caps or expanding exposure. | | RISK-009 | Deferred | High | Cross-chain Platform | Dormant BRL↔AssetHub executors retain XCM evidence exceptions: Pendulum→AssetHub re-entry trusts an internally persisted finalized source block without proving AssetHub arrival, and Pendulum→Moonbeam recovery may use source depletion. | Quote creation, registration, presign updates, start, phase execution, and automatic recovery reject, hold, or skip Moonbeam-dependent ramps; the catalog remains only for persisted schema/history compatibility. | Destination receipt/balance-delta proof and durable ambiguous-broadcast recovery are release blockers before re-enabling execution or automatic recovery. | From 392c85324a27da75210152606112d4448c9339e4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 11:20:24 +0200 Subject: [PATCH 4/7] docs(repo): move the operator runbooks to the private repository --- CLAUDE.md | 3 + apps/api/.env.example | 2 +- apps/api/scripts/monerium-b2b-sandbox-e2e.ts | 2 +- apps/api/scripts/seed-demo-account.ts | 2 +- .../services/demo/demo-account.constants.ts | 2 +- .../api/services/demo/demo-account.service.ts | 2 +- .../api/services/monerium-b2b/monitoring.ts | 6 +- apps/api/src/config/vars.ts | 2 +- docs/README.md | 8 +- docs/adr-0004-sandbox-demo-environment.md | 2 +- docs/adr-0005-monerium-b2b-onramp.md | 4 +- ...-0007-monerium-b2b-partner-registration.md | 2 +- docs/architecture-identity-model.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 4 +- docs/operations-brl-tax-id-claim-release.md | 117 -- docs/operations-demo-environment.md | 101 -- docs/operations-legacy-schema-cleanup.md | 136 -- docs/operations-monerium-b2b-rollout.md | 88 -- docs/operations-monerium-b2b-runbook.md | 1226 ----------------- docs/security-spec/05-integrations/brla.md | 2 +- .../05-integrations/monerium-b2b.md | 2 +- docs/security-spec/RISK-REGISTER.md | 4 +- 22 files changed, 25 insertions(+), 1694 deletions(-) delete mode 100644 docs/operations-brl-tax-id-claim-release.md delete mode 100644 docs/operations-demo-environment.md delete mode 100644 docs/operations-legacy-schema-cleanup.md delete mode 100644 docs/operations-monerium-b2b-rollout.md delete mode 100644 docs/operations-monerium-b2b-runbook.md diff --git a/CLAUDE.md b/CLAUDE.md index 4548585a4..8530723c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,9 @@ update the existing canonical document when one owns the topic. - Never create or extend a document whose audience includes a client or partner, or that tracks commercial gates, terms or negotiation status: it belongs in the private `pendulum-chain/vortex-private` repository (see "Public Repository: No Client Data"). +- Operator runbooks live in `operations/` of the private `pendulum-chain/vortex-private` + repository, not here. Cite them by file name and section; when a change alters a + documented procedure, update the runbook there in a follow-up pull request. - Repair indexes and relative links in the same change as a move or deletion. - Agent skills live in `.agents/skills//SKILL.md` so every coding agent can use them; `.claude/skills/` holds only symlinks to those directories (Claude Code follows diff --git a/apps/api/.env.example b/apps/api/.env.example index 71fc7e818..80196786c 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,7 +9,7 @@ BROWSER_SDK_ORIGINS= # Environment Configuration SANDBOX_ENABLED=false -# Sales-demo account (sandbox only) — see docs/operations-demo-environment.md +# Sales-demo account (sandbox only) — see operations/operations-demo-environment.md in pendulum-chain/vortex-private DEMO_ACCOUNT_EMAIL=demo@satoshipay.io # Serves Alfredpay KYB from canned in-process responses so the demo corridor can be # re-onboarded endlessly. Refuses to start unless DEPLOYMENT_ENV=sandbox. diff --git a/apps/api/scripts/monerium-b2b-sandbox-e2e.ts b/apps/api/scripts/monerium-b2b-sandbox-e2e.ts index ab292e563..a394fb698 100644 --- a/apps/api/scripts/monerium-b2b-sandbox-e2e.ts +++ b/apps/api/scripts/monerium-b2b-sandbox-e2e.ts @@ -1,6 +1,6 @@ /** * End-to-end run of the Monerium B2B onramp against a live backend, the sandbox or a local - * rehearsal (docs/operations-monerium-b2b-runbook.md §8). It registers a client's destination + * rehearsal (B2B operations runbook §8, pendulum-chain/vortex-private). It registers a client's destination * the way the partner does, waits until the keeper has deployed, linked and activated the * account, then waits for one EUR payment and checks on chain that it was converted and * forwarded, or refunded with --refund. diff --git a/apps/api/scripts/seed-demo-account.ts b/apps/api/scripts/seed-demo-account.ts index bef35be91..37585186b 100644 --- a/apps/api/scripts/seed-demo-account.ts +++ b/apps/api/scripts/seed-demo-account.ts @@ -5,7 +5,7 @@ * * Sandbox only — the restore routine itself refuses to run anywhere else. The demo profile * must have signed in once via OTP first; the seed cannot forge a Supabase Auth user. - * See docs/operations-demo-environment.md. + * See operations/operations-demo-environment.md in pendulum-chain/vortex-private. */ import path from "node:path"; import dotenv from "dotenv"; diff --git a/apps/api/src/api/services/demo/demo-account.constants.ts b/apps/api/src/api/services/demo/demo-account.constants.ts index 2ca332575..0eae60562 100644 --- a/apps/api/src/api/services/demo/demo-account.constants.ts +++ b/apps/api/src/api/services/demo/demo-account.constants.ts @@ -3,7 +3,7 @@ * carries one of these ids, so restore is idempotent and can never touch a real ramp, * recipient, or provider record created during a demo. * - * See docs/operations-demo-environment.md. + * See operations/operations-demo-environment.md in pendulum-chain/vortex-private. */ /** All demo-owned primary keys share this prefix. */ diff --git a/apps/api/src/api/services/demo/demo-account.service.ts b/apps/api/src/api/services/demo/demo-account.service.ts index 199a30ac9..9649b34f0 100644 --- a/apps/api/src/api/services/demo/demo-account.service.ts +++ b/apps/api/src/api/services/demo/demo-account.service.ts @@ -49,7 +49,7 @@ export interface DemoRestoreSummary { * rows. Every write is keyed by a fixed demo id, so running it twice is the same as * running it once and it can never overwrite a real ramp created during a demo. * - * Sandbox only. See docs/operations-demo-environment.md. + * Sandbox only. See operations/operations-demo-environment.md in pendulum-chain/vortex-private. */ export async function restoreDemoAccount(): Promise { if (config.deploymentEnv !== "sandbox") { diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index 00b1ef148..bc553b27b 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -344,9 +344,7 @@ export async function runExecutableDepthCheck(): Promise { const verdict = classifyExecutableDepth(best.minImpactBps, best.capImpactBps, slippageBps); if (verdict.severity === "error") { - logger.error( - `monerium-b2b: DEPTH BELOW FLOOR — ${verdict.reason}; triage per docs/operations-monerium-b2b-runbook.md §3. ${detail}` - ); + logger.error(`monerium-b2b: DEPTH BELOW FLOOR — ${verdict.reason}; triage per the B2B operations runbook §3. ${detail}`); } else if (verdict.severity === "warn") { logger.warn(`monerium-b2b: ${verdict.reason}. ${detail}`); } else { @@ -417,7 +415,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/operations-monerium-b2b-runbook.md). + * (B2B operations runbook, pendulum-chain/vortex-private). */ 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 467bea816..e22b00fa5 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -166,7 +166,7 @@ function readRecipientInviteDiscountLimit(): number { interface Config { env: string; deploymentEnv: DeploymentEnv; - /** Login email of the seeded sales-demo account. Sandbox only; see docs/operations-demo-environment.md. */ + /** Login email of the seeded sales-demo account. Sandbox only; see operations/operations-demo-environment.md in pendulum-chain/vortex-private. */ demoAccountEmail: string; /** * Replaces the Alfredpay client with a canned in-process stand-in so the demo corridor can be diff --git a/docs/README.md b/docs/README.md index afcadcb70..e5c149b3f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,11 +26,6 @@ The smaller set of general project documents stays directly in `docs/`: | [`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-brl-tax-id-claim-release.md`](operations-brl-tax-id-claim-release.md) | Operator runbook for releasing a BRL tax ID squatted through `createSubaccount` (RISK-026) | -| [`operations-demo-environment.md`](operations-demo-environment.md) | Setup and runbook for the sandbox sales-demo account | -| [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | -| [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md) | Deploy checklist, launch readiness, and open items ledger for the B2B onramp | -| [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md) | Operator procedures for the B2B onramp: onboarding, incidents, alert triage, dormancy, migration | | [`operations-monerium-interface.md`](operations-monerium-interface.md) | Monerium onramp boundary across the OAuth and white-label apps plus the profile, address, IBAN, and payment interface reference | | [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | @@ -88,6 +83,9 @@ Do not add: - completed implementation plans or refactor summaries; - a second architecture document for behavior already owned by `security-spec/`; - an in-repository archive of stale docs. Git history is the archive; +- operator runbooks (incident, rollout, environment and migration procedures). They live + in `operations/` of the private `pendulum-chain/vortex-private` repository; cite them + by file name and section (for example "runbook §2.7"); - anything that names clients, partners or prospects, or records agreement or negotiation status, terms agreed with a specific party, onboarding status or contacts. This repository is public: documents written for a partner and rollout trackers with diff --git a/docs/adr-0004-sandbox-demo-environment.md b/docs/adr-0004-sandbox-demo-environment.md index 62c6eba6c..c2b813272 100644 --- a/docs/adr-0004-sandbox-demo-environment.md +++ b/docs/adr-0004-sandbox-demo-environment.md @@ -1,7 +1,7 @@ # ADR 0004: Sandbox Sales-Demo Account Status: accepted 2026-08-07. Implemented in `apps/api/src/api/services/demo/`; the runbook -is [`operations-demo-environment.md`](operations-demo-environment.md). +is [`operations-demo-environment.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-demo-environment.md) (private repository). ## Context diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 439d1662d..94e2714ef 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -12,8 +12,8 @@ acceptances* of the B2B EUR → USDC onramp. How the system works lives in invariants and the threat model in [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md); launch gates and the deploy checklist in -[`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md); procedures in -[`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). The +[`operations-monerium-b2b-rollout.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/partner-docs/monerium-b2b/operations-monerium-b2b-rollout.md) (private repository); procedures in +[`operations-monerium-b2b-runbook.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-monerium-b2b-runbook.md) (private repository). The consumer-flow design this grew out of remains a phase-2 proposal: [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md). diff --git a/docs/adr-0007-monerium-b2b-partner-registration.md b/docs/adr-0007-monerium-b2b-partner-registration.md index a6dd978c1..169c4ea2c 100644 --- a/docs/adr-0007-monerium-b2b-partner-registration.md +++ b/docs/adr-0007-monerium-b2b-partner-registration.md @@ -6,7 +6,7 @@ invariants in [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md) (0, 12, 15 and the key separation), procedures in -[`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md) §1, §6 and §8. +[`operations-monerium-b2b-runbook.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-monerium-b2b-runbook.md) (private repository) §1, §6 and §8. ## Context diff --git a/docs/architecture-identity-model.md b/docs/architecture-identity-model.md index 0d7c3c1aa..ee767a801 100644 --- a/docs/architecture-identity-model.md +++ b/docs/architecture-identity-model.md @@ -204,7 +204,7 @@ quote cannot be claimed by another user. - Provider ownership resolution: `apps/api/src/api/services/avenia-account.ts` and provider controllers/services - Schema history: `apps/api/src/database/migrations/038-*` onward - Managed-profile schema: `apps/api/src/database/migrations/063-create-managed-profiles.ts` -- Migrations 060-061 production gates: [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) +- Migrations 060-061 production gates: [`operations-legacy-schema-cleanup.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-legacy-schema-cleanup.md) (private repository) - Security details: `docs/security-spec/01-auth/`, `03-ramp-engine/recipient-transfers.md`, and the provider specs under `05-integrations/` Update this document only when the cross-module shape changes. Provider-specific flows, diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 7b33c4bc1..15a8fdb5f 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -5,8 +5,8 @@ clients. Normative security detail lives in [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.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). +launch gates in [`operations-monerium-b2b-rollout.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/partner-docs/monerium-b2b/operations-monerium-b2b-rollout.md) (private repository); +operator procedures in [`operations-monerium-b2b-runbook.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-monerium-b2b-runbook.md) (private repository). ## The shape in one paragraph diff --git a/docs/operations-brl-tax-id-claim-release.md b/docs/operations-brl-tax-id-claim-release.md deleted file mode 100644 index 937749783..000000000 --- a/docs/operations-brl-tax-id-claim-release.md +++ /dev/null @@ -1,117 +0,0 @@ -# Releasing A Squatted BRL Tax ID - -Status: current operator runbook. It backs RISK-026 in -[`security-spec/RISK-REGISTER.md`](security-spec/RISK-REGISTER.md); the behavior it works -around is specified in [`security-spec/05-integrations/brla.md`](security-spec/05-integrations/brla.md) -(invariant 48). - -## When to use it - -`POST /v1/brl/createSubaccount` reserves a CPF/CNPJ for the first authenticated caller. The -unique index `ux_provider_customers_tax_hash` makes that reservation exclusive at once, before -the provider has verified who the caller is. If someone claimed a tax ID that is not theirs, the -real owner's `createSubaccount` fails with `409 A subaccount already exists for this taxId` and -has no self-service recovery. Use this runbook when a person (or company) shows that a tax ID -they own is held by a profile that is not theirs. - -Do not use it when the holder's row is `approved`: the provider matched documents to that tax -ID, so the holder is most likely the genuine owner. Escalate to engineering and compliance. - -## Ground rules - -- Reads use the read-only connection. Changes need a write connection and a second person who - reviews the verified row IDs before the change runs. -- Record the tax ID only as its hash in tickets and logs. Never paste the raw CPF/CNPJ. -- Run the change in one transaction and check every affected-row count against the read steps - before committing. - -## 1. Identify the claim - -Compute the hash the same way `hashTaxReference` does (SHA-256 of the digits-only value) and look -up the holder: - -```sql -SELECT encode(sha256(convert_to('', 'UTF8')), 'hex') AS tax_hash; - -SELECT pc.id, pc.customer_type, pc.status, pc.status_external, pc.provider_subaccount_id, - pc.created_at, ce.profile_id AS holder_profile_id -FROM provider_customers pc -JOIN customer_entities ce ON ce.id = pc.customer_entity_id -WHERE pc.provider = 'avenia' AND pc.tax_reference_hash = ''; -``` - -Expect exactly one row and a status other than `approved`. Note `pc.id`, -`provider_subaccount_id` and `holder_profile_id`. - -## 2. Check what the holder has started - -```sql -SELECT id, type, status, status_external, provider_case_id, submitted_at, - verification_method, verification_submission->>'status' AS submission_status -FROM kyc_cases -WHERE provider = 'avenia' AND provider_customer_id = ''; - -SELECT id, current_phase, created_at FROM ramp_states WHERE user_id = ''; -``` - -- A case with `provider_case_id` set, `in_review` status, or a `submitted`/`confirmed` submission - means an attempt exists at the provider. Ask the provider about that attempt before going on; - do not release while it could still be approved for this tax ID. -- The ramp query is expected to return no rows: onboarding must be approved before a ramp can be - registered. Stop and escalate if it returns any. - -## 3. Find the exactly-once claim - -`createSubaccount` records a `financial_operations` row keyed by the tax hash. If it is left in -place, the real owner's retry hits it with a different request hash (the owner profile is part -of the request) and fails with `409 ... already claimed with different inputs`. - -```sql -SELECT id, status, external_id, created_at -FROM financial_operations -WHERE scope_type = 'profile' AND scope_id = '' - AND phase = 'createSubaccount' AND provider = 'avenia'; -``` - -Expect exactly one row whose `external_id` equals the `provider_subaccount_id` from step 1. Any -other result means the state is not the expected squat; stop and escalate. - -One variant is still a squat: step 1 returns no row, but this query returns a row. The holder's -provider call or local write did not finish, so only the claim remains and it blocks the real -owner the same way. If its `status` is `submitted` or `unknown`, a provider subaccount may exist -without any local record; ask the provider before deleting the row, and treat `external_id` (if -set) as the subaccount for step 4. Skip the `provider_customers` and `kyc_cases` deletes in -step 5. - -## 4. Decide on the provider subaccount - -Vortex has no call to close or delete a provider subaccount, and the create call carries only -account type and name, so the provider learns the tax ID only if a KYC attempt was submitted -(step 2). Ask the provider to close or flag the subaccount id from step 1, or record it as -abandoned in the operations log. Never reassign it to the real owner: it carries the holder's -unapproved state and data. The real owner's retry creates a fresh subaccount. - -## 5. Release - -Only after steps 1-4 match, in one transaction: - -```sql -BEGIN; -DELETE FROM kyc_cases - WHERE provider = 'avenia' AND provider_customer_id = '' AND status <> 'approved'; -- rows from step 2 -DELETE FROM provider_customers - WHERE id = '' AND provider = 'avenia' AND status <> 'approved'; -- exactly 1 -DELETE FROM financial_operations - WHERE id = '' AND scope_id = '' AND phase = 'createSubaccount'; -- exactly 1 --- compare each reported count with the read steps, then COMMIT or ROLLBACK -``` - -The holder's `customer_entities` row is the profile's own entity and stays. Decide separately -whether the holder profile should be suspended under the abuse policy. - -## 6. Verify and follow up - -- Ask the real owner to retry onboarding. `createSubaccount` should return `200` with a new - `subAccountId`, and the tax hash should now resolve to a row owned by the owner's profile. -- Log the tax hash, the deleted row IDs, the approver and the provider ticket. A second release - within a quarter is the trigger in RISK-026 to make the reservation exclusive only on approval. diff --git a/docs/operations-demo-environment.md b/docs/operations-demo-environment.md deleted file mode 100644 index 1d0baeff0..000000000 --- a/docs/operations-demo-environment.md +++ /dev/null @@ -1,101 +0,0 @@ -# Demo environment — operations - -How to stand up and run the seeded sales-demo account on the dashboard. Applies only to -the **sandbox** deployment (`DEPLOYMENT_ENV=sandbox`, `SANDBOX_ENABLED=true`); every entry -point described here refuses to run anywhere else. Decisions and rationale are in -[`adr-0004-sandbox-demo-environment.md`](adr-0004-sandbox-demo-environment.md). - -## What the demo account shows - -One profile — the login email in `DEMO_ACCOUNT_EMAIL`, defaulting to -`demo@satoshipay.io` — with a `business` customer entity. - -**Onboarding** opens at 🇧🇷 **BR approved** (a real Avenia customer, created once by hand) -and 🇨🇴 **CO not started**. CO is onboarded live during the pitch and ends approved, so the -view finishes with two approved corridors. - -**Recipients** — four seeded rows, display-only: - -| Country | Status | Instrument | -|---|---|---| -| 🇧🇷 Padaria Aurora LTDA | Approved | PIX `••••4821` | -| 🇲🇽 Miguel Ortega Servicios | Approved | CLABE `••••7390` | -| 🇨🇴 Andrea Rojas | Pending review | — | -| 🇦🇷 Estudio Belgrano SRL | Invite created | — | - -**Transactions** — four seeded rows, plus whatever the demo itself creates: - -| Direction | Status | Amounts | -|---|---|---| -| BUY | completed | 5,000.00 BRL → 912.44 USDC | -| SELL | completed | 1,500.00 USDC → 8,194.35 BRL | -| BUY | processing | 2,400.00 BRL → 437.98 USDC | -| SELL | processing | 750.00 USDC → 4,097.18 BRL | - -Every seeded date is relative to restore time, so the history never reads as stale. - -## One-time setup - -1. **Sign in once as the demo account.** `profiles.id` is the Supabase Auth UUID, so the - seed cannot forge the profile — it has to exist first. Log into the sandbox dashboard - with the demo email and complete the email OTP. Restore fails with an explicit message - until this is done. -2. **Onboard BR for real.** Complete Avenia company KYB for the demo entity in the sandbox. - This is the one corridor restore never touches, and it is what makes the transfers real. -3. **Set the sandbox API environment:** - - ```bash - DEPLOYMENT_ENV=sandbox - SANDBOX_ENABLED=true - DEMO_ACCOUNT_EMAIL=demo@satoshipay.io # optional; this is the default - DEMO_PROVIDER_ENABLED=true # canned Alfredpay KYB, see below - ``` - - `DEMO_PROVIDER_ENABLED=true` with any other `DEPLOYMENT_ENV` refuses to start. -4. **Fund the demo wallet** with testnet USDC on Polygon Amoy (or Base Sepolia) for the - SELL demo. Payins need no funding. - -## Restoring the demo state - -Restore is idempotent and can be run as often as you like. - -```bash -cd apps/api && bun seed:demo -``` - -It also runs **automatically after every demo-account login**, so in practice a fresh -browser session is all that is needed before a call. A restore failure never blocks the -login — it is logged and swallowed. - -Each run: - -- ensures the business customer entity exists and is the profile's active entity; -- **wipes the CO corridor** (`provider_customers` + `kyc_cases` for `alfredpay`/`CO`) so - the onboarding wizard is walkable again; -- re-seeds the four recipients and four transactions, re-stamping their dates; -- **leaves BR alone**, along with every ramp, recipient, and corridor created during a - demo — restore only writes rows whose ids carry the demo UUID prefix. - -## Running the demo - -- **Onboarding** — start CO. With `DEMO_PROVIDER_ENABLED=true`, the KYB form, questionnaire, - and document upload accept anything, the submission sits in review for ~10 seconds, then - approves. No hosted redirect, no liveness step: the demo never leaves the tab. -- **Payin (BUY)** — BRL → USDC on BR. Real quote, real signing, and the sandbox's - 10-second auto-completion carries it to `complete`. -- **Payout (SELL)** — USDC → BRL on BR. The PIX key is entered at ramp time, so no - recipient setup is needed. -- **Avoid the transfer tab for the seeded recipients.** No recipient can currently be paid - from the recipients list (see [`product-dashboard.md`](product-dashboard.md)); the tab - still renders "Fiat-to-Fiat transfers are coming soon". Demo payouts run through the BUY/ - SELL flow above. - -## If something goes wrong - -| Symptom | Cause | Fix | -|---|---|---| -| `No profile for . The demo account must sign in once via OTP first` | Step 1 of setup was skipped | Log in once with the demo email | -| `Demo restore is sandbox-only` | `DEPLOYMENT_ENV` is not `sandbox` | Point the script at the sandbox API's `.env` | -| `Active entity selection is immutable` | The profile signed up as an individual | Delete the profile in Supabase, sign in again, and choose **Company** | -| CO onboarding hits the real Alfredpay | `DEMO_PROVIDER_ENABLED` is unset | Set it to `true` and restart the API | -| The processing rows flipped to failed | Something wrote `presigned_txs` on them | Re-run `bun seed:demo` | diff --git a/docs/operations-legacy-schema-cleanup.md b/docs/operations-legacy-schema-cleanup.md deleted file mode 100644 index f8515060f..000000000 --- a/docs/operations-legacy-schema-cleanup.md +++ /dev/null @@ -1,136 +0,0 @@ -# Legacy Schema Cleanup Rollout - -Status: migration 060 has run on staging. Migration 061 removes the retired `api_keys` -table. Production runs both migrations in one deployment after every pre-deployment gate -below has current evidence. - -## Purpose - -Safely deploy `apps/api/src/database/migrations/060-drop-legacy-schema.ts` and -`apps/api/src/database/migrations/061-drop-legacy-api-keys.ts`, the irreversible contract -steps for the unified identity, partner-pricing, and credential schemas. Migration 060 removes -the legacy provider/KYC tables, `partners_legacy`, pricing columns copied from `partners` to -`partner_pricing_configs`, `api_keys.partner_name`, and the unread directional -partner-assignment columns. Migration 061 rejects any active legacy key, drops `api_keys`, and -removes its enum type. Each runs in one transaction with a five-second lock timeout and relies -on PostgreSQL's default `RESTRICT` behavior to reject unknown dependencies. - -Permanent deletion without a separate archive is approved for all remaining legacy-only rows, -including `tax_ids` (ownerless/quarantined rows and unresolved Avenia subaccounts), -`kyc_level_2`, folded or historical `alfredpay_customers`, and `mykobo_customers`. The accepted -consequence is loss of provider/KYC history or account references present only in those tables. -Migrations 060 and 061 have no database `down()` path; recovery requires restoring the validated -pre-migration backup. - -For this rollout, **maintenance mode means a full backend shutdown**, not only an active -maintenance schedule: no backend instance may remain alive or serve any API endpoint. The only -permitted backend process is the new instance executing migrations 060-061 during startup, -before it begins listening for API traffic. - -## Pre-Deployment Gates - -Do not deploy migrations 060-061 to production, or migration 061 to staging, until all of these -conditions are recorded: - -- A maintenance-window hard cutover is scheduled. Maintenance mode must block new quote and ramp - mutations before operators drain traffic and stop every API and worker process. -- Runtime authentication reads only `api_credentials`, every intended legacy credential has been - migrated or reissued, and every `api_keys` row is inactive. Migration 061 aborts if any active row - remains. -- The `Partner` model is identity-only. Pricing, fee, subsidy, dynamic-discount, and payout-address - reads use `partner_pricing_configs`. -- The application model and associations do not declare - `profile_partner_assignments.buy_partner_id` or `sell_partner_id`. -- No old API or worker instance remains running before the migration starts. -- The live PostgreSQL catalog has been checked for foreign keys, views, materialized views, - functions, triggers, RLS policies, publications, grants, and cross-schema dependencies on every - removed object. Resolve each finding; never add `CASCADE` to bypass it. -- Supabase views/RPCs, Edge Functions, BI exports, support scripts, and other external consumers - have been checked and no longer read removed objects. -- A restorable pre-migration backup has been taken and restoration has been rehearsed. - -## Production Parity Gate - -The gate script was retired after the production cleanup (it reads deleted tables and cannot -run against the post-061 schema); restore it from git history to rehearse the gate elsewhere. -Run the read-only gate with a database-owner or `BYPASSRLS` connection: - -```bash -psql "$DATABASE_URL" -f apps/api/scripts/schema-parity-checks.sql -``` - -Preserve the complete output from the authoritative run immediately before the production -migrations, after all old API and worker instances have stopped and before the final backup. -Record the timestamp, environment/database, deployed commit, latest `SequelizeMeta` migration, -operator, Sections 0-4, and process exit status. Section 4 uses legacy UUIDs and one-way -tax-reference hashes rather than email addresses or raw tax IDs. - -Proceed only when: - -- Section 0 shows plausible production row counts. Implausible zeros on a populated database mean - the role is filtered by RLS or the connection targets the wrong database. -- Every Section 1 `PARITY` count is zero. -- Section 4 returns no finding rows. -- The final line contains - `MIGRATION GATE PASSED: every eligible legacy provider row has the exact canonical identity mapping and KYC case`. -- `psql` exits with status zero. - -Interpret non-passing output as follows: - -| Output | Required action | -|---|---| -| Implausible Section 0 counts | Stop, correct the database or role, and rerun the complete script. | -| Non-zero Section 1 or any Section 4 row | Stop, repair or explicitly reconcile the canonical mapping for every source identifier, and rerun. Never alter a legacy row merely to make the gate pass. | -| Non-zero Section 2 | Preserve the approved-deletion counts. Every active legacy API key is a blocker and must be revoked. | -| Section 3 status drift | Reconcile against provider/runtime history. Do not overwrite newer canonical status merely to match the legacy snapshot. | -| `MIGRATION GATE FAILED` or non-zero exit | Preserve the output with the remediation record and do not run migrations 060-061. | - -The parity gate proves database mapping only. It does not replace provider-side reconciliation, -the catalog/external-consumer audit, process drain, or backup/restore rehearsal. - -## Deployment Sequence - -1. Activate maintenance mode and verify that new quote and ramp mutations are rejected. Allow - in-flight work to quiesce. -2. Stop and verify the absence of every old API and worker process. Maintenance mode alone is not - sufficient because it does not stop workers or prevent a live old process from querying columns - removed by migrations 060-061. -3. Run and preserve the authoritative parity gate. -4. Take and validate the final restorable backup. -5. Deploy the release containing migrations 060-061. Staging, where migration 060 is already - applied, runs only migration 061. If the five-second lock timeout aborts startup, identify the - blocking session before retrying; do not increase the timeout blindly. -6. Verify migrations 060 and 061 appear exactly once in `SequelizeMeta`, `api_keys` is absent, - and all intended tables, columns, indexes, and table-specific enum types are absent. The shared - `ramp_direction_enum` must remain. - Also verify the renumbered credential migrations: `SELECT name FROM "SequelizeMeta" WHERE name - LIKE '05%' ORDER BY name;` must list `057-create-api-credentials`, - `058-create-partner-managed-profiles`, and `059-add-api-credential-id-to-quote-tickets` exactly - once each, with no entry remaining under an old name such as `055-create-api-credentials` - (startup renames applied entries automatically before computing pending migrations). -7. Run the smoke tests and monitor through the normal post-deploy observation window. - -## Post-Deployment Verification - -- Authenticate with partner-bound and profile-managed public/secret credentials; confirm - deleted-partner credentials remain revoked. -- Create BUY and SELL quotes across configured fiat corridors and verify scoped/wildcard partner - pricing selection. -- Verify partner fee payout, subsidy caps, dynamic-discount updates, and payout-address resolution - use the expected pricing configuration. -- Exercise Avenia onboarding, account lookup, KYC/KYB polling, quote creation, and ramp - registration without any `tax_ids` query. -- Exercise profile-assigned pricing and its admin list/create/revoke routes without the removed - directional columns. -- Confirm API error rate, authentication failures, quote failures, and migration/lock logs remain - clean through the observation window. - -## Failure And Recovery - -Do not use Umzug revert after migrations 060-061. If a removed dependency or data requirement makes -the release unsafe, stop the application, restore the validated pre-migration backup, and deploy -an application version compatible with that restored schema. Preserve the failed migration and -smoke-test evidence for remediation. - -Production cleanup is verified: `apps/api/scripts/schema-parity-checks.sql` was retired because it -intentionally reads deleted tables and cannot run against the post-061 schema. Git history keeps it. diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md deleted file mode 100644 index 519cefaa0..000000000 --- a/docs/operations-monerium-b2b-rollout.md +++ /dev/null @@ -1,88 +0,0 @@ -# Monerium B2B Onramp — Rollout - -What still stands between the implemented system and a live pilot on the engineering -side: the external audit gate, the deploy checklist, and the open items ledger. Decisions and parameters are -final in [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); -procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). - -## Gates - -**G3 — external contract audit.** Parameters are final (ADR); the internal reviews and -the invariant suite are done, but this moves client funds. - -## Deploy checklist (mainnet bring-up) - -The Sepolia sandbox follows its own procedure in the runbook (§8). - -1. Apply database migrations from exactly one deployment instance. Migration execution - is not serialized across replicas; do not let multiple instances run the migrator - concurrently. Migrations 076/077 install allocation accounting and its exact - 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. Migrations 078/079 add the - ppm fee policy and the pricing columns: before applying them, confirm no `Pending` - `monerium_conversion_executions` row has a NULL `reference_rate_raw` or `route_index` - (it would stay in flight forever and block its account) and no deposit-converted - outbox delivery is still pending (it would replay without the `execution` block). -2. **Treasury first (O2):** create the dedicated fee Safe multisig — `FEE_RECIPIENT` is - immutable in the implementation. Confirm the guardian key storage plan (EOA acceptable - for pilot; hardware/multisig at GA). -3. Re-verify the initial route's pools and fee tiers at the deploy block (P10) and re-run - the liquidity baseline quote methodology (T6); confirm `perSwapCap` €10k still - executes within floor plus the per-swap subsidy cap, and decide whether a second - route (direct EURe→USDC or other tiers) is worth whitelisting from day one. -4. Deploy implementation + factory with the final parameters (ADR table: 52 h oracle - age, 60 bps floor on the net, 1% fee cap, 100 bps reference band, 2 h recovery / 24 h - trigger delays, €1 floor/€50k ceiling, initial 5 bps/5 bps route; the recovery address - is per client, passed at each clone's deployment); set operational `minSwapAmount` - €1 and `perSwapCap` €10k; register the keeper key and grant the deployer key its role - (`setDeployer`). -4a. Deploy `VortexSubsidyVault` (USDC, the fee Safe as treasury, the factory, 50 bps per - swap, 200 USDC per day — P13), point the factory at it (`setSubsidyVault`), and fund - it from the treasury with the first days of budget. Runbook §2.6 has the commands. -5. Verify factory + implementation source on the block explorer; generate, verify, and - publish the manifest. -6. Production credentials for the partner's white-label app from Monerium; configure the keeper backend (the - mykobo flow variant only): credentials, attestor/keeper/guardian/deployer keys (four - distinct; keeper and deployer funded), `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID` (the - partner manager allowed to register destinations), `MONERIUM_B2B_REFUND_SEED` (derives every client's refund wallet), - read RPC + private orderflow RPC, webhook secret, and - `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`; the backend needs outbound HTTPS to - `api.exchange.coinbase.com` for the reference rate (P12) — without it every swap - defers. Keep `MONERIUM_B2B_ENABLED=false` until G3 is done and the launch is signed off. -7. Register the webhook endpoint at Monerium (`profile.updated`, `iban.updated`, - `order.created`, `order.updated`). -8. Before first production onboarding, simulate a SEPA deposit end to end (dashboard → - Receive → "Simulate bank transfer") and re-verify the signed `webhook-id`, - `webhook-timestamp`, and `webhook-signature: v1,` fixture against a real - production delivery. -9. Partner side: manager profile configured (EU corridor, business type), secret - credential issued, deposit-event webhook registered and verifying signatures against - `GET /v1/public-key`. -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 (partner registration, or deploy clone → map; automated - link/IBAN → activate → optional penny test). Before the first enablement on a backend - that already holds accounts, run the runbook §1.7 check for `onboarding` accounts that - already have an IBAN: they stop converting until activated. - -## Open items ledger - -| Item | Owner | Status | -|---|---|---| -| G3 audit | External | After PR merge; params final | -| Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering | Open — only remaining engineering unknown | -| Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | -| Subsidy ladder calibration | Ops ↔ product | Launch ladder in P14; retune from the `deferring conversion` shortfall lines and the vault spend after the first weeks; raise the vault's per-swap cap to the ladder's top before enabling | -| Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 45 bps after the 2026-09-17 move to 60 bps: the twelve-month replay on spot (2026-09-18, ADR amendment 3) shows six minute-long blips a year at that margin outside the 2025-10 depeg weekend, so ordinary weekends do not refund; the depeg weekend (39.7 h out of the 100 bps band) does, by design | -| Refund seed + float wallet | Ops | Generate `MONERIUM_B2B_REFUND_SEED` (32 random bytes; every client's refund wallet derives from it, and onboarding links each to its client's profile) and the EURe float key; fund the float with EURe and ETH (it pays the refund wallets' gas); both into the keeper's KMS. No Vortex company profile is needed | -| Refund automation | Ops | Implemented (`recovery.ts`): ship with `MONERIUM_B2B_AUTO_RECOVERY=alert`, observe one sandbox refund end to end, then `auto` with the float key set; refunds of EUR 15,000 or more stay manual (Monerium requires a supporting document from that amount) | -| Sandbox SEPA simulation: payer counterpart | Engineering | Capture one real issue-order webhook to confirm `counterpart.identifier.iban` / `details.name` arrive as the spec says (the refund target) | -| Memo-routed mint to a refund wallet | Engineering | Open: detect it and handle it as a refund | -| Monerium read-back | Engineering | Open: read the issued IBAN and the issue order's payer IBAN and name back from Monerium's API instead of relying on webhook payloads alone (the payer IBAN is the refund target); check in the sandbox whether listing the app's webhook subscriptions returns their secrets (Monerium's guide says so, the response schema has no secret field), since every holder of the app's credentials can list Vortex's subscription | -| Subsidy vault funding and refill cadence | Ops | Before first activation; runbook §2.6 | -| GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md deleted file mode 100644 index a4cab1df2..000000000 --- a/docs/operations-monerium-b2b-runbook.md +++ /dev/null @@ -1,1226 +0,0 @@ -# Monerium B2B Onramp — Operations Runbook - -All operator procedures for the B2B onramp in one place: onboarding, incident response, -alert triage, dormancy, client migration, and the Sepolia sandbox bring-up (§8). Architecture: -[`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); decisions -and parameters: [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); -security invariants: -[`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md). - -Ground rules that shape every procedure here: - -- **Vortex powers are bounded.** Guardian/keeper can pause, - execute the policy (chunk swaps, one forward per payment) and — only for a payment - whose batch has been open for `RECOVERY_DELAY` (2 h) — move that payment to the - client's refund wallet fixed in the clone for a bank refund (§2.7). Nothing else can - move funds, and nothing can redirect them: the clone pays the client's destination, - the fee treasury and the client's refund wallet, full stop. -- **Pauses block swaps and forwards, never a recovery.** Pause-then-recover is the - incident sequence. Past 24 h anyone may swap and forward permissionlessly, so a - pause plus a dead keeper still cannot trap converted funds. There is no client key - on the clone any more (ADR amendment 2026-09-17). -- **The subsidy vault holds Vortex money only.** It tops a chunk up to the floor on the - clone (the top-up is forwarded with the payment), within its caps, and withdraws only - to the treasury; funding, limits and pause are ordinary operations (§2.6), never a - client-funds question. -- **Never send raw EURe to a CEX destination.** EURe leaves a clone only to the router - or the client's refund wallet. -- **Run migrations from one deployment instance only.** Migration 080 refuses to run - while an execution from the former allocation model spans several deposits; reconcile - such rows by hand rather than guessing an attribution. - -## 1. Client onboarding - -**Partner registration (default).** The partner calls `POST /v1/monerium-b2b/accounts` -with the client's Monerium profile ID, destination, client reference and contact email; -the keeper waits for Monerium's approval, deploys the clone with the deployer key, maps -the account and runs §1.5. The operator's only step is activation (§1.7); only the -sandbox (`SANDBOX_ENABLED=true`, §8) activates a registered account by itself once its -IBAN is recorded. The partner follows a registration in -`GET /v1/monerium-b2b/registrations`. Operators list the same rows with the keeper's -progress (`id`, `contactEmail`, `managerProfileId`, `deployTxHash`, `deploySentAt`, -`lastCheckedAt`) with `GET /v1/admin/monerium-b2b/registrations?status=requested|mapped|rejected` -(`limit`, `offset`; `Authorization: Bearer $ADMIN_SECRET`), no SQL needed. - -A `requested` registration shows what it waits for in `waitingReason`: - -| `waitingReason` | Meaning | Who acts | -|---|---|---| -| `monerium_profile_pending` | Monerium reports the profile `created`, `incomplete`, `pending` or `review`; the keeper re-reads it every minute | The partner, at Monerium (finish the KYB); nothing at Vortex | -| `monerium_profile_not_visible` | Monerium now answers 404 for the profile in the partner's app, though it was visible when the partner registered it | The partner checks that the profile still exists in its app; if it persists, Vortex checks that the white-label credentials are still the partner's app | -| `deployment_pending` | The profile is approved and the deployment is sent (`deployTxHash`, `deploySentAt`) or queued behind another one (one deployment per cycle); with no receipt after 10 minutes, or after a revert, it is sent again | Nobody; if it outlasts a few cycles, check the deployer's pending nonce and the RPC | -| `deployer_not_ready` | The deployer cannot deploy: no deployer role on the factory (`NotDeployer`) or no gas. Vortex's side, never the partner's | Vortex: `setDeployer` (§8.5) or fund the deployer address; the next cycle proceeds | -| `manager_inactive` | The mapping is refused because the partner's managed-profile manager is inactive or missing | Vortex: restore the manager (`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type `business`) | -| `temporary_error` | A transient failure: RPC, Monerium (timeout, rate limit, 5xx, 403), the mapping's on-chain verification, or the database; the keeper retries every cycle | Vortex only if it persists: the log line `registration for profile ... failed this cycle` names the cause | - -A registration becomes `rejected`, with a reason the partner can read, only for a -definite cause: Monerium rejected or closed the profile; the factory refused the -arguments (`InvalidConfigAddress`, `ZeroAddress`, `InvalidFeePolicy`, §1.1); a client -conflict (the client reference already used with another contact email, by a client -that is not a business or with another Monerium profile; the contact email used by -another client; another registration that is not rejected with the same reference or -email); a definite mapping conflict; an operator mapped the profile by hand to a -different destination or for another manager's client; the guardian revoked the clone -at the predicted address (the same destination would always land on it); or an operator -withdrew it. Transient failures wait instead. The -partner may register a rejected profile again, with the same or corrected data (202; the -row restarts). - -To stop a registration that must not proceed (a partner typo, a wrong destination, a -clone you revoked), withdraw it: `POST /v1/admin/monerium-b2b/registrations//withdraw` -turns a `requested` registration into `rejected` with the reason "Withdrawn by Vortex -operations: register the profile again with corrected data" (409 -`MONERIUM_B2B_REGISTRATION_NOT_WITHDRAWABLE` when it is mapped or already rejected, 404 -`MONERIUM_B2B_REGISTRATION_NOT_FOUND`); the partner registers the profile again. The -keeper deploys as soon as Monerium approves the profile, so withdraw before that; a clone -already deployed for a withdrawn registration stays unused (no IBAN is linked to it) and -the guardian may revoke it (§6). A withdrawal always wins over a keeper cycle running at -the same time: the keeper writes only to the row it loaded and locks it while it maps. - -The manual path below stays for corrections and clients registered outside the API. -Mapping a profile by hand while its registration is still `requested` is safe: the keeper -adopts the account when the destination matches and the account belongs to one of the -registering manager's clients, and rejects the registration otherwise, without deploying -a second clone. - -Deploy → manifest → verify → map → (automated: link + IBAN) → activate → optional penny -test. -One pass per client. Prerequisites: guardian key funded on the target chain; -`MONERIUM_B2B_ENABLED=true` and the complete `MONERIUM_B2B_*` env set on the one -`mykobo` keeper backend (including the trusted factory address, read/private RPCs, -webhook secret, and three keys); the factory's subsidy vault deployed, pointed at and -funded (§2.6); the client company onboarded and KYB-approved on Monerium's side with -its Monerium profile UUID at hand; the partner configured as a managed-profile manager -(`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type -`business`). - -### 1.1 Client inputs - -- `destination` — client's payout address. CEX deposit addresses allowed; validate by - hand: EIP-55 checksum, not zero/dead/precompile/token/router, warn-and-attest for - contract addresses and CEX addresses (rotation risk). Nothing else screens - them: the registration API rejects only a malformed or zero address (400), and the - factory refuses at init (the registration is rejected) only EURe, EURC, USDC, the - router, the clone itself and the client's own refund wallet. A dead or precompile - address deploys and would receive the client's USDC. -- (No client-held recovery address: the clone's `recoveryAddress` is the client's refund - wallet, derived by Vortex, step 1.2. The destination has no setter — a client wallet - change is a new clone, §5 — so get it right; a penny test is recommended for exchange - destinations.) -- `targetPpm` / `floorPpm` — the client's fee policy in ppm below the reference rate; - Vortex's default policy 1250 / 1500 (12.5 / 15 bps, ADR B1). Adjustable later via - the guardian's timelocked `setFeePolicy` (raising either value waits 24 h). - -### 1.2 Deploy the forwarder clone - -```bash -# the client's refund wallet, derived from MONERIUM_B2B_REFUND_SEED and the Monerium profile ID -curl -s -H "Authorization: Bearer $ADMIN_SECRET" \ - "$API/v1/admin/monerium-b2b/refund-address?moneriumProfileId=$MONERIUM_PROFILE_ID" -# predict, then deploy (guardian or a factory deployer); salt = any bytes32, convention: client index -cast call $FACTORY "predictAddress(address,address,uint32,uint32,bytes32)(address)" \ - $DESTINATION $REFUND_ADDRESS $TARGET_PPM $FLOOR_PPM $SALT --rpc-url $RPC -cast send $FACTORY "deployForwarder(address,address,uint32,uint32,bytes32)" \ - $DESTINATION $REFUND_ADDRESS $TARGET_PPM $FLOOR_PPM $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -The factory deploys at the CREATE2 salt -`keccak256(abi.encode(destination, recoveryAddress, targetPpm, floorPpm, salt))`, so -`predictAddress` takes the very arguments of `deployForwarder`, and nobody can occupy a -client's predicted address with other arguments. The same arguments with the same salt -twice revert (`CloneFailed`); use a fresh salt for a second clone of one configuration. - -The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). The -refund wallet is fixed for the clone's lifetime; the account mapping (§1.4) refuses a -clone whose `recoveryAddress` is not this client's derived wallet. -Record the forwarder address + deploy tx hash. - -### 1.3 Manifest: generate, verify, publish - -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": "", - "targetPpm": 1250, - "floorPpm": 1500 -} -``` - -Replaying the identical call is safe (200); divergent input is a 409, never an -overwrite. If the partner's registration for this profile is still `requested`, the -keeper marks it mapped on its next cycle when the destination matches and rejects it when -it differs; it never deploys a second clone for an account that exists. - -### 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), links the client's refund wallet with its own signature, then requests IBAN -issuance for the forwarder (`POST /ibans`, async 202). The IBAN lands on -the account row via the `iban.updated` webhook (the log says `account ... has its -IBAN`); from then on the association monitor treats the DB record as the reference -state, and the onboarding step stops polling the account: it only awaits activation -(§1.7). Nothing to do manually — verify the row has its IBAN before activation, and check -the logs if it stays empty for more than a few cycles. - -### 1.6 Penny test (optional) - -Optional, and recommended for exchange destinations (ADR amendment 2026-09-29): prove -the destination actually credits contract-originated USDC transfers (CEXes can rotate -or mis-credit) before real volume flows. Run it right after activation (§1.7), before -the client starts paying: only an active account converts, so a penny test sent earlier -waits on the clone and is refunded at the deadline (§1.7), which reads as a failed -destination test. - -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 it like any other payment (the minimum swap is €1). -3. **Partner/client confirms credit at the destination** in writing. - -### 1.7 Activate - -Activation is the operator's check of the destination, and it gates the money: only an -active account converts. A payment that reaches an account before activation waits on -the clone, and the partner sees waiting reason `account_not_active` on the deposit. With -`MONERIUM_B2B_AUTO_RECOVERY=auto` the deadline (`MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES`, -120 by default, counted from the mint) then refunds it; in `alert` mode the keeper only -logs `REFUND DUE` and in `off` mode (the default) it does nothing, so mark the deposit -by hand (§2.7). The gate is a keeper-side policy, not an on-chain guarantee: once the -clone's batch has been open for `TRIGGER_DELAY` (24 h), `swap` and `forwardAll` are -permissionless and anyone can send the payment to the unchecked destination, so activate -or refund well before that. - -1. Find the accounts waiting for activation, with their partner manager: - `GET /v1/admin/monerium-b2b/accounts?status=onboarding` (Authorization: Bearer - $ADMIN_SECRET). Entries with an `iban` wait for you; those without are still linking - (§1.5). -2. Check the account's `destination` against the address the partner confirmed in - writing; it can never change. -3. Activate: - -``` -PATCH /v1/admin/monerium-b2b/accounts//status (Authorization: Bearer $ADMIN_SECRET) -{ "status": "active" } -``` - -(Refused with 409 while no IBAN is recorded.) Activation records `activated_at`: the -60-day dormancy window (§4) of a never-converted account runs from it, so a late -activation does not pause the account at once. Every status change is logged -(`operator moved account from to `, with destination and forwarder); the -log names no operator, since the admin secret is shared. Run the penny test (§1.6) if the -destination is an exchange, confirm the next monitoring pass picks the account up -cleanly, then tell the partner the account is active; `ACCOUNT_UPDATED` reports it too. - -If the destination does not match, suspend the account instead -(`{ "status": "suspended" }`, allowed from `onboarding` once the IBAN is issued): nothing converts, recoveries -still run, and `ACCOUNT_UPDATED` tells the partner. Mark any payment already on the clone -for the refund path (§2.7). - -**Before deploying the gate to a backend that already holds accounts**, list the accounts -in `onboarding` that already have an IBAN (the same call; `iban` not null). They converted -before the gate and stop converting once it is live, so a payment would sit on the clone. -Run the check above for each and activate it, or leave it idle on purpose. - -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 -# Route lever: disable a route whose pool went bad (indices are stable; the keeper re-quotes each cycle) -cast send $FACTORY "setRouteEnabled(uint256,bool)" false --rpc-url $RPC --private-key $GUARDIAN_KEY -# Subsidy lever: stop topping up (below-floor swaps then defer instead of executing) -cast send $VAULT "setPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -Both pauses block `swap`, `forward` and `forwardAll` only — never `recover`; unpause = -same call with `false`. Pausing the vault pauses nothing on the forwarders: swaps that -need no subsidy keep executing. - -### 2.2 Monerium IBAN suspension ask - -Contact Monerium support/emergency, identify the whitelabel partner account and -affected IBAN(s) + forwarder(s), ask for suspension of inbound SEPA (deposits bounce to -senders — NOT profile closure), and record the ticket. While unsuspended, inbound SEPA -keeps minting EURe to the forwarder — safe behind the contract invariants, but growing -exposure. - -### 2.3 Client notification - -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 refunded to the -sending bank account through the recovery path — nothing is lost by pausing); status -page entry if global. - -### 2.4 Critical-vulnerability sequence (the 02:00-UTC drill) - -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: the refund path.** Mark every open deposit for recovery - (§2.7); once each clone's batch is 2 h old the keeper moves the funds to that - client's refund wallet and the payments are refunded to the payers' bank accounts. The - issuer recovery backstop (burn + payout to the client's own bank account; validates - the already-whitelisted ownership message) is the last resort. -6. **Ship the fix as a migration** (§5): new implementation + factory (new audit), new - clones, re-link, move IBANs, optionally penny-test, republish the manifest. Old clones stay - paused; residual balances leave through the refund path. -7. **Unpause / decommission** only contracts confirmed unaffected. - -### 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. - -### 2.6 Subsidy vault operations - -One `VortexSubsidyVault` per factory, deployed once (USDC, the fee Safe as treasury, the -factory, launch limits 50 bps per swap and 200 USDC per day — ADR P13), then pointed at -by the factory and funded from the treasury. All guardian-key calls are ordinary -operations: the vault never holds client funds. - -```bash -# once: point the factory at the vault -cast send $FACTORY "setSubsidyVault(address)" $VAULT --rpc-url $RPC --private-key $GUARDIAN_KEY -# fund (from the treasury Safe): plain USDC transfer to $VAULT -# tune limits (instant) -cast send $VAULT "setMaxSubsidyPpm(uint32)" 5000 --rpc-url $RPC --private-key $GUARDIAN_KEY -cast send $VAULT "setDailyBudget(uint256)" 200000000 --rpc-url $RPC --private-key $GUARDIAN_KEY -# read runway -cast call $VAULT "dailyBudget()(uint256)" --rpc-url $RPC -cast call $VAULT "spentToday()(uint256)" --rpc-url $RPC -cast call $USDC "balanceOf(address)(uint256)" $VAULT --rpc-url $RPC -# return funds (treasury only — there is no other target) -cast send $VAULT "withdraw(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -Sizing: the vault's per-swap cap must be at least the subsidy ladder's top (100 bps, so -`setMaxSubsidyPpm(10000)` at launch), because the keeper's tier is the effective cap and -the vault's is the ceiling. At the €10k per-swap cap a top-up at the ladder's top is -about 115 USDC, so size the daily budget from the expected number of chunks that reach -the late tiers, not from one worst case; raise the budget or lower `perSwapCap` if -deferrals become routine; both are instant. The ladder itself -(`MONERIUM_B2B_SUBSIDY_LADDER`, seconds:bps steps) and the re-quote cadence -(`MONERIUM_B2B_KEEPER_CYCLE_SECONDS`) are backend settings; tune the ladder from the -`deferring conversion ... shortfall N bps, tier M bps` log lines. - -### 2.7 Refund (recovery) procedure - -Trigger: a deposit the promised window was missed on (the deadline, -`MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES`, 120 by default), a remainder below -`minSwapAmount`, a compliance decision, or a critical incident (§2.4). Prerequisites: the -client's refund wallet (the clone's `recoveryAddress()`) is linked to the client's -Monerium profile (onboarding does this, §1.5), `MONERIUM_B2B_REFUND_SEED` and the EURe -float wallet's key are held by the operator, and the float holds EURe and some ETH -(it also pays the refund wallet's gas). - -**Automation.** `MONERIUM_B2B_AUTO_RECOVERY` selects the mode: `off` (default) leaves -every step below to the operator; `alert` logs `REFUND DUE` for deposits past -`MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (120, counted from the mint) and nothing else; -`auto` marks them, and — with `MONERIUM_B2B_FLOAT_PRIVATE_KEY` set and the seed deriving -the clone's `recoveryAddress` — runs steps 2–6 itself, one refund at a time per client -(different clients' refunds run side by side), reporting through the refund monitor (§3). -Start on `alert`, switch to `auto` once a sandbox refund has been observed end to end. -What stays manual in `auto`: refunds of EUR 15,000 or more (Monerium's supporting -document), deposits whose issue order carried no payer IBAN/name, orders Monerium -rejects, and any step that failed five times — all park the deposit as -`recovery_failed` with the phase preserved (`monerium_recoveries.phase`/`error`). -After five failed attempts, fix the cause, then -`PATCH .../deposits//status {"status": "recovering"}` resumes from that phase. -The other parks stay manual (EUR 15,000 or more, no payer, a rejected order): do not set -`recovering`, the next cycle would park it again (a rejected order stays rejected, and -the memo lookup adopts it again). Address the rejection reason if there is one, place -the redeem order by hand (step 5) under the memo `vortex-refund:`, so no -automated retry can place a second order, and once Monerium processed it close the -deposit straight from `recovery_failed` with `{"status": "refunded"}` (step 6); the -keeper then closes the recovery without placing an order. While a client's refund is `recovery_failed` only that client's later -refunds wait behind it; other clients' refunds go on. The shared EURe float sends for one -client per keeper cycle, and an unconfirmed float transfer delays another client's float -step (a parked refund never does). Other clients advance one step per keeper cycle, and -a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one. - -**Hand sends from the float key (steps 3 and 4).** In `auto` the keeper keeps sending from -the same float key for other clients' refunds while one is parked, so a transfer you send -by hand can take the same nonce as a keeper transfer and one of the two is dropped. A -dropped keeper top-up leaves its client in `topping_up` and holds the float for every -client until that refund is parked. Cheaper option: first confirm that no other deposit is -`recovering` (a refund in `swapping`, or a confirmed `recover` with no recovery row yet, -starts sending from the float within a cycle or two) and that no float transaction is in -flight (the float's pending nonce equals its latest nonce). The result is a snapshot, so -the hand send must follow right after it: - -```sql -SELECT id, account_id FROM monerium_fiat_deposits -WHERE status = 'recovering' AND id <> ''; -``` - -Alternative: set `MONERIUM_B2B_AUTO_RECOVERY=alert`; this needs a restart and pauses every -client's refunds, and it also silences the refund monitor and the orchestrator's refund -alerts (`REFUND FAILED`, `waits for the operator`) for every client, so set `auto` again -right after the hand step. Even then, first confirm no float transaction is pending: one -sent before the restart can still collide with the hand send. Before parking a `topping_up` -refund by hand, check the float's pending nonce, or cancel or replace the stuck transfer, -then park: parking releases the float for the other clients while that transfer may still -be pending. If its float top-up was dropped, park the refund, clear its hash (`UPDATE monerium_recoveries SET float_topup_tx_hash = NULL WHERE deposit_id = '';`), then set it back to `recovering` (§2.7): a `topping_up` refund without a hash returns to `swapped` and re-derives the top-up from balances, so no hand send is needed. - -1. **Mark the deposit.** `POST /v1/admin/monerium-b2b/deposits//recover` - (`Authorization: Bearer $ADMIN_SECRET`). Refused (409) while a keeper transaction for - the deposit is pending — retry once it settled — or when the deposit is not - `minted`/`converting`. The deposit becomes `recovering`; the keeper stops chunking it. -2. **Wait for the keeper's `recover`.** It sends `recover(eureRemaining, usdcConverted)` - once the clone's `batchOpenedAt` is `RECOVERY_DELAY` old (the contract refuses - earlier; younger deposits keep converting meanwhile). Verify the `recover` execution - row is `confirmed` and the `Recovered(eure, usdc)` event amounts match: - - ```sql - SELECT kind, eure_in_raw, usdc_net_raw, tx_hash, status, error - FROM monerium_conversion_executions WHERE deposit_id = '' ORDER BY created_at; - ``` - -3. **Swap the USDC back** from the client's refund wallet (its key derived from the seed - and the Monerium profile ID; fund it with a little ETH first) over the reverse whitelisted route - (USDC → EURC → EURe on the same pools; `exactInput` on the router with a - Chainlink-derived minimum, 60 bps tolerance), or leave the USDC in the refund - wallet and let the float cover the whole difference when the market is thin. -4. **Top up from the float:** transfer `issueAmount − EURe on the refund wallet` EURe - from the float wallet to the refund wallet. Book that amount as the refund's - subsidy; book any EURe surplus from step 3 to the treasury. -5. **Redeem the exact amount.** `POST /orders` from the refund wallet: `kind: redeem`, - `amount` = the issue order's `amount` string, `counterpart.identifier.iban` = the issue - order's `counterpart.identifier.iban`, `details.companyName` = its `details.name` - (individual payers: `firstName`/`lastName`), `country` from the IBAN prefix, `memo` - `vortex-refund:` (the key the automation checks before placing), the - message `Send EUR to at ` - signed by the refund wallet's key; attach `supportingDocumentId` from EUR 15,000 (one - standing document per client can be uploaded once and reused for its refunds). Monerium - pays the refund out of the client's own IBAN. Watch `order.updated` for `processed`. - Monerium may review a redeem order during business hours before paying it out; the - payout goes by SEPA Instant where the payer's bank supports it, otherwise the next - business day. -6. **Close the deposit.** `PATCH /v1/admin/monerium-b2b/deposits//status` - with `{"status": "refunded"}`, from `recovering` or `recovery_failed`; use - `recovery_failed` when a step cannot complete (and `recovering` again to retry later). - Record deposit id, recover tx, reverse-swap tx, - float top-up, redeem order id and payer IBAN (masked) in the ops ledger. - -**Memo-routed payment** (no deposit row; found through the §3 warning `webhook order ... -references unknown forwarder address`). Look the order up at Monerium by the id in the -warning: it gives the amount, the address it minted to, and the payer's IBAN and name. If -that address is an old clone (§5), `poke()` it, wait for `RECOVERY_DELAY` (2 h), and have -the keeper `recover` the EURe to that clone's refund wallet; an old refund wallet from a -seed rotation (§6) needs the old seed's key. Then redeem the exact amount from the refund -wallet to the payer as in step 5, with a memo naming the issue order instead of a deposit -(`vortex-refund:` is reserved for deposits). On the client's current refund wallet, do -this before the client's next refund starts: that refund treats any EURe on the wallet as -surplus and sweeps it to the float, from where it must first be moved back. Record the -order id, recover tx (if any) and redeem order id in the ops ledger. - -## 3. Alert triage (monitoring log lines → action) - -Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium-b2b:`. - -| Log line contains | Meaning | Action | -|---|---|---| -| `DEPTH BELOW FLOOR — raw quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Even minimum-size fills land below Chainlink − 60 bps on every route before settlement. The floor is enforced on the client's net, so the keeper still executes while the vault covers the shortfall (up to the per-swap cap; beyond it the keeper defers and logs `deferring conversion`), but every swap of that size now costs a subsidy and the unsubsidized permissionless path would revert | Investigate pool state (LP exit, depeg) and watch the vault spend (§2.6); whitelist a better route or lower `perSwapCap`; global pause (§2.1) if it is a depeg or the vault is being drained; re-run the liquidity-baseline methodology before trusting the route again | -| `raw quote impact at perSwapCap exceeds SLIPPAGE_BPS` | Cap-sized swaps would need a vault subsidy; availability and vault spend, not fund risk | Lower `perSwapCap`, add a route, or accept the subsidies; watch for escalation | -| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable, a malformed ticker, or `spread of N bps exceeds 50 bps` — a thin book; check egress and the venue), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `exceeds the current tier` (normal while the chunk waits for the market; the line names the shortfall and the tier), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (only on the permissionless path since 2026-09-18: keeper swaps are settled up to the Chainlink floor by fee and tier-bounded subsidy, or defer on the tier/cap lines above), `no enabled swap route could be quoted` or `the factory has no enabled swap route` (§2.1 route lever) | Funds wait with the batch marker open; a deferral that outlives the 2 h window means the payment is refunded (§2.7) rather than converted late — communicate; after 24 h the permissionless path can execute unsubsidized | -| `SUBSIDY VAULT —` (error) | Vault paused or empty: every below-floor swap defers | §2.6: fund or unpause; check why it emptied (budget too high for the market?) | -| `subsidy vault ... refill before below-floor swaps start deferring` | Less than a day of budget left, or today's budget spent | §2.6 refill; consider the budget vs. observed spreads | -| `no subsidy vault is configured on the factory` | `setSubsidyVault` never ran; below-floor swaps defer | §2.6 | -| `route ... could not be quoted` | One whitelisted route's pool is unquotable (drained, removed) | Disable it (§2.1) so the keeper stops trying; keep at least one healthy route | -| `webhook order ... references unknown forwarder address, skipping` (warn) | An issue order minted to an address that is no account's forwarder: usually a payment memo-routed to a client's refund wallet, or to an old clone or refund wallet after a migration or seed rotation | Refund it by hand: §2.7, "Memo-routed payment" | -| `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, an address other than the forwarder and the client's own refund wallet linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | -| `stranded funds on forwarder ... past RECOVERY_DELAY` (warn) | A batch has been open longer than the promised 2 h window and is neither forwarded nor recovering | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts), `deferring conversion` lines; if the payment cannot complete, mark it for recovery (§2.7) | -| `stranded funds ... past TRIGGER_DELAY` (error) | Permissionless path now live; the promised window long missed (keeper outage or a persistent deferral) | Escalate; anyone may call `swap(reference, route, amountIn)` and `forwardAll()` — that path prices against Chainlink and pays no subsidy; communicate the delay | -| `REFERENCE VENUE —` (error) | The Coinbase product the reference reads is delisted or halted; every keeper swap defers silently | Change `COINBASE_REFERENCE_PRODUCT` (a live EURC market), redeploy the backend; the venue is an operational, not an on-chain, setting | -| `REFUND DUE — deposit ...` (error, `alert` mode) | A deposit outlived the promised window and the mode only reports | Mark it (§2.7 step 1) or switch to `auto` | -| `REFUND FAILED — deposit ... in phase ...` (error) | A refund step cannot complete automatically (large amount, missing payer, rejected order, five failed attempts) | §2.7: after five failed attempts, fix the cause and set the deposit back to `recovering`; otherwise refund by hand under the memo and close the deposit as `refunded`. A hand send from the float key can collide with the keeper's float sends for other clients: first confirm no other deposit is `recovering` and no float transaction is in flight, then send right away (the check is a snapshot), or set `MONERIUM_B2B_AUTO_RECOVERY=alert` (needs a restart, pauses every client's refunds and silences the refund alerts for every client; set `auto` again right after the hand step) | -| `could not be parked as recovery_failed (...)` (error) | A refund step needs the operator (the reason in parentheses), but moving the deposit to `recovery_failed` was refused (the reason after the colon), usually because its status changed meanwhile; the keeper re-runs the step every cycle | Check the deposit's status: `refunded` needs nothing (the next cycle closes the recovery); otherwise resolve the refusal, after which the line becomes `REFUND FAILED` | -| `was refunded (order ...) but could not be marked refunded` (error) | Monerium paid the refund out, but the deposit could not be set `refunded`; the keeper retries every cycle | Do not refund again. If it repeats, check the deposit's status and the database, then close it with `PATCH .../deposits//status {"status": "refunded"}` | -| `float steps wait for the unconfirmed float transfer of deposit` (warn) | That deposit's float transfer has no confirmed receipt, so other clients' float steps wait | Check the transfer's hash and the float's pending nonce; if it was dropped, park the refund, clear its hash (`UPDATE monerium_recoveries SET float_topup_tx_hash = NULL WHERE deposit_id = '';`), then set it back to `recovering` (§2.7): a `topping_up` refund without a hash returns to `swapped` and re-derives the top-up from balances, so no hand send is needed | -| `FLOAT ETH EMPTY` (error) / `float ETH running low` (warn) | The float's ETH cannot pay for one transfer / is below 0.05 ETH; every float send (EURe and gas top-ups) fails without it, and a refund whose step keeps failing parks after five attempts | Send ETH to the float wallet named in the line, then set any refund parked meanwhile back to `recovering` (§2.7) | -| `FLOAT UNDERFUNDED` / `FLOAT EMPTY` (error) | The EURe float cannot cover a top-up; the refund waits at `swapped` | Fund the float wallet named in the line; the step retries every cycle | -| `refund of deposit ... in phase ... since` (warn ≥1 h, error ≥4 h) | One client's open refund lingers or is parked (the line names the deposit; one line per open refund) | Check that client's refund wallet's balances and pending transactions, RPC health, Monerium order state; escalate per §2.7 | -| `untrusted factory` / `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on trusted factory` | Should-be-impossible state | Full incident: global pause, verify `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, run the manifest verifier, compare against manifest history | -| `reconciled guardian-authorized fee policy change` | A timelocked fee-policy change applied — expected, DB updated | No incident; confirm it matches the announced change | -| `config violation ... destination changed on chain` | Should be impossible: the clone has no destination setter | Full incident (see the row above) | -| `re-sent never-mined execution ... at reserved nonce` (warn) | A keeper transaction was reserved but never mined (process died before the send, the send threw, or the relay dropped it); after five idle minutes the keeper re-sent the identical call at that nonce | None: the next cycle finalizes it. `consumed its reserved nonce ... with a no-op` means the call reverts (or the account cannot convert: not activated, suspended, dormant or closed): the row fails on the next cycle and retries on a fresh plan; the line can repeat each cycle until the no-op is mined (a private relay can hold it), which is harmless since only one transaction per nonce mines. `re-send of execution ... failed` repeating means the keeper wallet cannot send (gas balance, RPC). A reservation behind a dropped `poke()` converges too: the missing nonces are first filled with no-ops | -| `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) | -| `the deployer cannot deploy for profile` (error) | The registration waits as `deployer_not_ready`: the deployer key has no role on the factory or no gas (Vortex's side, not the partner's input) | Grant the role (`setDeployer`, §8.5) or fund the deployer address; the next cycle deploys | -| `deployment ... has no receipt after 10 minutes; sending it again` (warn) | A registration's deployment was dropped or never mined | Nothing, unless it repeats: check the deployer's pending nonce, gas price and the private RPC | -| `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 whose last confirmed conversion (for a never-converted -account, its activation; its creation when none was recorded) is 60 days old is paused -(`setGuardianPaused(true)` with the guardian key, which the backend therefore keeps hot, -ADR-0007 decision 4) and `dormant_since` is recorded; the conversion executor stops -swapping and forwarding for it but still recovers deposits marked for the refund path -(`recover` ignores the pause). -EURe arriving during dormancy accumulates safely; a deposit into a dormant account is -therefore refunded through §2.7 once the window is missed, unless re-confirmation -arrives first. - -**Re-confirmation (manual, via partner):** partner re-confirms in writing that the -destination is valid and client-controlled (ADR B5). If the destination changed, deploy -a new clone with the new destination and migrate (§5) — the clone has no setter and -Vortex must never redirect — and re-running the penny test is recommended for CEX -destinations. 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 (forward every open deposit; refund a remainder below the minimum via §2.7). -3. Link the new clone to the same Monerium profile (attestor flow — automated once the - account row's forwarder is repointed, or manual `POST /addresses`). -4. Move the IBAN: `PATCH /ibans/{iban}` with the new address — this is the - S1-sensitive operation; it must only ever happen inside an announced migration. -5. Update the `monerium_accounts` row (forwarder address), re-activate (§1.7), then - optionally penny-test the new clone: only an active account converts. - -There is no unlink at Monerium and no parking position: EURe mints to the -IBAN's current default address. The old clone stays linked, so a payer who names it in -the SEPA memo still mints there; that payment is refunded by hand (§2.7, "Memo-routed -payment"). - -## 6. Key compromise quick reference - -| Key | Blast radius | Response | -|---|---|---| -| Attestor | Can link addresses to profiles; never move funds (recovery payouts go only to the client's own bank account) | Rotate key; new forwarders need a new implementation (ATTESTOR is immutable); existing links unaffected | -| Keeper | `poke`/`swap`/`forward`/`recover`: can pick any whitelisted route and any reference inside the Chainlink band — worst case the fee reaches the 1% cap or the vault pays up to its caps, plus gas theft — and can move a payment whose batch is 2 h old to the client's refund wallet (never anywhere else, never a redirect) | Rotate; `setKeeper(old,false)` + `setKeeper(new,true)`; pause the vault while rotating; reconcile executions against Coinbase history; audit `Recovered` events against marked deposits; refund gas | -| Refund seed (`MONERIUM_B2B_REFUND_SEED`) | Derives every client's refund wallet; each holds funds only between that client's `recover` and its bank refund, or after a payer memo-routes a payment to it until it is refunded by hand (§2.7), and can redeem them out of the client's IBAN to any IBAN | Set `MONERIUM_B2B_AUTO_RECOVERY=off`, finish or reconcile open refunds by hand, rotate the seed, then give every client a new clone with its new refund wallet and move the IBANs (§5); the old wallets hold nothing between refunds unless a payment is memo-routed to them later (keep the old seed for that refund) | -| Guardian | Pause/unpause, bounded params, timelocked fee policy, route whitelist (validated), vault limits and withdrawal to treasury, revoking clones — delay-only griefing plus Vortex-money exposure | Two-step `transferGuardian`/`acceptGuardian`; audit pause, pending-policy, route, vault, keeper and deployer state after (below) | -| Deployer (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`) | Deploys a registered clone for any destination and refund address; no power over existing clones, funds or settings. Each such clone counts as a forwarder, so the vault pays it, up to its `dailyBudget` and `maxSubsidyPpm`, once its batch delay allows a swap | Guardian: `setDeployer(old,false)`, then `revokeForwarder` for its rogue clones (below); fresh key, fund it, `setDeployer(new,true)`, update the env | -| Whitelabel API credentials | Control-plane: can re-link/move IBANs (future mints only) — S1 | §2.5 full sequence | -| `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, and a forged delivery that lands before Monerium's sets that deposit's refund target (forward-only lattice + mint watcher bound the rest) | Rotate at both ends; reconcile deposits against chain; check each open deposit's payer IBAN against its order at Monerium before refunding | - -**Leaked deployer key (guardian key).** The factory registry is what the vault and the -backend's account mapping trust, so a rogue clone is cleaned out of it: - -1. Stop new deployments: `cast send $FACTORY "setDeployer(address,bool)" $OLD false - --rpc-url $RPC --private-key $GUARDIAN_KEY`. Clones it already deployed stay - registered. -2. Optionally pause the vault (`setPaused(true)`, §2.1) while you work: below-floor swaps - defer meanwhile. -3. Enumerate the clones the key deployed: the manifest generator (§1.3) lists the - factory's `ForwarderDeployed` events with their deploy transactions, and - `cast tx from` names the sender. Drop every clone that backs an account - (`GET /v1/admin/monerium-b2b/accounts`, `forwarderAddress`) or a registration - (`GET /v1/admin/monerium-b2b/registrations`, `deployTxHash`); the rest are rogue. -4. Revoke each rogue clone: `cast send $FACTORY "revokeForwarder(address)" - --rpc-url $RPC --private-key $GUARDIAN_KEY` (one-way, emits `ForwarderRevoked`; the - vault then refuses it, so its swaps that need a subsidy revert, and the backend refuses - to map it; a clone that holds funds keeps its forward and recover paths). Never revoke a clone that backs a live account: its subsidies stop and - the monitor raises `not registered on trusted factory` (§3). -5. Generate a fresh deployer key, fund it, grant it, set it in - `MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, restart, then unpause the vault. - -**After a guardian transfer.** Keepers and deployers survive `acceptGuardian`, so the new -guardian inherits them. Read the factory's `KeeperSet` and `DeployerSet` events, check -`isKeeper(address)` and `isDeployer(address)` for every address that ever appeared, and -reset what is not recognised with `setKeeper(address,false)` and -`setDeployer(address,false)`. - -## 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` | -| Recovery wallet | (reference run predates the recovery wallet; use any local EOA) | -| Local account id | `5ebca15c-dadf-4eeb-aabb-e9c7462ff6b3` | -| Mock Monerium profile id | `f436dbeb-6012-4688-ab3b-d2446980c835` | -| Managed profile id | `c419d077-3e2b-488a-b228-359311c63324` | -| 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. That run -predates the reference-priced fee bands; a new run records a reference, a route, and a -fee or subsidy per the bands instead of a flat zero fee. These addresses and hashes are -evidence from that ephemeral run, not deployment pins; use the receipts and addresses -produced by each new run. - -### 7.2 Start an archive-backed fork - -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` | 60 (on the client's net after fee and subsidy) | -| `MAX_FEE_PPM` | 10000 | -| `MAX_REFERENCE_DEVIATION_BPS` | 100 | -| `RECOVERY_DELAY` | 2 hours | -| `TRIGGER_DELAY` | 24 hours | -| Initial route | EURe → EURC → USDC, 500 / 500 (packed path constructor argument) | -| `RECOVERY_HASH` | `bytes32(0)` | -| `MIN_SWAP_FLOOR` | `1e18` | -| `CAP_CEILING` | `50000e18` | -| Initial `minSwapAmount` | `1e18` | -| Initial `perSwapCap` | `10000e18` | - -After deployment, register Anvil account 1 as a keeper: - -```bash -cast send "$FACTORY" "setKeeper(address,bool)" "$KEEPER" true \ - --private-key "$GUARDIAN_KEY" --rpc-url http://127.0.0.1:8545 -``` - -Deploy `VortexSubsidyVault` (USDC, account 3 as treasury, the factory, 5000 ppm, 200e6) -and point the factory at it with `setSubsidyVault`. Fund it with USDC from an -impersonated mainnet holder if you want to exercise a below-floor top-up; left empty, -a below-floor fill makes the keeper defer, which is also a valid outcome to observe. - -Deploy a client clone with the default policy (1250 / 1500) as in §1.2, passing the -refund address the backend derives for the fixture's Monerium profile ID (the account -mapping verifies it). Use a fresh salt and record the predicted address and receipt. -Read back `destination()`, `recoveryAddress()`, -`targetPpm()`, `floorPpm()`, and `FACTORY()`, then require -`factory.isForwarder(forwarder) == true` before continuing. The keeper computes its -reference from the live Coinbase ticker before each swap, so the backend needs outbound -HTTPS during the run. - -### 7.4 Create the local account fixture - -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": "", - "targetPpm": 1250, - "floorPpm": 1500 -} -``` - -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`. 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 and balances 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 — and -since 2026-09-17 the keeper never converts `unattr:` rows, so to exercise a conversion -insert the matching order row by hand (or send the mint through the sandbox webhook) -before the watcher records it; the log should then show the mint, one `swap` execution -and one `forward` execution. - -Verify the durable records: - -```sql -SELECT monerium_order_id, amount_raw, status, tx_hash, log_index, block_number -FROM monerium_fiat_deposits -WHERE account_id = ''; - -SELECT kind, deposit_id, eure_in_raw, usdc_gross_raw, fee_raw, subsidy_raw, usdc_net_raw, destination, - reference_rate_raw, reference_source, max_subsidy_raw, route_index, - tx_hash, nonce, broadcast_block_number, block_number, swap_log_index, status, error -FROM monerium_conversion_executions -WHERE account_id = '' ORDER BY created_at; -``` - -Required results: - -- One `forwarded` deposit with the real transfer hash and log index. -- One `confirmed` `swap` execution bound to it with the 25 EURe input, a recorded - reference (rate, source), the tier cap and route index 0, a fee or subsidy - consistent with the fill's position against the reference bands (`usdc_net_raw = - usdc_gross_raw - fee_raw + subsidy_raw`), non-null nonce/hash/block/swap-log-index, - destination matching the clone, and `error IS NULL`. If the vault was left empty and - the fill sat below the floor, expect a `deferring conversion` log line and no - execution row instead. -- One `confirmed` `forward` execution bound to the same deposit whose `usdc_net_raw` - equals the swap's net. -- The forwarder's EURe and USDC balances are zero. -- The destination's USDC balance increased by the forward's `usdc_net_raw` in one transfer. -- The swap receipt contains `SwapExecuted` from the clone and no transfer to the - destination; the forward receipt contains `Forwarded` and the USDC `Transfer` from the - clone to the configured destination. - -### 7.7 What this exercise validates - -- 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 `swap` execution bound to the deposit - with its nonce, transaction hash, block number, log index, amounts and destination - recorded, followed by a confirmed `forward` execution for the summed net. -- The real contract accepts the current Chainlink EUR/USD answer, the keeper's live - Coinbase reference inside the band, and swaps successfully through whitelisted route 0 - (EURe -> EURC -> USDC on the 5-bps tiers). -- Keeper authorization, the 25 EURe minimum, allowance reset, full EURe consumption, - fee-band accounting against the recorded reference, USDC accumulating on the clone, - and one forward to the immutable per-client destination work together. - -### 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 fee-policy timelocks, route selection among several routes, a funded - vault's top-up (unless you fund it), the reference band rejection, per-swap-cap - chunking of one deposit, sub-minimum remainders, pause controls, dormancy, - permissionless triggering, or the recovery path (§2.7). -- It does not test stale/invalid oracle answers, insufficient liquidity, excess price - impact, slippage reverts, router failure, token transfer failure, or depeg behavior. -- It does not test reorg replacement, duplicate-log replay, concurrent executors, - advisory-lock contention, process crashes before/after broadcast, lost transaction - hashes, nonce replacement, or restart recovery. -- It does not test production key storage, 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. - -## 8. Sepolia sandbox bring-up - -Monerium's sandbox mints EURe on Ethereum Sepolia, and `api-sandbox.vortexfinance.co` -(the `vortex-sandbox` Render service, deployed from `main`) runs the keeper for it. -This section is the Sepolia counterpart of the mainnet deploy checklist in the rollout -doc. Every command below was dry-run on a local fork of Sepolia on 2026-10-06: pool, -factory, vault and clone, a payment converted in two chunks and forwarded, and the -refund leg (recover after the window, reverse swap). Set the B2B variables on the -`vortex-sandbox` service only, never in the shared "Vortex API" env group, which -production and staging both read; production stays dark. - -### 8.1 Sepolia addresses (verified on chain 2026-10-06) - -| Contract | Address | -|---|---| -| EURe (Monerium sandbox) | `0x67b34b93ac295c985e856E5B8A20D83026b580Eb` | -| EURC (Circle) | `0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4` | -| USDC (Circle) | `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238` | -| Chainlink EUR/USD (8 decimals) | `0x1a81afB8146aeFfCFc5E50e8479e826E7D55b910` | -| Uniswap v3 factory | `0x0227628f3F023bb0B980b67D528571c95c6DaC1c` | -| Uniswap SwapRouter02 | `0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E` | -| Uniswap NonfungiblePositionManager | `0x1238536071E1c677A632429e3655c799b22cDA52` | -| Mispriced EURe/USDC 5 bps pool (never whitelist) | `0xaC4D4fe930cb78b6eAC08e8Ec3cCA5Ea12059aD2` | - -The July 2026 link-test deployment (factory `0xcBE354…`) used other tokens and a -placeholder router; it is not reusable. - -The sandbox deployment of 2026-10-07 (§8.4 and §8.5, from the PR #1408 code with the -bound CREATE2 salt and `revokeForwarder`): - -| Contract | Address | -|---|---| -| Factory (guardian `0x44fd1d3b38A4523F6F5309ea5a5F8d7b4Df82a7f`) | `0xebbE4f26e856c138D34086Aa1CEB468d20c79992` | -| Forwarder implementation (`RECOVERY_DELAY` 900) | `0x0cFc619C62f9Cf0778D3269345cd6Ca2AF39BE1C` | -| Subsidy vault | `0x155f59523E24ef4046756A83a58B972e31d4ec4a` | -| EURe/USDC 1 bps pool, factory route 0 | `0xFAB9CFfbA5Fc6fB32c7121128C58716D460a5579` | - -An earlier factory of the same day (`0x1E87a5…3891`, vault `0xE7C9Da…B95c`, emptied back -to the treasury) predates the salt binding and the revoke; it has no clones and is not -used. - -### 8.2 What differs from mainnet - -- **Vortex runs the pool.** The only EURe/USDC pool with liquidity prices EURe at 0.71 - USDC (Chainlink: 1.127 on 2026-10-06), so a swap there misses the floor (Chainlink - − `SLIPPAGE_BPS`) and reverts, and every payment would end in a refund. Vortex seeds - its own 1 bps pool at the Chainlink price (§8.4). -- **No quoting.** Off mainnet the keeper does not quote routes; it swaps on the first - enabled factory route. Make the 1 bps pool the initial route and add no other. -- **No arbitrage.** Nothing pulls the pool back to the market, and each conversion moves - it a little. Re-centre it before every test session (§8.9). The mispriced 5 bps pool - is an open arbitrage against it: if its USDC disappears between sessions, re-seed. -- **Fresh keys only.** The well-known development keys (anvil's) carry delegated code on - Sepolia that sweeps any ETH sent to them. -- **No private orderflow.** `MONERIUM_B2B_PRIVATE_RPC_URL` is required only when - `DEPLOYMENT_ENV=production`. -- **Public RPCs lag.** A load-balanced endpoint such as publicnode can answer a balance - or nonce from a node a block behind; when a send fails with `nonce too low`, resend - with `--nonce`. - -### 8.3 Keys and funding - -- Fresh EOAs: guardian, keeper, attestor and deployer (four distinct keys), the float wallet - (`MONERIUM_B2B_FLOAT_PRIVATE_KEY`, also distinct from the deployer: both send with - implicit nonces), and a fee recipient address Vortex controls. - `MONERIUM_B2B_REFUND_SEED` is any fresh 32-byte secret (`openssl rand -hex 32`). The - first block below writes them, the webhook secret and a test destination to a private - file outside every checkout, under the backend's variable names, and prints only the - addresses. -- Sepolia ETH: about 0.2 each for the guardian (factory and vault deployment), the - deployer (one clone per registered client), the keeper (swaps, forwards, recoveries) - and the float (it tops up the refund wallets' gas). -- Sandbox EURe for the guardian (pool seeding) and the float (refund top-ups): link the - address to a Vortex profile in Monerium's sandbox and use "Simulate bank transfer" on - that profile's IBAN. Sandbox EURe costs nothing. -- USDC: buy it with sandbox EURe from the mispriced 5 bps pool; its price is irrelevant - when the EURe is free. 2,000 EURe bought about 1,385 USDC in the dry run. - -```bash -F=~/.vortex/monerium-b2b-sepolia.env # never commit it or paste it anywhere -[ -e "$F" ] || (umask 077; mkdir -p ~/.vortex - for ROLE in GUARDIAN KEEPER ATTESTOR DEPLOYER FLOAT FEE_RECIPIENT E2E_DESTINATION; do - W=$(cast wallet new --json) - case $ROLE in FEE_RECIPIENT|E2E_DESTINATION) VAR=${ROLE}_PRIVATE_KEY ;; *) VAR=MONERIUM_B2B_${ROLE}_PRIVATE_KEY ;; esac - echo "$VAR=$(jq -r '.[0].private_key' <<< "$W")" >> "$F"; echo "$ROLE=$(jq -r '.[0].address' <<< "$W")" >> "$F" - done - echo "MONERIUM_B2B_REFUND_SEED=0x$(openssl rand -hex 32)" >> "$F" - echo "MONERIUM_B2B_WEBHOOK_SECRET=whsec_$(openssl rand -base64 32)" >> "$F") -grep -E '^[A-Z_0-9]+=0x[0-9a-fA-F]{40}$' "$F" # the addresses -set -a; . "$F"; set +a; GUARDIAN_KEY=$MONERIUM_B2B_GUARDIAN_PRIVATE_KEY -``` - -```bash -RPC= -EURE=0x67b34b93ac295c985e856E5B8A20D83026b580Eb -EURC=0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4 -USDC=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 -ORACLE=0x1a81afB8146aeFfCFc5E50e8479e826E7D55b910 -ROUTER=0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E -NPM=0x1238536071E1c677A632429e3655c799b22cDA52 -UNI_FACTORY=0x0227628f3F023bb0B980b67D528571c95c6DaC1c -GUARDIAN=$(cast wallet address $GUARDIAN_KEY) - -cast send $EURE "approve(address,uint256)" $ROUTER 2000ether --rpc-url $RPC --private-key $GUARDIAN_KEY -cast send $ROUTER "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ - "($EURE,$USDC,500,$GUARDIAN,2000000000000000000000,0,0)" --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -### 8.4 Seed the 1 bps pool - -Seed it at the higher of the Chainlink answer and the keeper's reference, the Coinbase -EURC-USDC midpoint: a fill below the reference less the client's floor waits for the -subsidy ladder, and one below Chainlink less `SLIPPAGE_BPS` reverts. On 2026-10-07 -Sepolia's Chainlink answer was 20 bps above the reference. - -USDC sorts before EURe, so the pool's price is EURe base units per USDC base unit, -`1e20 / price` for a price with 8 decimals. The position spans ±1% (100 ticks at the -1 bps tier's spacing of 1). 1,000 USDC and about 900 EURe keep a €100 payment's price -impact around 0.1%. - -```bash -ANSWER=$(cast call $ORACLE "latestRoundData()(uint80,int256,uint256,uint256,uint80)" --rpc-url $RPC | sed -n 2p | awk '{print $1}') -REF=$(curl -s https://api.exchange.coinbase.com/products/EURC-USDC/ticker | jq -r '((.bid|tonumber)+(.ask|tonumber))/2*1e8|floor') -PRICE=$(( ANSWER > REF ? ANSWER : REF )) -read SQRT_PRICE TICK_LOWER TICK_UPPER < <(python3 -c " -import math; a=$PRICE -tick = math.floor(math.log(10**20 / a, 1.0001)) -print(math.isqrt(10**20 * 2**192 // a), tick - 100, tick + 100)") - -cast send $NPM "createAndInitializePoolIfNecessary(address,address,uint24,uint160)" $USDC $EURE 100 $SQRT_PRICE \ - --rpc-url $RPC --private-key $GUARDIAN_KEY -POOL=$(cast call $UNI_FACTORY "getPool(address,address,uint24)(address)" $USDC $EURE 100 --rpc-url $RPC) -cast send $USDC "approve(address,uint256)" $NPM 1000000000 --rpc-url $RPC --private-key $GUARDIAN_KEY -cast send $EURE "approve(address,uint256)" $NPM 1000ether --rpc-url $RPC --private-key $GUARDIAN_KEY -# pass a limit: the mint used 2.43M gas on Sepolia, and 1.5M ran out -cast send $NPM "mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))" \ - "($USDC,$EURE,100,$TICK_LOWER,$TICK_UPPER,1000000000,1000000000000000000000,0,0,$GUARDIAN,$(( $(date +%s) + 3600 )))" \ - --gas-limit 4000000 --rpc-url $RPC --private-key $GUARDIAN_KEY -cast call $USDC "balanceOf(address)(uint256)" $POOL --rpc-url $RPC # 1000000000 -``` - -### 8.5 Deploy the factory, register the keeper, deploy the vault - -The parameters are the ADR's (§7.3 table) except `perSwapCap`: €25 lets a €60 test -payment convert in three chunks. It is operational; `setPerSwapCap` changes it later. -`RECOVERY_DELAY` is 15 minutes instead of two hours, so a refund test fits into one -session; it is immutable, and `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (§8.6) matches it. -The subsidy ladder's top tier, from 16 minutes, then never applies. The initial route is -the 1 bps pool, EURe → USDC. - -From `contracts/monerium-forwarder/`, with `ATTESTOR`, `KEEPER`, `DEPLOYER` and -`FEE_RECIPIENT` set to the §8.3 addresses, and `FACTORY` and `VAULT` taken from forge's "Deployed to" line: - -```bash -ROUTE=$(cast concat-hex $EURE 0x000064 $USDC) # fee 100 as three bytes -forge create src/VortexForwarderFactory.sol:VortexForwarderFactory --rpc-url $RPC --private-key $GUARDIAN_KEY --broadcast \ - --constructor-args "($EURE,$EURC,$USDC,$ROUTER,$ORACLE,$ATTESTOR,$FEE_RECIPIENT,187200,60,10000,100,900,86400,0x0000000000000000000000000000000000000000000000000000000000000000)" \ - 1000000000000000000 50000000000000000000000 1000000000000000000 25000000000000000000 $ROUTE -cast call $FACTORY "route(uint256)(bytes,bool)" 0 --rpc-url $RPC # the path above, true -cast send $FACTORY "setKeeper(address,bool)" $KEEPER true --rpc-url $RPC --private-key $GUARDIAN_KEY -# the partner registration path deploys clones with its own deployer key -cast send $FACTORY "setDeployer(address,bool)" $DEPLOYER true --rpc-url $RPC --private-key $GUARDIAN_KEY - -# vault: 1% per swap (the ladder's top), 50 USDC per day -forge create src/VortexSubsidyVault.sol:VortexSubsidyVault --rpc-url $RPC --private-key $GUARDIAN_KEY --broadcast \ - --constructor-args $USDC $FEE_RECIPIENT $FACTORY 10000 50000000 -cast send $FACTORY "setSubsidyVault(address)" $VAULT --rpc-url $RPC --private-key $GUARDIAN_KEY -cast send $USDC "transfer(address,uint256)" $VAULT 20000000 --rpc-url $RPC --private-key $GUARDIAN_KEY -``` - -Then generate and verify the manifest as in §1.3. Sepolia's public RPCs prune old logs, -so pass `--logs-rpc` with an endpoint that serves the full history, and commit the -result to `manifests/`. - -### 8.6 Sandbox backend configuration - -On the `vortex-sandbox` service only: - -| Variable | Value | -|---|---| -| `FLOW_VARIANT` | `mykobo` (startup refuses B2B otherwise; the sandbox's retail EUR onramp then runs on Mykobo) | -| `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET` | The partner's sandbox white-label app | -| `MONERIUM_B2B_RPC_URL` | Sepolia RPC | -| `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` | `$FACTORY` | -| `MONERIUM_B2B_KEEPER_PRIVATE_KEY`, `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` | the three keys of §8.3 | -| `MONERIUM_B2B_REFUND_SEED`, `MONERIUM_B2B_FLOAT_PRIVATE_KEY` | §8.3 | -| `MONERIUM_B2B_DEPLOYER_PRIVATE_KEY` | the deployer key of §8.3 (granted with `setDeployer`) | -| `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID` | The partner's manager profile ID on the sandbox: the only key that may register destinations | -| `MONERIUM_B2B_AUTO_RECOVERY` | `auto`, so the refund test runs end to end | -| `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` | `15`, the factory's `RECOVERY_DELAY` (§8.5) | -| `MONERIUM_B2B_WEBHOOK_SECRET` | `whsec_` plus base64 of 32 random bytes: `echo "whsec_$(openssl rand -base64 32)"` | -| `MONERIUM_B2B_ENABLED` | `true`, set last | - -Restart the service. Startup fails if a required setting is missing; once it is up, -`GET /v1/monerium-b2b/accounts` answers 401 without a key instead of 404. - -Only then register Vortex's webhook subscription on the partner's sandbox app with the -same secret: Monerium pings the URL when the subscription is created and does not create -it if the ping fails, and the route exists only while B2B is enabled. Run it from -`apps/api` with that app's credentials and `MONERIUM_API_URL` pointing at Monerium's -sandbox API: - -```bash -SECRET= MONERIUM_WHITELABEL_CLIENT_ID=... MONERIUM_WHITELABEL_CLIENT_SECRET=... \ -MONERIUM_API_URL=https://api.monerium.dev bun -e ' -import { MoneriumApiService } from "@vortexfi/shared"; -console.log(await MoneriumApiService.getInstance().createWebhook({ - secret: process.env.SECRET, types: ["iban.updated", "order.created", "order.updated", "profile.updated"], - url: "https://api-sandbox.vortexfinance.co/v1/monerium-b2b/webhook" }));' -``` - -### 8.7 Partner and test clients - -1. Make the partner's sandbox profile a manager: - `PUT /v1/admin/managed-profile-managers/` with corridor `EU` and customer - type `business`. The partner then takes a key from dashboard-sandbox and registers - its webhook through `POST /v1/webhook` (`DEPOSIT_UPDATED`, `ACCOUNT_UPDATED`). -2. Per test client, the partner registers the Monerium profile ID and a Sepolia - destination with `POST /v1/monerium-b2b/accounts`. The keeper deploys, maps, links and - requests the IBAN once Monerium approves the profile, and the account activates when - the IBAN is recorded: only a backend with `SANDBOX_ENABLED=true` (which boot pairs with - `DEPLOYMENT_ENV=sandbox`) activates by itself, every other environment needs the §1.7 - call. The manual path (§1.2 to §1.7) remains for a client registered outside the API. - -### 8.8 Test payments - -Each test runs through one command, with the §8.3 file (plus `MONERIUM_B2B_RPC_URL` and -`MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` once §8.5 has run) and the partner's secret key: - -```bash -VORTEX_SECRET_KEY=sk_test_... bun --env-file="$F" run --cwd apps/api monerium-b2b:sandbox-e2e \ - --profile [--amount 60] [--refund] -``` - -It registers the destination (or resumes the registration), waits until the account is -active, checks the clone on chain, asks for the payment, and then checks the outcome: one -forward transaction paying the deposit's net USDC to the destination, or the refund. -`--refund` suspends the account for the payment and reactivates it afterwards, with -`ADMIN_SECRET`. The script does not make the payment. How a payment reaches a -white-label profile's IBAN in the sandbox is still open (rollout ledger): the dashboard's -"Simulate bank transfer" mints only for your own profile, and EURe sent to the clone on -chain is recorded as unattributed and never converted. - -| Test | Payment | Expected | -|---|---|---| -| Normal | €20 | One chunk, one forward. The destination receives the reference less the client's target (12.5 bps); the fee treasury the surplus over it | -| Chunked | €60 | Three chunks at the €25 cap, then one forward of their sum | -| Refund | €15 | Suspend the account before the payment (`PATCH /v1/admin/monerium-b2b/accounts//status` with `suspended`): the keeper converts nothing for a suspended account but still arms the clone's clock and runs recoveries. After the deadline (`MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES`, 15 minutes in the sandbox) the deadline job marks the deposit, the keeper recovers it, and the refund leaves from the client's IBAN. Reactivate afterwards. If the simulated transfer carries no payer IBAN and name, the refund parks as `recovery_failed`; that is a finding about the sandbox simulation. Never close it while the refund wallet still holds the EURe: refund it by hand (§2.7) or sweep the EURe back to the float, then `PATCH /v1/admin/monerium-b2b/deposits//status` with `{"status": "refunded"}` (it sends `DEPOSIT_RETURNED`; note a sweep in the ops ledger) | - -`DEPOSIT_UPDATED` reports every step to the partner, and `GET /v1/monerium-b2b/deposits` -shows the same snapshots. - -### 8.9 Re-centre the pool before a session - -Each conversion sells EURe into the pool and pushes its price down, and the reference and -Chainlink move on their own. A swap with a price limit moves the pool back to the higher -of the two (§8.4): - -```bash -ANSWER=$(cast call $ORACLE "latestRoundData()(uint80,int256,uint256,uint256,uint80)" --rpc-url $RPC | sed -n 2p | awk '{print $1}') -REF=$(curl -s https://api.exchange.coinbase.com/products/EURC-USDC/ticker | jq -r '((.bid|tonumber)+(.ask|tonumber))/2*1e8|floor') -PRICE=$(( ANSWER > REF ? ANSWER : REF )) -TARGET=$(python3 -c "import math; print(math.isqrt(10**20 * 2**192 // $PRICE))") -CURRENT=$(cast call $POOL "slot0()(uint160,int24,uint16,uint16,uint16,uint8,bool)" --rpc-url $RPC | head -1 | awk '{print $1}') -if python3 -c "import sys; sys.exit(0 if $CURRENT > $TARGET else 1)"; then - # EURe cheaper than the target: buy EURe with USDC up to it - cast send $USDC "approve(address,uint256)" $ROUTER 1000000000 --rpc-url $RPC --private-key $GUARDIAN_KEY - cast send $ROUTER "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ - "($USDC,$EURE,100,$GUARDIAN,1000000000,0,$TARGET)" --gas-limit 600000 --rpc-url $RPC --private-key $GUARDIAN_KEY -else - # EURe dearer than the target: sell EURe for USDC down to it - cast send $EURE "approve(address,uint256)" $ROUTER 1000ether --rpc-url $RPC --private-key $GUARDIAN_KEY - cast send $ROUTER "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ - "($EURE,$USDC,100,$GUARDIAN,1000000000000000000000,0,$TARGET)" --gas-limit 600000 --rpc-url $RPC --private-key $GUARDIAN_KEY -fi -``` - -The router pulls only what the move needs. Chainlink's Sepolia feed must stay under the -52-hour `MAX_ORACLE_AGE`; it was four hours old when checked. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 76a2ab478..62db40a01 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -196,7 +196,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 45. **The Avenia webhook MUST remain notification-only for imported KYC** — A signed event may enqueue an idempotent notification but MUST NOT approve or otherwise mutate verification state. Exact-attempt polling remains the authoritative persistence path. 46. **Consent evidence MUST remain explicit and provisional** — The request requires literal `consentAttested: true`; Vortex appends an entry containing actor, subject, timestamp, and server-controlled policy `sumsub-share-v1` to `verification_submission.consentAttestations` without the raw token. Replacing a provider-`401` failed claim with a new idempotency key MUST preserve prior entries and append the new attestation. This policy is enabled provisionally and MUST NOT be represented as replacing legal basis, applicant disclosure, biometric/special-category consent, or data-transfer obligations while legal/provider confirmations remain unresolved. 47. **Standard individual KYC submission MUST be durable, request-bound, authorized at claim time, and privacy-safe** — Vortex MUST persist the submission JSON on the locked canonical case before the Avenia Level 1 POST and atomically persist its complete attempt baseline and case `submitted_at` when claiming it as `submitted`. Deterministic provider client rejections create no attempt, so they record `failed` with a fixed `400` and may be corrected and retried, mirroring UBO creation. Timeouts, rate limits, conflicts, 5xx, and local-confirmation outcomes become `ambiguous`; reconciliation uses that timestamp, excludes baseline and attempts bound to other cases, fails closed when it is absent, and never sends a second POST for an active claim. Avenia's own status and detail MUST NOT be forwarded to the caller. Prepared, submitted, ambiguous, and confirmed reuse MUST match actor, subject, and the SHA-256 digest of the canonical flat payload before reconciliation or replay; mismatches return a fixed `409` without a provider POST. Unmanaged calls MUST have `actor === subject` with no managed selectors. Managed calls MUST identify the controlling manager separately, allow only `actor === controlling manager` or `actor === subject`, and lock and revalidate the controlling active manager, exact active manager/subject/relationship ID, `BR` permission, null-means-all individual policy, managed subject, and expected active individual entity during preparation and immediately before `prepared -> submitted`. Second-check revocation or a concurrent canonical approval MUST prevent the provider POST. Confirmation MUST bind its exact attempt ID even when approval won concurrently, without downgrading terminal state or timestamps. A provider-confirmed retryable terminal may replace the prior confirmed JSON with a prepared claim carrying the new request fingerprint; confirmation clears prior case approval/rejection timestamps and failure reasons before returning it to pending. A standard provider state of `COMPLETED` without `APPROVED` or `REJECTED` MUST fail closed before either direct-status or onboarding persistence. The standard identity payload MUST use the Avenia client's sensitive-body mode, and raw payloads, digest inputs, request logs, provider error details, and thrown errors MUST NOT contain identity values echoed by Avenia. -48. **A tax-ID claim MUST be bound to the identity later verified on that subaccount** — The unique tax hash is exclusive from the first `createSubaccount`, so the identity the claimant submits through the API MUST be that tax ID. `newKyc` MUST return `400` before any provider call or KYC claim when the normalized `taxIdNumber` (non-string values included) does not hash, via `hashTaxReference`, to the resolved record's `tax_reference_hash`. `submitKybLevel1Api` MUST apply the same rule to `taxIdentificationNumberTin` before any provider read or write. Formatted and plain forms of one tax ID are equal. The claim is released only by an operator (`docs/operations-brl-tax-id-claim-release.md`); no request path releases another profile's unapproved claim. This binds only the API-submitted identity: Avenia subaccount creation sends only `accountType` and `name`, so the hosted KYB flow (`initiateKybLevel1`) and provider approval are not compared with the claimed tax ID, and `assertAveniaImportedTaxIdentity` (Sumsub token import) passes when the provider returns no tax ID (RISK-026). +48. **A tax-ID claim MUST be bound to the identity later verified on that subaccount** — The unique tax hash is exclusive from the first `createSubaccount`, so the identity the claimant submits through the API MUST be that tax ID. `newKyc` MUST return `400` before any provider call or KYC claim when the normalized `taxIdNumber` (non-string values included) does not hash, via `hashTaxReference`, to the resolved record's `tax_reference_hash`. `submitKybLevel1Api` MUST apply the same rule to `taxIdentificationNumberTin` before any provider read or write. Formatted and plain forms of one tax ID are equal. The claim is released only by an operator (`operations/operations-brl-tax-id-claim-release.md` in the private repository); no request path releases another profile's unapproved claim. This binds only the API-submitted identity: Avenia subaccount creation sends only `accountType` and `name`, so the hosted KYB flow (`initiateKybLevel1`) and provider approval are not compared with the claimed tax ID, and `assertAveniaImportedTaxIdentity` (Sumsub token import) passes when the provider returns no tax ID (RISK-026). ## Threat Vectors & Mitigations diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 794e75c7f..1e07fc9bd 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -48,7 +48,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-limited to one pass per 30 minutes) is detection-only; its invariants: -1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per `docs/operations-monerium-b2b-runbook.md`). +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 the B2B operations runbook in the private repository). 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). 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. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 55b55342e..cfa26dbca 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -37,7 +37,7 @@ register and the owning module specification. | RISK-014 | Accepted | Medium | Pricing + Treasury | CoinGecko’s `usd-coin` price is used as a USD/fiat fallback or sanity reference, so a USDC depeg can distort the reference. | FastForex/Binance primary routes, sanity bands, short cache TTL, fail-closed when no valid provider remains, operational depeg monitoring. | Replace with an independent fiat FX reference before raising depeg-sensitive exposure. | | RISK-015 | Accepted | Low | EVM Operations | Base cleanup sweeps supported ERC-20 residuals after completion but does not sweep small native gas dust. | Just-in-time gas funding and token sweeps limit residual value. | Revisit if observed native residuals become material. | | RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base and Polygon ramps are not swept by their post-process handlers, and no AssetHub cleanup handler exists. The Monerium Polygon cleanup intent covers residual USDC, not EURe. | Cleanup worker selects terminal ramps; completed Base token cleanup and completed Polygon USDC cleanup work; AssetHub corridors remain quote-disabled; client-held ephemeral keys are retained for recovery. The recovery worker starts funded `initial` SELL ramps instead of leaving them stranded, but one that later fails falls under this risk like any failed ramp. | Add safe failed/timed-out handling and EURe coverage on Polygon, widen safe Base cleanup, and implement AssetHub cleanup before relying on automatic failure refunds. | -| RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](../operations-legacy-schema-cleanup.md), verify production cleanup, and confirm the post-deploy observation window is clean. | +| RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-legacy-schema-cleanup.md) (private repository), verify production cleanup, and confirm the post-deploy observation window is clean. | | RISK-018 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer with broad read and mutation rights. Ramp registration, update, and start; provider onboarding and KYC/KYB mutations; managed-child creation/deletion; manager/child credential creation/revocation; and partner payout-destination registration are denied. Quote generation, recipient, active-entity, notification, and other customer-account operations remain available. Alfredpay fiat-account creation and deletion are explicitly accepted even though these provider-side payout-account mutations outlive the session. | Per-operator Supabase identity plus per-request `vortex_admin` re-check; role removal atomically revokes live sessions; 30-minute non-renewable TTL; transaction-serialized and database-unique active session per (actor, target); hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks ramp money movement, KYC/KYB mutations, managed-child lifecycle, manager/child credential lifecycle, partner payout-destination registration, and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before changing the allowed mutation scope; managed sub-account composition has been reviewed with lifecycle and credential mutations denied. See `01-auth/admin-impersonation.md`. | | RISK-019 | Accepted | High | Product + Compliance | Managed-profile contact email uniqueness is manager-scoped, while Alfredpay uses email as provider identity. Different managers can submit the same normalized email; on an Alfredpay `409`, Vortex may adopt the provider customer returned for that email when country and customer type match, without independent proof that the second manager controls that provider identity. | Manager/child authorization remains isolated; contact email is immutable and unique within one manager; conflict recovery rejects country/type mismatch; the provider customer ID remains globally unique locally. Partners must supply an email identity they are authorized to use, and operations must investigate cross-manager collision errors rather than bypass uniqueness. | Before onboarding managers whose customer-email namespaces may overlap, enforce global or provider-scoped ownership of contact email, or replace email-based adoption with a provider ownership/claim proof and migrate existing relationships. | | RISK-020 | Deferred | High | Cross-chain + Operations | Moonbeam is unavailable. Historical ramps, residual ephemeral funds, and legacy rebalancer state may remain stranded. A successful `moonbeamCleanup` now records retirement acknowledgement rather than an on-chain sweep. | Moonbeam-dependent registration/update/start, phase execution, automatic recovery, status polling, and legacy rebalancing are disabled without deleting persisted flow identities or recovery data. | Reconcile every affected ramp/account and complete a reviewed manual rescue before restoring any Moonbeam runtime path or automatic recovery. | @@ -46,7 +46,7 @@ register and the owning module specification. | RISK-023 | Accepted | High | Payments Platform + Operations | The active Monerium EUR onramp attributes settlement from the linked owner's EURe balance increasing by the quoted post-fee amount. It does not correlate a provider issue order or mint transaction to the ramp, so an unrelated, duplicate, late, concurrent, or replacement-ramp credit can satisfy the delta. | Registration snapshots the owner balance after resolving one approved profile/Polygon EOA/IBAN match and rejects a second live ramp for the same owner (which would share the permit nonce and race for one credit); IBAN moves are refused while a ramp waits on the current wallet; execution transfers only the exact quoted amount; excess stays with the owner; caller-controlled provider identity is rejected. | Implement deterministic provider-order or mint-transaction correlation before increasing Monerium volume, operating concurrent/replacement ramps for one owner, or claiming payment-level attribution. | | RISK-024 | Accepted | High | Payments Platform + Product | The Monerium owner permit expires one week after preparation (the swap presign deadline) and can become stale if its nonce is consumed before SEPA settlement. There is no automatic reauthorization, refund, or recovery path, so late settlement can leave EURe in the owner's wallet and require manual resolution. | Payment instructions are withheld until the exact permit and downstream presigns validate; execution rechecks nonce, deadline, allowance, and balances and fails closed rather than broadening authorization. | Add a safe re-sign/recovery/refund flow and define the accepted SEPA settlement window before unattended operation at material volume. | | RISK-025 | Accepted | Medium | Payments Platform + Product | Monerium profiles onboarded through the OAuth application are invisible to the white-label application, so their EUR readiness and ramp registration depend on an access/refresh token pair that exists only in backend memory. A backend restart, token revocation, or refresh failure makes such a user unregisterable until they reconnect Monerium. | Reads and registration fail closed with `MONERIUM_REAUTHENTICATION_REQUIRED`; the dashboard and widget prompt a reconnect; no token is persisted, so nothing at rest can be stolen. | Add an encrypted refresh-token store or migrate OAuth profiles into the white-label application before relying on unattended re-registration or observing reconnect prompts at material volume. | -| RISK-026 | Accepted | Medium | Payments Platform + Product + Operations | Brazilian tax-ID squatting and enumeration. `createSubaccount` reserves a CPF/CNPJ exclusively for the first authenticated caller (unique `ux_provider_customers_tax_hash`) before the provider has verified who the caller is, and Supabase sign-up is open OTP. Any signed-in session can therefore claim a valid, unclaimed tax ID; the real owner then receives `409` and cannot recover without an operator. The tax-keyed lookup routes also answer differently for unknown and other-owned tax IDs, so they can be used to test whether a CPF is registered. The product owner accepted a lean mitigation instead of making the reservation exclusive only on provider approval. | Checksum, account-type and `name` validation before any provider call or write; `newKyc` / KYB submission bound to the claimed tax ID (the hosted KYB flow and provider approval are not bound); the global 100 req/min per IP limiter, which is the only bound on tax-ID existence probing; unchanged ownership checks and status codes; operator release runbook ([`operations-brl-tax-id-claim-release.md`](../operations-brl-tax-id-claim-release.md)). | Implement exclusivity only on provider approval (unapproved claims contestable or expiring) before BRL onboarding volume grows materially, when a second squat needs an operator release, or when tax-ID enumeration is observed (then answer other-owned and unregistered tax IDs identically, or cap probes per principal in the database). | +| RISK-026 | Accepted | Medium | Payments Platform + Product + Operations | Brazilian tax-ID squatting and enumeration. `createSubaccount` reserves a CPF/CNPJ exclusively for the first authenticated caller (unique `ux_provider_customers_tax_hash`) before the provider has verified who the caller is, and Supabase sign-up is open OTP. Any signed-in session can therefore claim a valid, unclaimed tax ID; the real owner then receives `409` and cannot recover without an operator. The tax-keyed lookup routes also answer differently for unknown and other-owned tax IDs, so they can be used to test whether a CPF is registered. The product owner accepted a lean mitigation instead of making the reservation exclusive only on provider approval. | Checksum, account-type and `name` validation before any provider call or write; `newKyc` / KYB submission bound to the claimed tax ID (the hosted KYB flow and provider approval are not bound); the global 100 req/min per IP limiter, which is the only bound on tax-ID existence probing; unchanged ownership checks and status codes; operator release runbook ([`operations-brl-tax-id-claim-release.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-brl-tax-id-claim-release.md) (private repository)). | Implement exclusivity only on provider approval (unapproved claims contestable or expiring) before BRL onboarding volume grows materially, when a second squat needs an operator release, or when tax-ID enumeration is observed (then answer other-owned and unregistered tax IDs identically, or cap probes per principal in the database). | ## Review cadence From ba36c284728c417831fda56c62156e076d79c51d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 11:21:07 +0200 Subject: [PATCH 5/7] docs(repo): state the consumer onramp security posture directly --- docs/proposal-monerium-consumer-onramp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/proposal-monerium-consumer-onramp.md b/docs/proposal-monerium-consumer-onramp.md index a8735cc1a..5100773b2 100644 --- a/docs/proposal-monerium-consumer-onramp.md +++ b/docs/proposal-monerium-consumer-onramp.md @@ -35,7 +35,7 @@ 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. -**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. +**Security posture:** the design 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. Scope (reduced v1) From d4dc2f33e3a18bc290df9abb897006cc4bd35d87 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 11:24:38 +0200 Subject: [PATCH 6/7] docs(repo): narrow the consumer onramp launch gates to engineering --- docs/proposal-monerium-consumer-onramp.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/proposal-monerium-consumer-onramp.md b/docs/proposal-monerium-consumer-onramp.md index 5100773b2..4ea457a0a 100644 --- a/docs/proposal-monerium-consumer-onramp.md +++ b/docs/proposal-monerium-consumer-onramp.md @@ -13,8 +13,8 @@ **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). +- Trust model rewritten as scoped guarantees per lifecycle stage (F01, F02, F17). +- Monerium control-plane authority (`PATCH /ibans/{iban}` on bearer auth alone) verified against live docs (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). @@ -68,7 +68,7 @@ Replaces v1 §4/§8.3. Every user-facing security claim must be traceable to one | 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 | +| **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 | Monerium insolvency/compliance action; credential theft. Mitigations: Monerium-side pinning where available (OQ4), 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) | @@ -254,7 +254,7 @@ The public API exposes `MoneriumAccount` (long-lived) and per-deposit `FiatDepos ## 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. +- **02:00-UTC module vulnerability runbook:** pause all → ask Monerium to suspend affected IBANs (capability to be confirmed) → 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. @@ -262,17 +262,15 @@ The public API exposes `MoneriumAccount` (long-lived) and per-deposit `FiatDepos ## 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):** 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. +- **G1 — 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. +- **G2 — 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? +- **OQ4** — Does Monerium offer pinning that upgrades the S1 statement, or does it stay trust-based? - **OQ5** — Recovery-owner UX default (second passkey vs printable key as the recommended path). --- @@ -281,7 +279,7 @@ The public API exposes `MoneriumAccount` (long-lived) and per-deposit `FiatDepos | ID | Disposition | Response / change | |---|---|---| -| 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 | +| 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-side controls tracked as OQ4; association monitoring + credential isolation added | | 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 | @@ -294,8 +292,8 @@ The public API exposes `MoneriumAccount` (long-lived) and per-deposit `FiatDepos | 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) | +| F14 | **Accept** | Instant guardian pause; incident runbook incl. Monerium IBAN suspension (capability to be confirmed); 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; 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 | From d9952c920c99185698afe6145f40d0744d0b4b7e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 11:41:40 +0200 Subject: [PATCH 7/7] docs(repo): cite operator runbooks by name only --- CLAUDE.md | 11 +++++------ apps/api/.env.example | 2 +- apps/api/scripts/monerium-b2b-sandbox-e2e.ts | 2 +- apps/api/scripts/seed-demo-account.ts | 2 +- .../src/api/services/demo/demo-account.constants.ts | 2 +- .../api/src/api/services/demo/demo-account.service.ts | 2 +- apps/api/src/api/services/monerium-b2b/monitoring.ts | 2 +- apps/api/src/config/vars.ts | 2 +- docs/README.md | 11 +++++------ docs/adr-0004-sandbox-demo-environment.md | 4 ++-- docs/adr-0005-monerium-b2b-onramp.md | 5 ++--- docs/adr-0007-monerium-b2b-partner-registration.md | 2 +- docs/architecture-identity-model.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 3 +-- docs/security-spec/05-integrations/brla.md | 2 +- docs/security-spec/05-integrations/monerium-b2b.md | 2 +- docs/security-spec/RISK-REGISTER.md | 4 ++-- 17 files changed, 28 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8530723c9..00de35e5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,12 +102,11 @@ update the existing canonical document when one owns the topic. `docs/adr-NNNN-.md`, with current behavior updated in the relevant maintained document. - Keep local `README.md` files only when they explain a non-obvious subsystem contract. -- Never create or extend a document whose audience includes a client or partner, or that - tracks commercial gates, terms or negotiation status: it belongs in the private - `pendulum-chain/vortex-private` repository (see "Public Repository: No Client Data"). -- Operator runbooks live in `operations/` of the private `pendulum-chain/vortex-private` - repository, not here. Cite them by file name and section; when a change alters a - documented procedure, update the runbook there in a follow-up pull request. +- Never add a document written for a specific client or partner, or one that tracks + commercial gates, terms or negotiation status (see "Public Repository: No Client Data"). +- Operator runbooks are maintained outside this repository. Cite them by name and section + (for example "runbook §2.7"); when a change alters a documented procedure, say so in the + pull request description. - Repair indexes and relative links in the same change as a move or deletion. - Agent skills live in `.agents/skills//SKILL.md` so every coding agent can use them; `.claude/skills/` holds only symlinks to those directories (Claude Code follows diff --git a/apps/api/.env.example b/apps/api/.env.example index 80196786c..b3b60cf14 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,7 +9,7 @@ BROWSER_SDK_ORIGINS= # Environment Configuration SANDBOX_ENABLED=false -# Sales-demo account (sandbox only) — see operations/operations-demo-environment.md in pendulum-chain/vortex-private +# Sales-demo account (sandbox only) — see the internal demo-environment runbook DEMO_ACCOUNT_EMAIL=demo@satoshipay.io # Serves Alfredpay KYB from canned in-process responses so the demo corridor can be # re-onboarded endlessly. Refuses to start unless DEPLOYMENT_ENV=sandbox. diff --git a/apps/api/scripts/monerium-b2b-sandbox-e2e.ts b/apps/api/scripts/monerium-b2b-sandbox-e2e.ts index a394fb698..7ea8e0434 100644 --- a/apps/api/scripts/monerium-b2b-sandbox-e2e.ts +++ b/apps/api/scripts/monerium-b2b-sandbox-e2e.ts @@ -1,6 +1,6 @@ /** * End-to-end run of the Monerium B2B onramp against a live backend, the sandbox or a local - * rehearsal (B2B operations runbook §8, pendulum-chain/vortex-private). It registers a client's destination + * rehearsal (B2B operations runbook §8). It registers a client's destination * the way the partner does, waits until the keeper has deployed, linked and activated the * account, then waits for one EUR payment and checks on chain that it was converted and * forwarded, or refunded with --refund. diff --git a/apps/api/scripts/seed-demo-account.ts b/apps/api/scripts/seed-demo-account.ts index 37585186b..251521796 100644 --- a/apps/api/scripts/seed-demo-account.ts +++ b/apps/api/scripts/seed-demo-account.ts @@ -5,7 +5,7 @@ * * Sandbox only — the restore routine itself refuses to run anywhere else. The demo profile * must have signed in once via OTP first; the seed cannot forge a Supabase Auth user. - * See operations/operations-demo-environment.md in pendulum-chain/vortex-private. + * See the internal demo-environment runbook. */ import path from "node:path"; import dotenv from "dotenv"; diff --git a/apps/api/src/api/services/demo/demo-account.constants.ts b/apps/api/src/api/services/demo/demo-account.constants.ts index 0eae60562..4630e72bb 100644 --- a/apps/api/src/api/services/demo/demo-account.constants.ts +++ b/apps/api/src/api/services/demo/demo-account.constants.ts @@ -3,7 +3,7 @@ * carries one of these ids, so restore is idempotent and can never touch a real ramp, * recipient, or provider record created during a demo. * - * See operations/operations-demo-environment.md in pendulum-chain/vortex-private. + * See the internal demo-environment runbook. */ /** All demo-owned primary keys share this prefix. */ diff --git a/apps/api/src/api/services/demo/demo-account.service.ts b/apps/api/src/api/services/demo/demo-account.service.ts index 9649b34f0..ecad98ee2 100644 --- a/apps/api/src/api/services/demo/demo-account.service.ts +++ b/apps/api/src/api/services/demo/demo-account.service.ts @@ -49,7 +49,7 @@ export interface DemoRestoreSummary { * rows. Every write is keyed by a fixed demo id, so running it twice is the same as * running it once and it can never overwrite a real ramp created during a demo. * - * Sandbox only. See operations/operations-demo-environment.md in pendulum-chain/vortex-private. + * Sandbox only. See the internal demo-environment runbook. */ export async function restoreDemoAccount(): Promise { if (config.deploymentEnv !== "sandbox") { diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index bc553b27b..99ee289a1 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -415,7 +415,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 - * (B2B operations runbook, pendulum-chain/vortex-private). + * (B2B operations runbook). */ 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 e22b00fa5..2432b768e 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -166,7 +166,7 @@ function readRecipientInviteDiscountLimit(): number { interface Config { env: string; deploymentEnv: DeploymentEnv; - /** Login email of the seeded sales-demo account. Sandbox only; see operations/operations-demo-environment.md in pendulum-chain/vortex-private. */ + /** Login email of the seeded sales-demo account. Sandbox only; see the internal demo-environment runbook. */ demoAccountEmail: string; /** * Replaces the Alfredpay client with a canned in-process stand-in so the demo corridor can be diff --git a/docs/README.md b/docs/README.md index e5c149b3f..d8bee9c8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -83,14 +83,13 @@ Do not add: - completed implementation plans or refactor summaries; - a second architecture document for behavior already owned by `security-spec/`; - an in-repository archive of stale docs. Git history is the archive; -- operator runbooks (incident, rollout, environment and migration procedures). They live - in `operations/` of the private `pendulum-chain/vortex-private` repository; cite them - by file name and section (for example "runbook §2.7"); +- operator runbooks (incident, rollout, environment and migration procedures). They are + maintained outside this repository; cite them by name and section (for example + "runbook §2.7"); - anything that names clients, partners or prospects, or records agreement or negotiation status, terms agreed with a specific party, onboarding status or contacts. This - repository is public: documents written for a partner and rollout trackers with - commercial gates live in the private `pendulum-chain/vortex-private` repository. Docs - here describe mechanisms with neutral roles ("the partner") and present parameters as + repository is public: documents written for a specific partner and rollout trackers + with commercial gates do not belong here. Docs here describe mechanisms with neutral roles ("the partner") and present parameters as configurable defaults; they may cite a registry ID, never the deal behind it. ### Proposals and decisions diff --git a/docs/adr-0004-sandbox-demo-environment.md b/docs/adr-0004-sandbox-demo-environment.md index c2b813272..074ce1d46 100644 --- a/docs/adr-0004-sandbox-demo-environment.md +++ b/docs/adr-0004-sandbox-demo-environment.md @@ -1,7 +1,7 @@ # ADR 0004: Sandbox Sales-Demo Account -Status: accepted 2026-08-07. Implemented in `apps/api/src/api/services/demo/`; the runbook -is [`operations-demo-environment.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-demo-environment.md) (private repository). +Status: accepted 2026-08-07. Implemented in `apps/api/src/api/services/demo/`; operating it +follows the internal demo-environment runbook. ## Context diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 94e2714ef..bb92e518e 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -11,9 +11,8 @@ 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 the deploy checklist in -[`operations-monerium-b2b-rollout.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/partner-docs/monerium-b2b/operations-monerium-b2b-rollout.md) (private repository); procedures in -[`operations-monerium-b2b-runbook.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-monerium-b2b-runbook.md) (private repository). The +launch gates, the deploy checklist and operator procedures in the internal operations +runbooks. The consumer-flow design this grew out of remains a phase-2 proposal: [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md). diff --git a/docs/adr-0007-monerium-b2b-partner-registration.md b/docs/adr-0007-monerium-b2b-partner-registration.md index 169c4ea2c..9b41059fe 100644 --- a/docs/adr-0007-monerium-b2b-partner-registration.md +++ b/docs/adr-0007-monerium-b2b-partner-registration.md @@ -6,7 +6,7 @@ invariants in [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md) (0, 12, 15 and the key separation), procedures in -[`operations-monerium-b2b-runbook.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-monerium-b2b-runbook.md) (private repository) §1, §6 and §8. +the internal B2B operations runbook, §1, §6 and §8. ## Context diff --git a/docs/architecture-identity-model.md b/docs/architecture-identity-model.md index ee767a801..2b0d06217 100644 --- a/docs/architecture-identity-model.md +++ b/docs/architecture-identity-model.md @@ -204,7 +204,7 @@ quote cannot be claimed by another user. - Provider ownership resolution: `apps/api/src/api/services/avenia-account.ts` and provider controllers/services - Schema history: `apps/api/src/database/migrations/038-*` onward - Managed-profile schema: `apps/api/src/database/migrations/063-create-managed-profiles.ts` -- Migrations 060-061 production gates: [`operations-legacy-schema-cleanup.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-legacy-schema-cleanup.md) (private repository) +- Migrations 060-061 production gates: the internal legacy-schema-cleanup runbook - Security details: `docs/security-spec/01-auth/`, `03-ramp-engine/recipient-transfers.md`, and the provider specs under `05-integrations/` Update this document only when the cross-module shape changes. Provider-specific flows, diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 15a8fdb5f..b3d080083 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -5,8 +5,7 @@ clients. Normative security detail lives in [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.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`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/partner-docs/monerium-b2b/operations-monerium-b2b-rollout.md) (private repository); -operator procedures in [`operations-monerium-b2b-runbook.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-monerium-b2b-runbook.md) (private repository). +launch gates and operator procedures in the internal operations runbooks. ## The shape in one paragraph diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 62db40a01..e78c620d6 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -196,7 +196,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 45. **The Avenia webhook MUST remain notification-only for imported KYC** — A signed event may enqueue an idempotent notification but MUST NOT approve or otherwise mutate verification state. Exact-attempt polling remains the authoritative persistence path. 46. **Consent evidence MUST remain explicit and provisional** — The request requires literal `consentAttested: true`; Vortex appends an entry containing actor, subject, timestamp, and server-controlled policy `sumsub-share-v1` to `verification_submission.consentAttestations` without the raw token. Replacing a provider-`401` failed claim with a new idempotency key MUST preserve prior entries and append the new attestation. This policy is enabled provisionally and MUST NOT be represented as replacing legal basis, applicant disclosure, biometric/special-category consent, or data-transfer obligations while legal/provider confirmations remain unresolved. 47. **Standard individual KYC submission MUST be durable, request-bound, authorized at claim time, and privacy-safe** — Vortex MUST persist the submission JSON on the locked canonical case before the Avenia Level 1 POST and atomically persist its complete attempt baseline and case `submitted_at` when claiming it as `submitted`. Deterministic provider client rejections create no attempt, so they record `failed` with a fixed `400` and may be corrected and retried, mirroring UBO creation. Timeouts, rate limits, conflicts, 5xx, and local-confirmation outcomes become `ambiguous`; reconciliation uses that timestamp, excludes baseline and attempts bound to other cases, fails closed when it is absent, and never sends a second POST for an active claim. Avenia's own status and detail MUST NOT be forwarded to the caller. Prepared, submitted, ambiguous, and confirmed reuse MUST match actor, subject, and the SHA-256 digest of the canonical flat payload before reconciliation or replay; mismatches return a fixed `409` without a provider POST. Unmanaged calls MUST have `actor === subject` with no managed selectors. Managed calls MUST identify the controlling manager separately, allow only `actor === controlling manager` or `actor === subject`, and lock and revalidate the controlling active manager, exact active manager/subject/relationship ID, `BR` permission, null-means-all individual policy, managed subject, and expected active individual entity during preparation and immediately before `prepared -> submitted`. Second-check revocation or a concurrent canonical approval MUST prevent the provider POST. Confirmation MUST bind its exact attempt ID even when approval won concurrently, without downgrading terminal state or timestamps. A provider-confirmed retryable terminal may replace the prior confirmed JSON with a prepared claim carrying the new request fingerprint; confirmation clears prior case approval/rejection timestamps and failure reasons before returning it to pending. A standard provider state of `COMPLETED` without `APPROVED` or `REJECTED` MUST fail closed before either direct-status or onboarding persistence. The standard identity payload MUST use the Avenia client's sensitive-body mode, and raw payloads, digest inputs, request logs, provider error details, and thrown errors MUST NOT contain identity values echoed by Avenia. -48. **A tax-ID claim MUST be bound to the identity later verified on that subaccount** — The unique tax hash is exclusive from the first `createSubaccount`, so the identity the claimant submits through the API MUST be that tax ID. `newKyc` MUST return `400` before any provider call or KYC claim when the normalized `taxIdNumber` (non-string values included) does not hash, via `hashTaxReference`, to the resolved record's `tax_reference_hash`. `submitKybLevel1Api` MUST apply the same rule to `taxIdentificationNumberTin` before any provider read or write. Formatted and plain forms of one tax ID are equal. The claim is released only by an operator (`operations/operations-brl-tax-id-claim-release.md` in the private repository); no request path releases another profile's unapproved claim. This binds only the API-submitted identity: Avenia subaccount creation sends only `accountType` and `name`, so the hosted KYB flow (`initiateKybLevel1`) and provider approval are not compared with the claimed tax ID, and `assertAveniaImportedTaxIdentity` (Sumsub token import) passes when the provider returns no tax ID (RISK-026). +48. **A tax-ID claim MUST be bound to the identity later verified on that subaccount** — The unique tax hash is exclusive from the first `createSubaccount`, so the identity the claimant submits through the API MUST be that tax ID. `newKyc` MUST return `400` before any provider call or KYC claim when the normalized `taxIdNumber` (non-string values included) does not hash, via `hashTaxReference`, to the resolved record's `tax_reference_hash`. `submitKybLevel1Api` MUST apply the same rule to `taxIdentificationNumberTin` before any provider read or write. Formatted and plain forms of one tax ID are equal. The claim is released only by an operator (the internal tax-ID release runbook); no request path releases another profile's unapproved claim. This binds only the API-submitted identity: Avenia subaccount creation sends only `accountType` and `name`, so the hosted KYB flow (`initiateKybLevel1`) and provider approval are not compared with the claimed tax ID, and `assertAveniaImportedTaxIdentity` (Sumsub token import) passes when the provider returns no tax ID (RISK-026). ## Threat Vectors & Mitigations diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 1e07fc9bd..377e03340 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -48,7 +48,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 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 the B2B operations runbook in the private repository). +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 the B2B operations runbook). 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). 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. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index cfa26dbca..74ab0ffa0 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -37,7 +37,7 @@ register and the owning module specification. | RISK-014 | Accepted | Medium | Pricing + Treasury | CoinGecko’s `usd-coin` price is used as a USD/fiat fallback or sanity reference, so a USDC depeg can distort the reference. | FastForex/Binance primary routes, sanity bands, short cache TTL, fail-closed when no valid provider remains, operational depeg monitoring. | Replace with an independent fiat FX reference before raising depeg-sensitive exposure. | | RISK-015 | Accepted | Low | EVM Operations | Base cleanup sweeps supported ERC-20 residuals after completion but does not sweep small native gas dust. | Just-in-time gas funding and token sweeps limit residual value. | Revisit if observed native residuals become material. | | RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base and Polygon ramps are not swept by their post-process handlers, and no AssetHub cleanup handler exists. The Monerium Polygon cleanup intent covers residual USDC, not EURe. | Cleanup worker selects terminal ramps; completed Base token cleanup and completed Polygon USDC cleanup work; AssetHub corridors remain quote-disabled; client-held ephemeral keys are retained for recovery. The recovery worker starts funded `initial` SELL ramps instead of leaving them stranded, but one that later fails falls under this risk like any failed ramp. | Add safe failed/timed-out handling and EURe coverage on Polygon, widen safe Base cleanup, and implement AssetHub cleanup before relying on automatic failure refunds. | -| RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-legacy-schema-cleanup.md) (private repository), verify production cleanup, and confirm the post-deploy observation window is clean. | +| RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in the internal legacy-schema-cleanup runbook, verify production cleanup, and confirm the post-deploy observation window is clean. | | RISK-018 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer with broad read and mutation rights. Ramp registration, update, and start; provider onboarding and KYC/KYB mutations; managed-child creation/deletion; manager/child credential creation/revocation; and partner payout-destination registration are denied. Quote generation, recipient, active-entity, notification, and other customer-account operations remain available. Alfredpay fiat-account creation and deletion are explicitly accepted even though these provider-side payout-account mutations outlive the session. | Per-operator Supabase identity plus per-request `vortex_admin` re-check; role removal atomically revokes live sessions; 30-minute non-renewable TTL; transaction-serialized and database-unique active session per (actor, target); hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks ramp money movement, KYC/KYB mutations, managed-child lifecycle, manager/child credential lifecycle, partner payout-destination registration, and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before changing the allowed mutation scope; managed sub-account composition has been reviewed with lifecycle and credential mutations denied. See `01-auth/admin-impersonation.md`. | | RISK-019 | Accepted | High | Product + Compliance | Managed-profile contact email uniqueness is manager-scoped, while Alfredpay uses email as provider identity. Different managers can submit the same normalized email; on an Alfredpay `409`, Vortex may adopt the provider customer returned for that email when country and customer type match, without independent proof that the second manager controls that provider identity. | Manager/child authorization remains isolated; contact email is immutable and unique within one manager; conflict recovery rejects country/type mismatch; the provider customer ID remains globally unique locally. Partners must supply an email identity they are authorized to use, and operations must investigate cross-manager collision errors rather than bypass uniqueness. | Before onboarding managers whose customer-email namespaces may overlap, enforce global or provider-scoped ownership of contact email, or replace email-based adoption with a provider ownership/claim proof and migrate existing relationships. | | RISK-020 | Deferred | High | Cross-chain + Operations | Moonbeam is unavailable. Historical ramps, residual ephemeral funds, and legacy rebalancer state may remain stranded. A successful `moonbeamCleanup` now records retirement acknowledgement rather than an on-chain sweep. | Moonbeam-dependent registration/update/start, phase execution, automatic recovery, status polling, and legacy rebalancing are disabled without deleting persisted flow identities or recovery data. | Reconcile every affected ramp/account and complete a reviewed manual rescue before restoring any Moonbeam runtime path or automatic recovery. | @@ -46,7 +46,7 @@ register and the owning module specification. | RISK-023 | Accepted | High | Payments Platform + Operations | The active Monerium EUR onramp attributes settlement from the linked owner's EURe balance increasing by the quoted post-fee amount. It does not correlate a provider issue order or mint transaction to the ramp, so an unrelated, duplicate, late, concurrent, or replacement-ramp credit can satisfy the delta. | Registration snapshots the owner balance after resolving one approved profile/Polygon EOA/IBAN match and rejects a second live ramp for the same owner (which would share the permit nonce and race for one credit); IBAN moves are refused while a ramp waits on the current wallet; execution transfers only the exact quoted amount; excess stays with the owner; caller-controlled provider identity is rejected. | Implement deterministic provider-order or mint-transaction correlation before increasing Monerium volume, operating concurrent/replacement ramps for one owner, or claiming payment-level attribution. | | RISK-024 | Accepted | High | Payments Platform + Product | The Monerium owner permit expires one week after preparation (the swap presign deadline) and can become stale if its nonce is consumed before SEPA settlement. There is no automatic reauthorization, refund, or recovery path, so late settlement can leave EURe in the owner's wallet and require manual resolution. | Payment instructions are withheld until the exact permit and downstream presigns validate; execution rechecks nonce, deadline, allowance, and balances and fails closed rather than broadening authorization. | Add a safe re-sign/recovery/refund flow and define the accepted SEPA settlement window before unattended operation at material volume. | | RISK-025 | Accepted | Medium | Payments Platform + Product | Monerium profiles onboarded through the OAuth application are invisible to the white-label application, so their EUR readiness and ramp registration depend on an access/refresh token pair that exists only in backend memory. A backend restart, token revocation, or refresh failure makes such a user unregisterable until they reconnect Monerium. | Reads and registration fail closed with `MONERIUM_REAUTHENTICATION_REQUIRED`; the dashboard and widget prompt a reconnect; no token is persisted, so nothing at rest can be stolen. | Add an encrypted refresh-token store or migrate OAuth profiles into the white-label application before relying on unattended re-registration or observing reconnect prompts at material volume. | -| RISK-026 | Accepted | Medium | Payments Platform + Product + Operations | Brazilian tax-ID squatting and enumeration. `createSubaccount` reserves a CPF/CNPJ exclusively for the first authenticated caller (unique `ux_provider_customers_tax_hash`) before the provider has verified who the caller is, and Supabase sign-up is open OTP. Any signed-in session can therefore claim a valid, unclaimed tax ID; the real owner then receives `409` and cannot recover without an operator. The tax-keyed lookup routes also answer differently for unknown and other-owned tax IDs, so they can be used to test whether a CPF is registered. The product owner accepted a lean mitigation instead of making the reservation exclusive only on provider approval. | Checksum, account-type and `name` validation before any provider call or write; `newKyc` / KYB submission bound to the claimed tax ID (the hosted KYB flow and provider approval are not bound); the global 100 req/min per IP limiter, which is the only bound on tax-ID existence probing; unchanged ownership checks and status codes; operator release runbook ([`operations-brl-tax-id-claim-release.md`](https://github.com/pendulum-chain/vortex-private/blob/HEAD/operations/operations-brl-tax-id-claim-release.md) (private repository)). | Implement exclusivity only on provider approval (unapproved claims contestable or expiring) before BRL onboarding volume grows materially, when a second squat needs an operator release, or when tax-ID enumeration is observed (then answer other-owned and unregistered tax IDs identically, or cap probes per principal in the database). | +| RISK-026 | Accepted | Medium | Payments Platform + Product + Operations | Brazilian tax-ID squatting and enumeration. `createSubaccount` reserves a CPF/CNPJ exclusively for the first authenticated caller (unique `ux_provider_customers_tax_hash`) before the provider has verified who the caller is, and Supabase sign-up is open OTP. Any signed-in session can therefore claim a valid, unclaimed tax ID; the real owner then receives `409` and cannot recover without an operator. The tax-keyed lookup routes also answer differently for unknown and other-owned tax IDs, so they can be used to test whether a CPF is registered. The product owner accepted a lean mitigation instead of making the reservation exclusive only on provider approval. | Checksum, account-type and `name` validation before any provider call or write; `newKyc` / KYB submission bound to the claimed tax ID (the hosted KYB flow and provider approval are not bound); the global 100 req/min per IP limiter, which is the only bound on tax-ID existence probing; unchanged ownership checks and status codes; operator release runbook. | Implement exclusivity only on provider approval (unapproved claims contestable or expiring) before BRL onboarding volume grows materially, when a second squat needs an operator release, or when tax-ID enumeration is observed (then answer other-owned and unregistered tax IDs identically, or cap probes per principal in the database). | ## Review cadence