From 4394a91bd1ff71106e00ad80e958e84080d5af60 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 15:53:32 +0200 Subject: [PATCH 01/34] docs(repo): keep client names and data out of the public repo Agents copy whatever names the task context uses into docs, commit messages and PR text, and on a public repository those stay readable in history after any later edit. --- CLAUDE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ca60f3107..677681fa6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,27 @@ Commit examples from history: `fix(api): keep active phase retries below lock ex `feat(dashboard): add searchable token selection`, `docs(dashboard): sync implemented feature specs`. +## Public Repository: No Client Data + +This repository is public. Files, commit messages, branch names, PR titles and +descriptions, and review comments are world-readable, and git and GitHub keep every +earlier version even after an edit. + +- Never name customers, business clients, partners, prospects, or their end users. Write + "the partner", "a business client", or another neutral role, even when the user or the + task context uses the real name. Providers the code already integrates (for example + Monerium or Avenia) are not clients and may be named. +- Keep commercial details out as well: agreement and negotiation status, prices, fees or + discounts agreed with a specific party, onboarding status, contacts, and anything quoted + from a client conversation. +- Never commit production personal data (names, emails, tax IDs, IBANs, wallet addresses + tied to a person). Fixtures use obviously fake values such as `@example.com`, and must + not hint at a real client in disguise. +- Before every commit, push, `gh pr create`, `gh pr edit`, or review comment, check the + diff, message, branch name, and PR title and body for any of the above. +- If something already leaked, stop and tell the user. Never write a commit message, PR + title, or comment that explains what was removed or why. + ## Lean, Safe Fixes For fixes, prefer the smallest change that fully resolves the demonstrated root cause From cd46a2910264f1b4d7d725e459cbb0a8309daf0d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 16:05:04 +0200 Subject: [PATCH 02/34] docs(repo): have reviewers flag client data in public changes The agent rule only covers what agents write; Copilot and vortex-review also need to catch names that reach a PR from people or other tools, without repeating them in their review comments. --- .agents/skills/vortex-review/SKILL.md | 5 +++++ .github/copilot-instructions.md | 1 + 2 files changed, 6 insertions(+) diff --git a/.agents/skills/vortex-review/SKILL.md b/.agents/skills/vortex-review/SKILL.md index 2bfc5ab58..5db84a3a0 100644 --- a/.agents/skills/vortex-review/SKILL.md +++ b/.agents/skills/vortex-review/SKILL.md @@ -26,6 +26,11 @@ the implementing session or via `/address-feedback`. A stale snapshot on the branch = automatic P1 finding. - Read the PR description and any linked proposal/ADR: findings include "the change doesn't achieve its stated intent", not just "the code is wrong". +- Public-repo check (CLAUDE.md "Public Repository: No Client Data"): scan the diff, every + commit message in the range, the branch name, and the PR title and body for client, + partner or prospect names, commercial terms tied to a named party, and production + personal data. Any hit is an automatic P1 finding. Cite its location only: never repeat + the name or term in the report or in anything posted to GitHub. ## 2. Round 1 — finder fleet diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 22a85c0cc..45d580e4f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,6 +11,7 @@ ## Repository invariants +- This repository is public. Flag customer, business-client, partner or prospect names, commercial terms tied to a named party, and production personal data anywhere in the change, including fixtures, the PR title and the PR description. Point to the location without repeating the name or term in your comment. - Treat `docs/security-spec/` as normative for security-sensitive behavior. Flag code/spec contradictions and behavior changes that leave the relevant specification stale. - Treat public OpenAPI schemas, `packages/sdk` exports, and shared wire types as compatibility contracts. Check consumers and documentation when their shapes, semantics, defaults, or errors change. - Changes to `packages/shared` can affect every application. Check downstream assumptions, and require the shared build before consumer verification. From d7b14b4fc059800edbe804e7a7b9992d9d657819 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 17:09:00 +0200 Subject: [PATCH 03/34] docs(repo): keep partner-facing and commercial docs in the private repo The documentation rules said where docs go but not what must stay out of a public repository, so partner-facing flow docs and rollout trackers with agreement status landed here. --- CLAUDE.md | 3 +++ docs/README.md | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 677681fa6..4548585a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,9 @@ 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"). - 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/docs/README.md b/docs/README.md index 1928eee29..84f9b1aae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,7 +87,13 @@ Do not add: - agent memory banks, active-context logs, progress journals, or handoff notes; - 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. +- an in-repository archive of stale docs. Git history is the archive; +- 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 + configurable defaults; they may cite a registry ID, never the deal behind it. ### Proposals and decisions From 687e0cad1501a2eb8ec6943d2aedc526e485f660 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 17:11:50 +0200 Subject: [PATCH 04/34] docs(frontend): generalize the design-context user segments --- apps/frontend/.impeccable.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/.impeccable.md b/apps/frontend/.impeccable.md index f4f7c146c..a68f396fe 100644 --- a/apps/frontend/.impeccable.md +++ b/apps/frontend/.impeccable.md @@ -4,9 +4,9 @@ **Primary:** Crypto-native DeFi users who need to move money between fiat and crypto efficiently. They understand wallets, slippage, liquidity — but still expect fast, frictionless UX. -**Integration partners:** Protocols like Pendle that embed Vortex as a ramping layer. Users arrive mid-flow from another product and need zero onboarding friction. +**Integration partners:** DeFi protocols that embed Vortex as a ramping layer. Users arrive mid-flow from another product and need zero onboarding friction. -**High-volume P2P operators:** Businesses like IaCrypto (Brazil) processing millions in daily volume. They care about reliability, speed, and trust signals — not aesthetics. Every failure costs real money. +**High-volume P2P operators:** Businesses processing large daily volumes. They care about reliability, speed, and trust signals — not aesthetics. Every failure costs real money. **Implication:** The interface must feel immediately legible to someone who has used Aave or Uniswap. No hand-holding, no consumer-app softness. Assume competence. From da0bc3cc316dd9a6f841faea2f68d4c732d7749e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 17:11:51 +0200 Subject: [PATCH 05/34] test(repo): use standard example tax IDs and bank accounts in fixtures Fixture identifiers now come from published examples or are generated, so they can never match a real person or account. --- .../api/controllers/brla.controller.test.ts | 86 +++++++++---------- .../src/api/middlewares/validators.test.ts | 2 +- .../src/api/services/avenia-account.test.ts | 4 +- .../workers/unhandled-payment.worker.test.ts | 2 +- ...dpay-kyb-legacy-entity.integration.test.ts | 2 +- .../contracts/alfredpay.contract.test.ts | 6 +- ...tifications-onboarding.integration.test.ts | 6 +- apps/gold/tests/pilot-safety.test.mjs | 10 +-- apps/gold/tests/vortex.test.mjs | 6 +- .../shared/src/services/brla/helpers.test.ts | 2 +- 10 files changed, 63 insertions(+), 63 deletions(-) diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 26d60a8c9..fe6c2ff96 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -149,7 +149,7 @@ describe("getAveniaUser", () => { await getAveniaUser( { authenticatedPartner: { id: "partner-1", name: "Partner" }, - query: { taxId: "08786985906" } + query: { taxId: "04821536951" } } as any, res as any ); @@ -173,7 +173,7 @@ describe("getAveniaUser", () => { profileId: "user-1", strength: "secret" }, - query: { taxId: "08786985906" } + query: { taxId: "04821536951" } } as any, res as any ); @@ -188,7 +188,7 @@ describe("getAveniaUser", () => { const res = createResponse(); await getAveniaUser( { - query: { taxId: "08786985906" }, + query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any @@ -204,7 +204,7 @@ describe("getAveniaUser", () => { const res = createResponse(); await getAveniaUser( { - query: { taxId: "08786985906" }, + query: { taxId: "04821536951" }, userId: "attacker-user" } as any, res as any @@ -239,7 +239,7 @@ describe("getAveniaUser", () => { ); const res = createResponse(); - await getAveniaUser({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await getAveniaUser({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); expect(res.body).toEqual(expectedConfirmedBody); @@ -270,7 +270,7 @@ describe("getAveniaUser", () => { const res = createResponse(); await getAveniaUser( { - query: { taxId: "08786985906" }, + query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any @@ -307,7 +307,7 @@ describe("recordInitialKycAttempt", () => { const res = createResponse(); await recordInitialKycAttempt( - { body: { quoteId: "quote-1", taxId: "08786985906" }, userId: "user-1" } as any, + { body: { quoteId: "quote-1", taxId: "04821536951" }, userId: "user-1" } as any, res as any ); @@ -319,7 +319,7 @@ describe("recordInitialKycAttempt", () => { it("requires a quote id before recording an Avenia interaction", async () => { const res = createResponse(); - await recordInitialKycAttempt({ body: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await recordInitialKycAttempt({ body: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); expect(res.body).toEqual({ error: "Missing quoteId or taxId body parameter" }); @@ -338,7 +338,7 @@ describe("recordInitialKycAttempt", () => { const res = createResponse(); await recordInitialKycAttempt( - { body: { quoteId: "quote-1", taxId: "08786985906" }, userId: "attacker" } as any, + { body: { quoteId: "quote-1", taxId: "04821536951" }, userId: "attacker" } as any, res as any ); @@ -358,7 +358,7 @@ describe("recordInitialKycAttempt", () => { const res = createResponse(); await recordInitialKycAttempt( - { body: { quoteId: "quote-1", taxId: "08786985906" }, userId: "user-1" } as any, + { body: { quoteId: "quote-1", taxId: "04821536951" }, userId: "user-1" } as any, res as any ); @@ -610,7 +610,7 @@ describe("fetchSubaccountKycStatus", () => { ); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.NOT_FOUND); expect(update).toHaveBeenCalledWith({ status: VerificationStatus.Pending, statusExternal: null }, expect.anything()); @@ -650,7 +650,7 @@ describe("fetchSubaccountKycStatus", () => { ); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); expect(res.body).toEqual({ details: "database unavailable", error: "Server error" }); @@ -692,7 +692,7 @@ describe("fetchSubaccountKycStatus", () => { ); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.CONFLICT); expect(res.body).toEqual({ error: "The KYC submission requires reconciliation" }); @@ -747,7 +747,7 @@ describe("fetchSubaccountKycStatus", () => { }); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); expect((res.body as { result: string }).result).toBe(KycAttemptResult.APPROVED); @@ -766,7 +766,7 @@ describe("fetchSubaccountKycStatus", () => { }); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.BAD_GATEWAY); expect(res.body).toEqual({ error: "The provider returned an inconsistent KYC attempt" }); @@ -781,7 +781,7 @@ describe("fetchSubaccountKycStatus", () => { }); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.BAD_GATEWAY); expect(res.body).toEqual({ error: "The provider returned an inconsistent KYC attempt" }); @@ -797,7 +797,7 @@ describe("fetchSubaccountKycStatus", () => { }); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); expect(update).toHaveBeenCalledWith( @@ -840,7 +840,7 @@ describe("fetchSubaccountKycStatus", () => { ); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.CONFLICT); expect(res.body).toEqual({ error: "The imported KYC attempt requires reconciliation" }); @@ -890,7 +890,7 @@ describe("fetchSubaccountKycStatus", () => { ); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); expect(strayCreate).not.toHaveBeenCalled(); @@ -904,7 +904,7 @@ describe("fetchSubaccountKycStatus", () => { }); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); expect(update).toHaveBeenCalledWith( @@ -957,7 +957,7 @@ describe("fetchSubaccountKycStatus", () => { ); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); expect(getExactAttempt).toHaveBeenCalledWith("attempt-imported", "subaccount-1"); @@ -978,7 +978,7 @@ describe("fetchSubaccountKycStatus", () => { providerSubaccountId: "subaccount-1", status: VerificationStatus.InReview, statusExternal: KycAttemptStatus.PROCESSING, - taxReferenceHash: hashTaxReference("08786985906"), + taxReferenceHash: hashTaxReference("04821536951"), update: customerUpdate }; ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; @@ -1017,7 +1017,7 @@ describe("fetchSubaccountKycStatus", () => { const { caseUpdate, customerUpdate } = mockImportedApproval("111.444.777-35"); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); expect(customerUpdate).not.toHaveBeenCalled(); @@ -1025,10 +1025,10 @@ describe("fetchSubaccountKycStatus", () => { }); it("approves imported KYC when Avenia returns the canonical CPF with formatting", async () => { - const { caseUpdate, customerUpdate } = mockImportedApproval("087.869.859-06"); + const { caseUpdate, customerUpdate } = mockImportedApproval("048.215.369-51"); const res = createResponse(); - await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + await fetchSubaccountKycStatus({ query: { taxId: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); expect(customerUpdate).toHaveBeenCalledWith( @@ -1612,7 +1612,7 @@ describe("createSubaccount", () => { const validBody = { accountType: AveniaAccountType.INDIVIDUAL, name: "Attacker", - taxId: "08786985906" + taxId: "04821536951" }; it("rejects a managed child's mismatched account type before provider access", async () => { @@ -1831,7 +1831,7 @@ describe("createSubaccount", () => { customerEntityId: "entity-new-user", provider: "avenia", providerSubaccountId: "new-subaccount", - taxReference: "08786985906" + taxReference: "04821536951" }), expect.anything() ); @@ -1884,12 +1884,12 @@ describe("createSubaccount", () => { { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "12345678901" }, { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "abc" }, { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter" }, - { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: 8786985906 }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: 4821536951 }, { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "11222333000181" }, - { accountType: AveniaAccountType.COMPANY, name: "Squatter Ltda", taxId: "08786985906" }, - { accountType: AveniaAccountType.INDIVIDUAL, name: " ", taxId: "08786985906" }, - { accountType: AveniaAccountType.INDIVIDUAL, taxId: "08786985906" }, - { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "08786985907" } + { accountType: AveniaAccountType.COMPANY, name: "Squatter Ltda", taxId: "04821536951" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: " ", taxId: "04821536951" }, + { accountType: AveniaAccountType.INDIVIDUAL, taxId: "04821536951" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "04821536952" } ]; for (const body of malformed) { const res = await submitThroughRoute(body); @@ -1908,13 +1908,13 @@ describe("createSubaccount", () => { ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; ProviderCustomer.create = providerCreateMock as unknown as typeof ProviderCustomer.create; - const res = await submitThroughRoute({ ...validBody, taxId: "087.869.859-06" }, "new-user"); + const res = await submitThroughRoute({ ...validBody, taxId: "048.215.369-51" }, "new-user"); expect(res.statusCode).toBe(httpStatus.OK); expect(createAveniaSubaccountMock).toHaveBeenCalledTimes(1); expect(providerCreateMock.mock.calls[0]?.[0]).toMatchObject({ - taxReference: "08786985906", - taxReferenceHash: hashTaxReference("08786985906") + taxReference: "04821536951", + taxReferenceHash: hashTaxReference("04821536951") }); }); @@ -2124,7 +2124,7 @@ describe("getUploadUrls", () => { const res = createResponse(); await getUploadUrls( - { body: { documentType: BrDocumentType.ID, taxId: "08786985906" }, userId: "user-1" } as any, + { body: { documentType: BrDocumentType.ID, taxId: "04821536951" }, userId: "user-1" } as any, res as any ); @@ -2184,7 +2184,7 @@ describe("newKyc", () => { id: "customer-1", provider: "avenia", providerSubaccountId: "subaccount-1", - taxReferenceHash: hashTaxReference("08786985906") + taxReferenceHash: hashTaxReference("04821536951") })) as unknown as typeof ProviderCustomer.findOne; const getInstance = mock(() => ({}) as BrlaApiService); BrlaApiService.getInstance = getInstance; @@ -2199,7 +2199,7 @@ describe("newKyc", () => { it("rejects a taxIdNumber that differs from the claimed CPF before any provider call", async () => { const { getInstance, transaction } = mockOwnedIndividual(); - for (const taxIdNumber of ["52998224725", "", undefined, 8786985906]) { + for (const taxIdNumber of ["52998224725", "", undefined, 4821536951]) { const res = createResponse(); await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber }, userId: "user-1" } as any, res as any); @@ -2214,7 +2214,7 @@ describe("newKyc", () => { const { transaction } = mockOwnedIndividual(); const res = createResponse(); - await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "087.869.859-06" }, userId: "user-1" } as any, res as any); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "048.215.369-51" }, userId: "user-1" } as any, res as any); expect(transaction).toHaveBeenCalled(); expect(res.statusCode).not.toBe(httpStatus.BAD_REQUEST); @@ -2229,7 +2229,7 @@ describe("newKyc", () => { id: "customer-1", provider: "avenia", providerSubaccountId: "subaccount-1", - taxReferenceHash: hashTaxReference("08786985906") + taxReferenceHash: hashTaxReference("04821536951") })) as unknown as typeof ProviderCustomer.findOne; KycCase.findAll = mock(async () => [{ id: "case-1", verificationMethod: "sumsub_share_token" }]) as unknown as typeof KycCase.findAll; sequelize.transaction = mock(async callback => @@ -2247,7 +2247,7 @@ describe("newKyc", () => { ); const res = createResponse(); - await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "08786985906" }, userId: "user-1" } as any, res as any); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "04821536951" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.CONFLICT); expect(getUploadedDocuments).not.toHaveBeenCalled(); @@ -2264,7 +2264,7 @@ describe("newKyc", () => { provider: "avenia", providerSubaccountId: "subaccount-1", status: VerificationStatus.InReview, - taxReferenceHash: hashTaxReference("08786985906"), + taxReferenceHash: hashTaxReference("04821536951"), update: customerUpdate }; ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; @@ -2322,7 +2322,7 @@ describe("newKyc", () => { { body: { subAccountId: "subaccount-1", - taxIdNumber: "087.869.859-06", + taxIdNumber: "048.215.369-51", uploadedDocumentId: "document-1", uploadedSelfieId: "selfie-1" }, diff --git a/apps/api/src/api/middlewares/validators.test.ts b/apps/api/src/api/middlewares/validators.test.ts index 30ff2426c..c11a04ac9 100644 --- a/apps/api/src/api/middlewares/validators.test.ts +++ b/apps/api/src/api/middlewares/validators.test.ts @@ -173,7 +173,7 @@ describe("Avenia API KYB validators", () => { percentageOfOwnership: "100", state: "SP", streetLine1: "Av Paulista 1000", - taxIdNumber: "08786985906", + taxIdNumber: "04821536951", uploadedIdentificationId: "identity-1", zipCode: "01310-100" } diff --git a/apps/api/src/api/services/avenia-account.test.ts b/apps/api/src/api/services/avenia-account.test.ts index d736e8f59..5fa1ff300 100644 --- a/apps/api/src/api/services/avenia-account.test.ts +++ b/apps/api/src/api/services/avenia-account.test.ts @@ -26,7 +26,7 @@ function approvedAccount(entityId: string, subAccountId: string) { customerType: "individual", providerSubaccountId: subAccountId, status: VerificationStatus.Approved, - taxReference: "08786985906" + taxReference: "04821536951" }; } @@ -42,7 +42,7 @@ describe("resolveAveniaAccountForUser", () => { const resolved = await resolveAveniaAccountForUser("user-1"); expect(resolved.subAccountId).toBe("sub-1"); - expect(resolved.taxId).toBe("08786985906"); + expect(resolved.taxId).toBe("04821536951"); }); it("prefers the active entity's account when several are approved", async () => { diff --git a/apps/api/src/api/workers/unhandled-payment.worker.test.ts b/apps/api/src/api/workers/unhandled-payment.worker.test.ts index 99c3c09b5..ed0a9e325 100644 --- a/apps/api/src/api/workers/unhandled-payment.worker.test.ts +++ b/apps/api/src/api/workers/unhandled-payment.worker.test.ts @@ -19,7 +19,7 @@ function paidInitialState(unhandledPaymentAlertSent = false) { state: { aveniaTicketId: "ticket-1", subAccountId: "snapshotted-subaccount", - taxId: "08786985906", + taxId: "04821536951", unhandledPaymentAlertSent }, update: mock(async () => undefined) diff --git a/apps/api/src/tests/alfredpay-kyb-legacy-entity.integration.test.ts b/apps/api/src/tests/alfredpay-kyb-legacy-entity.integration.test.ts index be75e31f3..c5f1beb94 100644 --- a/apps/api/src/tests/alfredpay-kyb-legacy-entity.integration.test.ts +++ b/apps/api/src/tests/alfredpay-kyb-legacy-entity.integration.test.ts @@ -204,7 +204,7 @@ describe("Alfredpay KYB on a migrated (individual-entity) profile", () => { }); it("createAlfredpayCustomer homes a new corridor's business row with the existing legacy rows", async () => { - const { entity, user } = await seedLegacyBusinessCustomer("kyb-legacy-colocate@example.com"); + const { entity, user } = await seedLegacyBusinessCustomer("kyb-legacy-business@example.com"); // Ramp registration resolves the active entity — a new corridor's row must land next to // the legacy rows there, not on a fresh business entity it can never reach. diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index e6f3962ec..e4476e997 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -66,7 +66,7 @@ const RUN_KYC_FLOW = !!process.env.ALFREDPAY_CONTRACT_RUN_KYC_FLOW; const RUN_KYB_FLOW = !!process.env.ALFREDPAY_CONTRACT_RUN_KYB_FLOW; // Completed Argentina sandbox customer reserved for the create/list/delete account lifecycle. const AR_COMPLETED_CUSTOMER_ID = "cd0a7a0d-1b2d-4894-bbb2-f05fe3b5c7df"; -const AR_CONTRACT_ACCOUNT_NUMBER = "0720369388000033954918"; +const AR_CONTRACT_ACCOUNT_NUMBER = "2850590940090418135201"; const CO_COMPLETED_CUSTOMER_ID = "2be2683f-9594-4b8f-9578-69212b6240fd"; const MX_COMPLETED_CUSTOMER_ID = "230ee85f-5f2d-4cbf-af7c-afa46591d9ef"; @@ -100,13 +100,13 @@ const FIAT_ACCOUNT_LIFECYCLE_CASES: FiatAccountLifecycleCase[] = [ }, { accountFields: { - accountNumber: "012020477538404708", + accountNumber: "002010077777777771", accountType: "CLABE", metadata: { accountHolderName: "Vortex Contract Test" } }, country: "MX", customerId: MX_COMPLETED_CUSTOMER_ID, - expectedFields: { accountNumber: "012020477538404708", accountType: "CLABE" }, + expectedFields: { accountNumber: "002010077777777771", accountType: "CLABE" }, type: DomesticFiatAccountType.SPEI } ]; diff --git a/apps/api/src/tests/notifications-onboarding.integration.test.ts b/apps/api/src/tests/notifications-onboarding.integration.test.ts index f1c2bff1b..4f55a1a18 100644 --- a/apps/api/src/tests/notifications-onboarding.integration.test.ts +++ b/apps/api/src/tests/notifications-onboarding.integration.test.ts @@ -688,7 +688,7 @@ describe("GET /v1/onboarding/status", () => { it("does not approve imported onboarding status when Avenia exposes a different CPF", async () => { const { user, token } = await createAuthedUser("avenia-import-tax-mismatch@example.com"); - const customer = await createTestTaxId(user.id, { subAccountId: "sub-import-tax-mismatch", taxId: "08786985906" }); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-import-tax-mismatch", taxId: "04821536951" }); await customer.update({ status: VerificationStatus.InReview }); const kycCase = await KycCase.create({ customerEntityId: customer.customerEntityId, @@ -726,7 +726,7 @@ describe("GET /v1/onboarding/status", () => { it("approves imported onboarding status when Avenia returns a formatted matching CPF", async () => { const { user, token } = await createAuthedUser("avenia-import-tax-match@example.com"); - const customer = await createTestTaxId(user.id, { subAccountId: "sub-import-tax-match", taxId: "08786985906" }); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-import-tax-match", taxId: "04821536951" }); await customer.update({ status: VerificationStatus.InReview }); const kycCase = await KycCase.create({ customerEntityId: customer.customerEntityId, @@ -745,7 +745,7 @@ describe("GET /v1/onboarding/status", () => { getVerificationAttemptStatus: mock(async () => ({ attempt: { id: "attempt-imported", result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED } })), - subaccountInfo: mock(async () => ({ accountInfo: { taxId: "087.869.859-06" } })) + subaccountInfo: mock(async () => ({ accountInfo: { taxId: "048.215.369-51" } })) }) as unknown as BrlaApiService ); diff --git a/apps/gold/tests/pilot-safety.test.mjs b/apps/gold/tests/pilot-safety.test.mjs index c2f569257..034be7973 100644 --- a/apps/gold/tests/pilot-safety.test.mjs +++ b/apps/gold/tests/pilot-safety.test.mjs @@ -189,7 +189,7 @@ test('KYC polling rides out blips and reconciliation, and stops on every final s const jwt=exp=>'e30.'+Buffer.from(JSON.stringify({exp})).toString('base64url')+'.x'; const script=(replies)=>{let calls=0;globalThis.fetch=async()=>{const reply=replies[Math.min(calls++,replies.length-1)];if(reply instanceof Error) throw reply;return new Response(JSON.stringify(reply.body),{status:reply.status});};return ()=>calls;}; const ok=(body)=>({status:200,body}); - const poll=(options={})=>pollBrazilKyc('08786985906',{intervalMs:1,timeoutMs:2000,...options}); + const poll=(options={})=>pollBrazilKyc('04821536951',{intervalMs:1,timeoutMs:2000,...options}); const inPortuguese=(error)=>/verificação/.test(error.message)&&!/not found|reconciliation/i.test(error.message); try { setVortexSession({access_token:jwt(Math.floor(Date.now()/1000)+3600),refresh_token:'fake-test-only'}); @@ -221,7 +221,7 @@ test('a refused new KYC attempt points to support in Portuguese',async()=>{ try { setVortexSession({access_token:jwt(Math.floor(Date.now()/1000)+3600),refresh_token:'fake-test-only'}); globalThis.fetch=async()=>new Response(JSON.stringify({error:'The KYC submission does not match this request'}),{status:409,headers:{'X-Request-ID':'req-123'}}); - await assert.rejects(submitBrazilKyc({taxIdNumber:'08786985906'}),(error)=>error.code==='KYC_NEW_ATTEMPT_BLOCKED'&&/suporte/.test(error.message)&&/req-123/.test(error.message)&&!/does not match/.test(error.message)); + await assert.rejects(submitBrazilKyc({taxIdNumber:'04821536951'}),(error)=>error.code==='KYC_NEW_ATTEMPT_BLOCKED'&&/suporte/.test(error.message)&&/req-123/.test(error.message)&&!/does not match/.test(error.message)); globalThis.fetch=async()=>new Response(JSON.stringify({error:'Invalid documentType'}),{status:400}); await assert.rejects(submitBrazilKyc({}),(error)=>error.status===400&&error.code!=='KYC_NEW_ATTEMPT_BLOCKED'); } finally {clearVortexSession();globalThis.fetch=old;} @@ -247,13 +247,13 @@ test('KYC polling stops when the modal closes, even with a status answer in flig setVortexSession({access_token:jwt(Math.floor(Date.now()/1000)+3600),refresh_token:'fake-test-only'}); globalThis.fetch=async()=>{calls++;return reply({status:'PROCESSING'});}; const waiting=new AbortController();setTimeout(()=>waiting.abort(),20); - await assert.rejects(pollBrazilKyc('08786985906',{intervalMs:60_000,signal:waiting.signal}),{name:'AbortError'}); + await assert.rejects(pollBrazilKyc('04821536951',{intervalMs:60_000,signal:waiting.signal}),{name:'AbortError'}); assert.equal(calls,1); - await assert.rejects(pollBrazilKyc('08786985906',{signal:AbortSignal.abort()}),{name:'AbortError'}); + await assert.rejects(pollBrazilKyc('04821536951',{signal:AbortSignal.abort()}),{name:'AbortError'}); assert.equal(calls,1); globalThis.fetch=()=>new Promise((resolve)=>{release=()=>resolve(reply({status:'COMPLETED',result:'APPROVED'}));}); const closing=new AbortController(); - const polling=pollBrazilKyc('08786985906',{intervalMs:1,signal:closing.signal}); + const polling=pollBrazilKyc('04821536951',{intervalMs:1,signal:closing.signal}); await new Promise((resolve)=>setTimeout(resolve,10)); closing.abort();release(); await assert.rejects(polling,{name:'AbortError'}); diff --git a/apps/gold/tests/vortex.test.mjs b/apps/gold/tests/vortex.test.mjs index e343bbd4c..a511f4b6c 100644 --- a/apps/gold/tests/vortex.test.mjs +++ b/apps/gold/tests/vortex.test.mjs @@ -86,9 +86,9 @@ test("ramp polling surfaces persistent outages and final errors", async () => { }); test("accepts only CPFs with valid check digits", () => { - for (const cpf of ["08786985906", "087.869.859-06", "52998224725"]) assert.equal(isValidCpf(cpf), true, cpf); - // 08786985914 is wrong only in the first check digit, 08786985907 only in the second. - for (const cpf of ["08786985914", "08786985907", "12345678901", "11111111111", "01234567890", "0878698590", "087869859060", "", undefined]) assert.equal(isValidCpf(cpf), false, String(cpf)); + for (const cpf of ["04821536951", "048.215.369-51", "52998224725"]) assert.equal(isValidCpf(cpf), true, cpf); + // 04821536960 is wrong only in the first check digit, 04821536952 only in the second. + for (const cpf of ["04821536960", "04821536952", "12345678901", "11111111111", "01234567890", "0482153695", "048215369510", "", undefined]) assert.equal(isValidCpf(cpf), false, String(cpf)); }); test("ramp polling stops as soon as it is aborted", { timeout: 2000 }, async () => { diff --git a/packages/shared/src/services/brla/helpers.test.ts b/packages/shared/src/services/brla/helpers.test.ts index ee425df47..858c34578 100644 --- a/packages/shared/src/services/brla/helpers.test.ts +++ b/packages/shared/src/services/brla/helpers.test.ts @@ -5,7 +5,7 @@ describe("isValidCpf", () => { it("accepts a checksum-valid CPF in digits-only and formatted form", () => { expect(isValidCpf("52998224725")).toBe(true); expect(isValidCpf("529.982.247-25")).toBe(true); - expect(isValidCpf("08786985906")).toBe(true); + expect(isValidCpf("04821536951")).toBe(true); }); it("rejects CPFs with wrong check digits", () => { From 642e6814e7cc953904628f8b497136d05205763c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 17:11:51 +0200 Subject: [PATCH 06/34] fix(gold): use a reserved example domain for the demo email example.com is reserved for documentation, so the demo identity can never reach a real mailbox. --- apps/gold/src/App.jsx | 2 +- apps/gold/src/main.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/gold/src/App.jsx b/apps/gold/src/App.jsx index 468a48e89..94d35f7cf 100644 --- a/apps/gold/src/App.jsx +++ b/apps/gold/src/App.jsx @@ -32,7 +32,7 @@ import { verifyVortexOtp, } from "./lib/vortex.js"; -const DEMO_EMAIL = "ana.silva@gmail.com"; +const DEMO_EMAIL = "ana.silva@example.com"; const INITIAL_GRAMS = 12.634; const HIGH_SLIPPAGE = 1.5; const formatBRL = (value, digits = 2) => new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL", minimumFractionDigits: digits, maximumFractionDigits: digits }).format(Number(value || 0)); diff --git a/apps/gold/src/main.jsx b/apps/gold/src/main.jsx index 6d288379e..c7bc66ec7 100644 --- a/apps/gold/src/main.jsx +++ b/apps/gold/src/main.jsx @@ -31,7 +31,7 @@ function DemoShell() { const [authenticated, setAuthenticated] = useState(false); const auth = useMemo(() => ({ authenticated, - user: authenticated ? { name: "Ana Silva", firstName: "Ana", email: "ana.silva@gmail.com" } : null, + user: authenticated ? { name: "Ana Silva", firstName: "Ana", email: "ana.silva@example.com" } : null, address: authenticated ? "0x71c2F3a68790eAd79216dBE733fb83aF089C93a4" : null, login: async () => { await new Promise((resolve) => setTimeout(resolve, 550)); setAuthenticated(true); }, logout: async () => setAuthenticated(false), From aa71fca761e8872610ba983cd422cbc36b5ba832 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:16:56 +0200 Subject: [PATCH 07/34] docs(repo): describe B2B onramp parameters as Vortex defaults The ADRs, architecture, runbook and security spec now state the fee policy, conversion window and destination handling as configured defaults, and carry the operational facts (memo routing, redeem timing, the link-message dependency, SEPA recalls) that only the flow overview held. --- contracts/monerium-forwarder/README.md | 2 +- docs/adr-0005-monerium-b2b-onramp.md | 113 +++++++++--------- ...-0007-monerium-b2b-partner-registration.md | 5 +- docs/api/pages/07-webhooks.md | 2 +- docs/architecture-monerium-b2b-onramp.md | 28 +++-- docs/operations-monerium-b2b-runbook.md | 48 ++++---- .../05-integrations/monerium-b2b.md | 8 +- 7 files changed, 109 insertions(+), 97 deletions(-) diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index 66975b7b7..d9ffa2a2e 100644 --- a/contracts/monerium-forwarder/README.md +++ b/contracts/monerium-forwarder/README.md @@ -3,7 +3,7 @@ Foundry project for the attestor-linked forwarder (Monerium B2B zero-touch onramp): per-client EIP-1167 clones whose EIP-1271 `isValidSignature` accepts only the fixed Monerium link message from the Vortex attestor, a conversion policy that swaps over a -factory-whitelisted Uniswap v3 route and settles the fill against a partner reference +factory-whitelisted Uniswap v3 route and settles the fill against a reference rate (fee above the target, top-up from the shared `VortexSubsidyVault` below the floor, Chainlink floor on the client's net), whole-payment forwarding (chunks accumulate as USDC on the clone and leave in one `forward`), and a keeper-only, delay-gated `recover` to the client's refund diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 81a40250b..9de820d5c 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -11,7 +11,7 @@ acceptances* of the B2B EUR → USDC onramp. How the system works lives in [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); security invariants and the threat model in [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md); -launch gates and terms inputs in +launch gates and the deploy checklist in [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md); procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). The consumer-flow design this grew out of remains a phase-2 proposal: @@ -19,13 +19,11 @@ consumer-flow design this grew out of remains a phase-2 proposal: ## Context -Partner-sourced business clients (the partner's OTC corporates, KYB'd under the partner's -FINMA/VQF licence with per-customer reliance attestations to Monerium) need EUR → USDC -on Ethereum with **zero Vortex-side interaction**: no app, no wallet ceremony, no digital -signature. Onboarding is paperwork only. The single blocker in the consumer design was -Monerium's link signature — connecting an IBAN to an on-chain address requires that -address to approve the fixed ownership message `"I hereby declare that I am the address -owner."` via EIP-1271. +Partner-sourced business clients need EUR → USDC on Ethereum with **zero Vortex-side +interaction**: no app, no wallet ceremony, no digital signature. Onboarding needs no +client action at Vortex. The single blocker in the consumer design was Monerium's link signature — +connecting an IBAN to an on-chain address requires that address to approve the fixed +ownership message `"I hereby declare that I am the address owner."` via EIP-1271. ## Decision: the attestor-constrained forwarder @@ -47,7 +45,7 @@ Supporting decisions, all in force: - **Conversion policy** (amended 2026-09-15): the swap runs over one of the factory's whitelisted Uniswap v3 routes — validated on chain to touch only EURe, EURC and USDC on the immutable router — chosen by the caller through a route index; the caller also - supplies the partner reference rate, which the contract bounds to a band around + supplies the reference rate, which the contract bounds to a band around Chainlink EUR/USD (staleness ceiling kept). The fill is settled into fee bands (amendment) and the Chainlink floor is enforced on the client's net after fee and subsidy; exact approvals and atomic delta checks stay; the fee goes to an immutable @@ -92,11 +90,11 @@ Supporting decisions, all in force: ## Amendment 2026-09-15: reference-priced fee bands and the subsidy vault -The partner agreement fixes the client's rate against a reference: the client receives -the Coinbase EURC-USDC reference minus 12.5 bps, and never worse than 15 bps below it. -A flat skim on whatever the DEX returns cannot express that, so the contract now settles -every fill into bands against a reference rate (decided with the partner; contracts were -not yet deployed, so this replaced the flat fee before launch with no migration): +Vortex's default fee policy fixes the client's rate against a reference: the client +receives the Coinbase EURC-USDC reference minus 12.5 bps, and never worse than 15 bps +below it. A flat skim on whatever the DEX returns cannot express that, so the contract now +settles every fill into bands against a reference rate (contracts were not yet deployed, +so this replaced the flat fee before launch with no migration): - **Reference rate** (superseded 2026-09-18 by the bid/ask midpoint, see the third amendment). Before each swap the keeper computes a five-minute @@ -135,7 +133,7 @@ not yet deployed, so this replaced the flat fee before launch with no migration) - **Accepted limitation.** After the 24 h trigger anyone may execute the swap, priced against Chainlink and unsubsidized, so a forced swap after a deliberate deferral can land below the 15 bps floor. Accepted: the trigger exists so no Vortex outage can trap - funds; the rate guarantee applies to keeper-executed swaps and the terms say so. + funds; the rate guarantee applies to keeper-executed swaps only. Pausing instead of deferring was rejected as turning every market dip into an operator incident. - **Accepted exposure.** The subsidy widens the sandwich-exploitable band from the @@ -144,11 +142,9 @@ not yet deployed, so this replaced the flat fee before launch with no migration) ## Amendment 2026-09-17: whole-deposit settlement and the refund path -Product requirements from the partner: one USDC transfer per bank -payment, and an automatic refund of the exact EUR amount to the payer's bank account -when a payment cannot be converted inside the promised window. Vortex holding the funds -for that refund is agreed commercially. Decisions (the proposal that led here, -`proposal-monerium-b2b-settlement-and-recovery.md`, is in git history): +Product requirements: one USDC transfer per bank payment, and an automatic refund of the +exact EUR amount to the payer's bank account when a payment cannot be converted inside +the promised window. Decisions: - **Chunks accumulate on the clone; one forward per payment.** `swap(reference, route, amountIn)` converts an explicit chunk and keeps the USDC (subsidy included) on the @@ -170,14 +166,12 @@ for that refund is agreed commercially. Decisions (the proposal that led here, runbook until automated. - **Client fallback role removed.** `fallbackAddress`, `sweep`, `setDestination`, `setClientPaused` and the dead-man sweep are gone (the client never held a key in - the pilot; the partner warrants the destination, B5). A destination change means a new + the pilot; the partner supplies the destination, B5). A destination change means a new clone (runbook §5). The permissionless `swap`/`forwardAll` path after `TRIGGER_DELAY` stays as the liveness guarantee, so a Vortex outage never traps converted funds. - **Trust statement (replaces "Vortex keys cannot move client funds").** Vortex keys can move a client's funds only to the Vortex recovery wallet, only after `RECOVERY_DELAY`, - and the contract can never send anywhere else. Consequences carried to G1 (Monerium - re-approval of the Vortex-held fallback and of one company profile refunding many - client corporates), G2 (custody scoping) and the partner terms (rollout §Terms 6). + and the contract can never send anywhere else. - **Refund triggers.** Missed window (automated in a later phase; operator-triggered via the admin endpoint until then), operator intervention, and remainders below `minSwapAmount` (they cannot be swapped and are refunded). Chunk fees already taken on @@ -246,8 +240,8 @@ for that refund is agreed commercially. Decisions (the proposal that led here, €1 (P6; €25 and €250 before). Almost every payment now converts instead of waiting for the refund path as sub-minimum. Accepted consequences: a very small payment costs Vortex more gas than it earns; a small payment forwarded to an exchange address can - land below that exchange's minimum deposit, which now sits with the client under B5 - instead of Vortex's minimum-forward diligence; and unsolicited EURe from €1 upward arms + land below that exchange's minimum deposit (the destination is the partner's input, + B5); and unsolicited EURe from €1 upward arms a clone's batch timers (from €25 before), which the stranded-balance monitor still reports. The guardian can raise the operational minimum at any time without a redeploy; it can never go below €1. @@ -256,13 +250,12 @@ for that refund is agreed commercially. Decisions (the proposal that led here, price impact per swap and a smaller top-up per chunk at the ladder's top (about 115 USDC instead of 285), at the cost of more transactions and more time for large payments. When the market needs the ladder's top tier, each chunk can wait up to - 16 minutes, so a payment above roughly €70k could reach the two-hour window; the - €50k per client per day pilot limit (B4) keeps a payment at five chunks or fewer. The + 16 minutes, so a payment above roughly €70k could reach the two-hour window. The guardian can change the cap at any time up to the €50k ceiling. -- **Penny test optional.** The penny test (B2) was never agreed as a mandatory step. It - is an optional check, recommended for exchange destinations, and not an activation - requirement. A wrong or rotated destination is now caught by the penny test only when - one is run, otherwise by the dormancy gate; the loss allocation under B5 is unchanged. +- **Penny test optional.** The penny test (B2) is an optional check, recommended for + exchange destinations, and not an activation requirement. A wrong or rotated + destination is now caught by the penny test only when one is run, otherwise by the + dormancy gate. ## Amendment 2026-10-01: per-client refund wallets @@ -284,9 +277,9 @@ for that refund is agreed commercially. Decisions (the proposal that led here, switching an existing client then means a new clone (runbook §5). - **Linked to the client's profile, refunded from the client's IBAN.** Onboarding links the refund wallet to the client's Monerium profile next to the forwarder (an address - belongs to exactly one profile, confirmed by Monerium 2026-09-30). The refund runs on - that wallet as before: reverse swap, float top-up to the exact amount, redeem to the - payer, which Monerium pays out of the client's own IBAN. The float also tops up the + belongs to exactly one profile). The refund runs on that wallet as before: reverse + swap, float top-up to the exact amount, redeem to the payer, which Monerium pays out of + the client's own IBAN. The float also tops up the wallet's ETH for its own transactions, sized from the current gas price. No Vortex company profile at Monerium is needed, and recovered funds of different clients never share a wallet. `MONERIUM_B2B_RECOVERY_PRIVATE_KEY` is replaced by @@ -296,12 +289,12 @@ for that refund is agreed commercially. Decisions (the proposal that led here, | ID | Parameter | Value | |---|---|---| -| B1 | Fee policy | **target 1250 ppm (12.5 bps), floor 1500 ppm (15 bps) below the reference**, per client, guardian-adjustable (amended 2026-09-15; replaces the flat 0 / 15 bps skim) | +| B1 | Default fee policy | **target 1250 ppm (12.5 bps), floor 1500 ppm (15 bps) below the reference**, per client, guardian-adjustable (amended 2026-09-15; replaces the flat 0 / 15 bps skim) | | B2 | Penny-test amount | 5 USDC, **optional** (amended 2026-09-29): recommended for exchange destinations, not an activation requirement | -| B3 | Processing SLA wording | **Same business day**; weekend mints execute within the 52 h oracle window at possibly wider spreads | -| B4 | Pilot volume limits | **€50k/client/day, paper/contractual only** (no backend enforcement in the pilot; GA revisit) | -| B5 | Partner liability | Tier A defaults: partner warrants destination correctness; rotation loss borne by the client; dormancy re-activation on written partner confirmation | -| B6 | Redemption-limitation disclosure | Mandatory in client terms (committed to Monerium); draft in the rollout doc | +| B3 | Conversion window | **2 h from the mint** (P3; `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` default 120), refunded past it; weekend mints execute within the 52 h oracle window at possibly wider spreads | +| B4 | Per-client volume limit | **Not enforced by the backend** (GA revisit); `perSwapCap` (P7) bounds each swap, not a client's daily volume | +| B5 | Destination responsibility | The partner supplies and confirms each destination; dormancy re-activation on written partner confirmation | +| B6 | Redemption limitation | A clone validates no redeem order, so its EURe is never redeemed to an IBAN directly: it leaves only by swap and forward to the destination, by recovery to the client's refund wallet, or through Monerium's issuer recovery (T1) | | P1 | `SLIPPAGE_BPS` | **60 bps on the client's net after fee and subsidy** (amended 2026-09-17; 40 from 2026-09-15, 100 on the raw fill before) | | P2 | `MAX_FEE_PPM` | 10000 ppm (1%), immutable; caps both the fee and the floor policy (amended 2026-09-15; was `MAX_FEE_BPS` 100) | | P3 | `RECOVERY_DELAY` | **2 hours** (amended 2026-09-17): the promised conversion window, enforced on chain as the earliest a payment may move to the recovery wallet. Replaces the dead-man sweep delay (7 days on 2026-09-15, 60 before), which had no target left once the fallback role was removed | @@ -316,10 +309,10 @@ for that refund is agreed commercially. Decisions (the proposal that led here, | P12 | Reference rate | **Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap, deferring on a spread above 50 bps** (amended 2026-09-18; from 2026-09-15 a five-minute VWAP over one-minute candles widened to 60 min on no volume), keeper-computed per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15). The floor on the net binds first: with `floorPpm` 15 bps and `SLIPPAGE_BPS` 40 bps, a reference more than `SLIPPAGE_BPS − floorPpm` ≈ 25 bps below Chainlink makes every normal fill (fee band or subsidized) revert on chain and defer off chain, so ~25 bps is the working downside margin against a stale round; the 100 bps band is the outlier ceiling for a keeper-supplied value, not the operating tolerance (2026-09-16) | | P14 | Subsidy ladder | **`MONERIUM_B2B_SUBSIDY_LADDER` = `0:0,360:10,480:20,600:30,720:40,840:50,960:100`** (2026-09-18; keeper policy, tunable from deferral logs); per-chunk clock; the vault's per-swap cap must be at least the ladder's top | | P13 | Subsidy vault limits | One shared vault; **50 bps of the reference value per swap, 200 USDC per UTC day** at launch, guardian-settable; withdraw to treasury only (2026-09-15) | -| T2 | Whitelabel MSA terms | Open — G1 negotiation (rollout doc), includes the per-IBAN suspension ask | -| T3 | KYB submission mechanism | Open, deliberately unbuilt — pilot corporates are approved by Monerium under partner KYC reliance and imported via the admin mapping; no identity-data submission path may exist until this settles (security-spec invariant 11) | +| T2 | Per-IBAN suspension | Best-effort: a manual request to Monerium (runbook §2.2) | +| T3 | KYB submission mechanism | Open, deliberately unbuilt — clients are approved by Monerium and imported via the admin mapping; no identity-data submission path may exist until a submission mechanism is designed and this row and security-spec invariant 11 are updated | | T4 | Sandbox wire-format verifications | Webhook digest encoding, delivery id field, order-state vocabulary, and the EIP-191 link-hash variant were confirmed against the sandbox during G0; re-verify against production before first mainnet deposit | -| T1 | Issuer recovery message | **Resolved (verbal, 2026-08-26): identical to the link message** — already whitelisted, recovery works as built; `RECOVERY_HASH` stays 0; written confirmation folds into the G1 package | +| T1 | Issuer recovery message | **Identical to the link message** — already whitelisted, recovery works as built; `RECOVERY_HASH` stays 0 | | O1 | Client-migration tooling | Build when first needed; manual procedure in the runbook meanwhile | | O2 | `FEE_RECIPIENT` treasury | **New dedicated Safe multisig** (immutable at implementation deploy); guardian key to hardware/multisig custody at GA | @@ -345,29 +338,37 @@ example (oversized-deposit allocation). client verification moment; the published manifest + verifier make deployments *checkable*, not trustless. Accepted; heightened relative to the consumer flow. - **S1 — Monerium credential control-plane.** Whitelabel credentials can re-link - addresses and move IBANs (redirecting *future* mints only). Cannot be prevented - client-side: association monitor is the detective control; Monerium-side - authorization requirements are the G1 ask; response = rotate + suspend (runbook). -- **CEX destination rotation.** Not verifiable on-chain; carried contractually (B5) + addresses and move IBANs (redirecting *future* mints only). The white-label app is the + partner's, so the partner holds credentials with the same power; it only creates + profiles and submits KYB, and never links addresses, requests or moves IBANs, places + orders, or changes Vortex's webhook subscription, so any such change is treated as an + incident (runbook §2.5). Closing a profile also closes its IBAN, so profile closures are + coordinated with Vortex operations. Cannot be prevented client-side: a preventive + control (authorization requirements on `PATCH /ibans` and `POST /addresses`) can exist + only at Monerium; the association monitor is the detective control; response = rotate + + suspend (runbook). +- **CEX destination rotation.** Not verifiable on-chain; left to the partner (B5), with the dormancy gate and an optional penny test. Silent-loss risk converts to a pause via the dormancy gate. - **Vortex custody on the refund path** (amendment 2026-09-17). A recovered payment sits in Vortex's own wallet until the bank refund goes out; a compromised keeper plus recovery key could divert a payment the window was missed on. Bounded by the immutable wallet, the on-chain delay, explicit amounts, a dedicated linked address holding - nothing else, and the association monitor; accepted commercially by the partner and - carried to G1/G2. + nothing else, and the association monitor. - **Broken destination** — with no client key on the clone, a wrong destination is - caught by an optional penny test or by the dormancy gate; a rotation loss is borne by the - client/partner (B5); a destination change is a new clone. -- **Non-custody ≠ out of MiCA scope.** The constrained-attestor construction defeats - the custody definition, but exchange/transfer-service scoping is a separate G2 - question. Never present "no custody" as "no licence needed". + caught by an optional penny test or by the dormancy gate; a destination change is a new + clone. - **Stuck-state table** (route death, feed retirement, depeg beyond bound, blacklisted destination, reference feed outage, exhausted subsidy budget): all fail-safe — swaps revert or the keeper defers, funds accumulate as EURe; past the promised window the payment is refunded through the client's refund wallet, past 24 h anyone may convert and forward permissionlessly; the issuer backstop remains. Accepted. +- **Link-message change.** The forwarder validates only Monerium's exact ownership + message (a constant of the implementation), so a change to that message at Monerium + fails every new link closed, and with it new onboarding, until a new implementation and + factory are deployed; issuer recovery validates the same message (T1). +- **SEPA recall after forwarding.** A payment recalled by the payer's bank after its USDC + was forwarded cannot be reversed on chain; the system has no clawback path. - **Bounded keeper pricing power.** A compromised keeper can pick any whitelisted route and any reference inside the Chainlink band: worst case the fee reaches `MAX_FEE_PPM` or the vault pays up to its caps. Bounded by the band, the fee cap, the vault limits @@ -387,7 +388,7 @@ accepted, IBAN issued, no client interaction). A Vortex outage can never trap co funds (the permissionless path), and a payment the promised window was missed on is refunded rather than parked, at the price of Vortex custody on that path. The cost: every rescue path must be designed in upfront (no universal owner key), fee-policy increases are timelocked and venue changes are -bounded by on-chain route validation rather than admin switches, the partner's rate +bounded by on-chain route validation rather than admin switches, the client's rate guarantee is enforced by the contract at the cost of a treasury-funded subsidy budget, and Vortex accepts elevated provisioning trust plus a control-plane risk at Monerium -that only contract terms and monitoring can bound. +that only Monerium-side controls and monitoring can bound. diff --git a/docs/adr-0007-monerium-b2b-partner-registration.md b/docs/adr-0007-monerium-b2b-partner-registration.md index 88dff2453..7802d9056 100644 --- a/docs/adr-0007-monerium-b2b-partner-registration.md +++ b/docs/adr-0007-monerium-b2b-partner-registration.md @@ -79,7 +79,7 @@ could deploy. the registry (below). 5. **Partner binding by configuration.** `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID` (read lowercased) names the one manager allowed to register, the partner owning the - white-label app. Per-partner credentials and bindings come with the second partner. + white-label app. Per-partner credentials and bindings are a later extension. 6. **Activation.** An operator call everywhere except the sandbox (`SANDBOX_ENABLED=true`, which boot pairs with `DEPLOYMENT_ENV=sandbox`), where a registered account activates once its IBAN is recorded; staging and development need @@ -99,7 +99,8 @@ could deploy. from and to status, destination and forwarder. Operators find the accounts waiting with `GET /v1/admin/monerium-b2b/accounts?status=onboarding`. 7. **No exchange-address distinction.** Every valid address is accepted the same way; - the partner agreement carries the risk of rotating exchange deposit addresses. + an exchange rotating a deposit address is caught by the dormancy gate or an optional + penny test (ADR-0005 B5). 8. **Profile kind.** The keeper reads a profile's state, not its kind, so a personal (individual) Monerium profile in the partner's app is accepted like a corporate one and mapped with a mirrored business KYB record. Accepted scope decision of the diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index bec865e65..20b8dec3c 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -186,7 +186,7 @@ Each `conversions[]` entry is one chunk swap of this deposit: the EURe it consum Deposit `status` values: `pending`, `minted`, `held`, `returned` (provider states), then `converting`, `forwarded`, or — when a payment cannot be converted within the promised window — `recovering`, `refunded`, `recovery_failed`. `DEPOSIT_RECEIVED` may already report `converting` when conversion started within the same minute. -The nested `execution` object is the chunk's pricing: `referenceRateRaw` is the EUR/USD reference the swap was settled against, the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). The chunk's `usdcNetRaw` already includes both. +The nested `execution` object is the chunk's pricing: `referenceRateRaw` is the EUR/USD reference the swap was settled against, the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap (8 decimals), `feeRaw` the fee taken above the client's target rate, and `subsidyRaw` the top-up paid to reach the client's floor rate (both 6-decimal USDC base units). The chunk's `usdcNetRaw` already includes both. ### `DEPOSIT_RETURNED` diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index 8e55b00f6..bcc73778c 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -11,14 +11,14 @@ operator procedures in [`operations-monerium-b2b-runbook.md`](operations-moneriu ## The shape in one paragraph Each corporate client is onboarded and KYB-approved by Monerium in Vortex's whitelabel -app (under the partner's KYC reliance) and owns a dedicated Monerium profile. Vortex +app and owns a dedicated Monerium profile. Vortex deploys one `VortexForwarder` contract clone per client, links it to that profile with an attestor signature, and requests an IBAN **for the linked contract address** — the IBAN's default mint destination *is* the forwarder. From then on the flow is passive on Monerium's side: EUR received on the IBAN mints EURe to the forwarder, and Vortex's keeper converts each bank payment in `swap(reference, route, amountIn)` chunks on the contract — each swaps EURe to USDC over a whitelisted Uniswap v3 route and settles the -fill against the partner reference rate (surplus above the target is the fee, shortfall +fill against the reference rate (surplus above the target is the fee, shortfall below the floor is topped up from the subsidy vault, Chainlink bounds the net) — the USDC accumulates on the forwarder, and one `forward(amount)` pushes the whole converted payment to the client's fixed destination wallet, so the client sees one transfer per @@ -119,7 +119,7 @@ sequenceDiagram participant M as Monerium participant C as Ethereum - Note over M: Monerium onboards the corporate under partner reliance - profile "approved" + Note over M: Monerium onboards and KYB-approves the corporate - profile "approved" Op->>Adm: GET /v1/admin/monerium-b2b/refund-address (derived refund wallet) Op->>C: deployForwarder(destination, refundWallet, targetPpm, floorPpm) via factory Op->>Adm: POST /v1/admin/monerium-b2b/accounts @@ -135,9 +135,8 @@ sequenceDiagram Steps in prose: -1. **Monerium onboards the corporate** under the partner's reliance attestation; the - profile arrives `approved`. (Vortex's KYB submission API is a deliberate 501 stub — - registry T3.) +1. **Monerium onboards the corporate** and approves its KYB; the profile arrives + `approved`. (Vortex's KYB submission API is a deliberate 501 stub — registry T3.) 2. **Operator deploys the forwarder clone** with the client's `destination` (no setter: a wallet change means a new clone, runbook §5), the client's refund wallet as `recoveryAddress` (derived from `MONERIUM_B2B_REFUND_SEED` and the Monerium profile ID, @@ -209,6 +208,14 @@ mint identity. Only a settled, chain-indexed mint makes an account a conversion candidate. A live balance by itself is deliberately insufficient: this prevents a swap from outrunning the watcher's reorg window and becoming impossible to attribute safely. +A payer can name a chain and address in the SEPA memo; when that address is linked to the +client's profile, Monerium mints there instead of to the IBAN's default address. The only +other address linked to a client profile is the client's refund wallet, so such a payment +lands there: its order is skipped as referencing an unknown forwarder address, and it is +neither converted nor refunded automatically. Operators refund it by hand (runbook §2.7) +before the client's next refund, which would otherwise sweep that EURe to the float as +surplus. + ## How the mint watcher walks the chain The watcher is a poll-based log scanner with a **persisted cursor** so no block range is @@ -270,6 +277,9 @@ stateDiagram-v2 } ``` +Monerium reports no separate review state (a payment under compliance review stays +`pending`), so `held` is mapped defensively and not expected in practice. + ```mermaid stateDiagram-v2 direction LR @@ -397,9 +407,9 @@ and sends at most one transaction per account per cycle: ## Fees, reference rate and subsidy -The partner agreement fixes the client's rate against a reference: the reference minus -12.5 bps whenever the market allows it, never worse than 15 bps below it. The contract -settles every fill into three bands against that reference (decisions: +Vortex's default fee policy prices the client's rate against a reference: the reference +minus 12.5 bps whenever the market allows it, never worse than 15 bps below it. The +contract settles every fill into three bands against that reference (decisions: [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md), amendment). - **Reference rate.** Before each swap the keeper reads the Coinbase Exchange EURC-USDC diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index b5a587e0d..43c4b79f9 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -89,17 +89,16 @@ One pass per client. Prerequisites: guardian key funded on the target chain; `MONERIUM_B2B_ENABLED=true` and the complete `MONERIUM_B2B_*` env set on the one `mykobo` keeper backend (including the trusted factory address, read/private RPCs, webhook secret, and three keys); the factory's subsidy vault deployed, pointed at and -funded (§2.6); partner paperwork complete; the client -company onboarded and KYB-approved on Monerium's side (partner KYC reliance) with its -Monerium profile UUID at hand; the partner configured as a managed-profile manager +funded (§2.6); the client company onboarded and KYB-approved on Monerium's side with +its Monerium profile UUID at hand; the partner configured as a managed-profile manager (`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type `business`). -### 1.1 Paperwork inputs (from the partner agreement) +### 1.1 Client inputs - `destination` — client's payout address. CEX deposit addresses allowed; validate by hand: EIP-55 checksum, not zero/dead/precompile/token/router, warn-and-attest for - contract addresses and CEX addresses (rotation risk — terms). Nothing else screens + contract addresses and CEX addresses (rotation risk). Nothing else screens them: the registration API rejects only a malformed or zero address (400), and the factory refuses at init (the registration is rejected) only EURe, EURC, USDC, the router, the clone itself and the client's own refund wallet. A dead or precompile @@ -109,9 +108,8 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag change is a new clone, §5 — so get it right; a penny test is recommended for exchange destinations.) - `targetPpm` / `floorPpm` — the client's fee policy in ppm below the reference rate; - launch policy 1250 / 1500 (12.5 / 15 bps, ADR B1). Adjustable later via the - guardian's timelocked `setFeePolicy` (raising either value waits 24 h). -- Signed terms including the redemption-limitation disclosure (rollout doc, Terms §1). + Vortex's default policy 1250 / 1500 (12.5 / 15 bps, ADR B1). Adjustable later via + the guardian's timelocked `setFeePolicy` (raising either value waits 24 h). ### 1.2 Deploy the forwarder clone @@ -277,12 +275,11 @@ need no subsidy keep executing. ### 2.2 Monerium IBAN suspension ask -Per-IBAN suspension is **G1 item 7 — not yet contractual**; until the MSA settles it, -best-effort: contact Monerium support/emergency, identify the whitelabel partner -account and affected IBAN(s) + forwarder(s), ask for suspension of inbound SEPA -(deposits bounce to senders — NOT profile closure), record the ticket for the G1 -negotiation record. While unsuspended, inbound SEPA keeps minting EURe to the forwarder -— safe behind the contract invariants, but growing exposure. +Contact Monerium support/emergency, identify the whitelabel partner account and +affected IBAN(s) + forwarder(s), ask for suspension of inbound SEPA (deposits bounce to +senders — NOT profile closure), and record the ticket. While unsuspended, inbound SEPA +keeps minting EURe to the forwarder — safe behind the contract invariants, but growing +exposure. ### 2.3 Client notification @@ -442,9 +439,12 @@ be pending. If its float top-up was dropped, park the refund, clear its hash (`U (individual payers: `firstName`/`lastName`), `country` from the IBAN prefix, `memo` `vortex-refund:` (the key the automation checks before placing), the message `Send EUR to at ` - signed by the refund wallet's key; attach `supportingDocumentId` above EUR 15,000 (the - same client agreement can be reused, Monerium 2026-09-30). Monerium pays the refund - out of the client's own IBAN. Watch `order.updated` for `processed`. + signed by the refund wallet's key; attach `supportingDocumentId` from EUR 15,000 (one + standing document per client can be uploaded once and reused for its refunds). Monerium + pays the refund out of the client's own IBAN. Watch `order.updated` for `processed`. + Monerium may review a redeem order during business hours before paying it out; the + payout goes by SEPA Instant where the payer's bank supports it, otherwise the next + business day. 6. **Close the deposit.** `PATCH /v1/admin/monerium-b2b/deposits//status` with `{"status": "refunded"}`, from `recovering` or `recovery_failed`; use `recovery_failed` when a step cannot complete (and `recovering` again to retry later). @@ -466,7 +466,7 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- | `route ... could not be quoted` | One whitelisted route's pool is unquotable (drained, removed) | Disable it (§2.1) so the keeper stops trying; keep at least one healthy route | | `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, an address other than the forwarder and the client's own refund wallet linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | | `stranded funds on forwarder ... past RECOVERY_DELAY` (warn) | A batch has been open longer than the promised 2 h window and is neither forwarded nor recovering | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts), `deferring conversion` lines; if the payment cannot complete, mark it for recovery (§2.7) | -| `stranded funds ... past TRIGGER_DELAY` (error) | Permissionless path now live; SLA long broken (keeper outage or a persistent deferral) | Escalate; anyone may call `swap(reference, route, amountIn)` and `forwardAll()` — that path prices against Chainlink and pays no subsidy; communicate the delay | +| `stranded funds ... past TRIGGER_DELAY` (error) | Permissionless path now live; the promised window long missed (keeper outage or a persistent deferral) | Escalate; anyone may call `swap(reference, route, amountIn)` and `forwardAll()` — that path prices against Chainlink and pays no subsidy; communicate the delay | | `REFERENCE VENUE —` (error) | The Coinbase product the reference reads is delisted or halted; every keeper swap defers silently | Change `COINBASE_REFERENCE_PRODUCT` (a live EURC market), redeploy the backend; the venue is an operational, not an on-chain, setting | | `REFUND DUE — deposit ...` (error, `alert` mode) | A deposit outlived the promised window and the mode only reports | Mark it (§2.7 step 1) or switch to `auto` | | `REFUND FAILED — deposit ... in phase ...` (error) | A refund step cannot complete automatically (large amount, missing payer, rejected order, five failed attempts) | §2.7: after five failed attempts, fix the cause and set the deposit back to `recovering`; otherwise refund by hand under the memo and close the deposit as `refunded`. A hand send from the float key can collide with the keeper's float sends for other clients: first confirm no other deposit is `recovering` and no float transaction is in flight, then send right away (the check is a snapshot), or set `MONERIUM_B2B_AUTO_RECOVERY=alert` (needs a restart, pauses every client's refunds and silences the refund alerts for every client; set `auto` again right after the hand step) | @@ -740,7 +740,7 @@ and point the factory at it with `setSubsidyVault`. Fund it with USDC from an impersonated mainnet holder if you want to exercise a below-floor top-up; left empty, a below-floor fill makes the keeper defer, which is also a valid outcome to observe. -Deploy a client clone with the launch policy (1250 / 1500) as in §1.2, passing the +Deploy a client clone with the default policy (1250 / 1500) as in §1.2, passing the refund address the backend derives for the fixture's Monerium profile ID (the account mapping verifies it). Use a fresh salt and record the predicted address and receipt. Read back `destination()`, `recoveryAddress()`, @@ -1111,7 +1111,7 @@ On the `vortex-sandbox` service only: | Variable | Value | |---|---| | `FLOW_VARIANT` | `mykobo` (startup refuses B2B otherwise; the sandbox's retail EUR onramp then runs on Mykobo) | -| `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET` | The partner's sandbox white-label app (S11) | +| `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET` | The partner's sandbox white-label app | | `MONERIUM_B2B_RPC_URL` | Sepolia RPC | | `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` | `$FACTORY` | | `MONERIUM_B2B_KEEPER_PRIVATE_KEY`, `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` | the three keys of §8.3 | @@ -1168,10 +1168,10 @@ It registers the destination (or resumes the registration), waits until the acco active, checks the clone on chain, asks for the payment, and then checks the outcome: one forward transaction paying the deposit's net USDC to the destination, or the refund. `--refund` suspends the account for the payment and reactivates it afterwards, with -`ADMIN_SECRET`. The script does not make the payment. How a payment reaches a white-label -profile's IBAN in the sandbox is open with Monerium: the dashboard's "Simulate bank -transfer" mints only for your own profile, and EURe sent to the clone on chain is recorded -as unattributed and never converted. +`ADMIN_SECRET`. The script does not make the payment. How a payment reaches a +white-label profile's IBAN in the sandbox is still open (rollout ledger): the dashboard's +"Simulate bank transfer" mints only for your own profile, and EURe sent to the clone on +chain is recorded as unattributed and never converted. | Test | Payment | Expected | |---|---|---| diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index 3474ce043..a8f69231d 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -2,7 +2,7 @@ ## What This Does -The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each corporate client a Monerium IBAN linked to a per-client `VortexForwarder` contract. SEPA deposits mint EURe to the forwarder; a keeper later swaps and forwards USDC to the client's destination. This spec covers the backend integration built in `apps/api/src/api/services/monerium-b2b/`: the whitelabel API client, the attestor signature for address linking, the webhook receiver with durable inbox, the deposit processor, the managed-profile account mapping, and the onboarding automation. It is deliberately NOT part of the one-shot ramp state machine — accounts are persistent and repeatedly funded. Each account is owned by a Vortex **managed profile** (`monerium_accounts.vortex_profile_id`): the corporate is onboarded and KYB-approved on Monerium's side under the partner's KYC reliance, then mapped into Vortex as a managed child with an approved `provider_customers`/`kyc_cases` mirror. +The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each corporate client a Monerium IBAN linked to a per-client `VortexForwarder` contract. SEPA deposits mint EURe to the forwarder; a keeper later swaps and forwards USDC to the client's destination. This spec covers the backend integration built in `apps/api/src/api/services/monerium-b2b/`: the whitelabel API client, the attestor signature for address linking, the webhook receiver with durable inbox, the deposit processor, the managed-profile account mapping, and the onboarding automation. It is deliberately NOT part of the one-shot ramp state machine — accounts are persistent and repeatedly funded. Each account is owned by a Vortex **managed profile** (`monerium_accounts.vortex_profile_id`): the corporate is onboarded and KYB-approved on Monerium's side, then mapped into Vortex as a managed child with an approved `provider_customers`/`kyc_cases` mirror. **Provider type:** on-ramp (EUR → USDC) **Fiat currencies:** EUR @@ -14,7 +14,7 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 0. **Dark by default and fail-fast on activation** — unless `MONERIUM_B2B_ENABLED` is exactly `true`, the public/admin B2B routes, raw webhook parser, keeper worker, and deposit-webhook registration are disabled. Existing generic webhook-outbox rows continue draining. Activation requires `FLOW_VARIANT=mykobo`, all whitelabel credentials, three keys, read RPC, webhook secret, and the trusted factory address; production also requires the private orderflow RPC. Missing configuration aborts startup rather than partially enabling the flow. Partner registrations (invariant 12) additionally need `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID` and `MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`, which startup accepts only together; without them the registration endpoint answers 403 and the keeper deploys nothing. The deployer key must differ from the attestor, guardian, keeper and float keys (it sends with implicit nonces, one EOA each), and the partner binding is compared lower-cased. 1. **Attestor key stays in env and out of logs** — `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` is read from the environment only, never persisted, never returned by an API response, and never included in log lines or error messages (the not-configured error names the variable, not the value). -2. **The attestor signature authorizes linking only** — the attestor signs exactly `keccak256(abi.encodePacked(block.chainid, forwarderAddress, hash))` (the chainid binding prevents cross-chain replay — review r1) where `hash` is `LINK_HASH_191`, the EIP-191 personal-message hash of `"I hereby declare that I am the address owner."` (the raw-keccak variant was removed after the G0 sandbox validation), or the optional non-zero `RECOVERY_HASH` reserved for a future distinct recovery message (kept `bytes32(0)`: per Monerium, T1 resolved 2026-08-26 verbally, the recovery flow validates the SAME ownership message as linking, so the whitelisted `LINK_HASH_191` already covers issuer recovery — the signature proves only address ownership; the recovery payout target is Monerium's controlled process, paying exclusively to the customer's own verified bank account). `VortexForwarder.isValidSignature` accepts nothing else, so a leaked attestor key can link addresses but can never move funds or change forwarder config. The backend MUST never sign arbitrary hashes with this key. +2. **The attestor signature authorizes linking only** — the attestor signs exactly `keccak256(abi.encodePacked(block.chainid, forwarderAddress, hash))` (the chainid binding prevents cross-chain replay — review r1) where `hash` is `LINK_HASH_191`, the EIP-191 personal-message hash of `"I hereby declare that I am the address owner."` (the raw-keccak variant was removed after the G0 sandbox validation), or the optional non-zero `RECOVERY_HASH` reserved for a future distinct recovery message (kept `bytes32(0)`: Monerium's recovery flow validates the SAME ownership message as linking (registry T1), so the whitelisted `LINK_HASH_191` already covers issuer recovery — the signature proves only address ownership; the recovery payout target is Monerium's controlled process, paying exclusively to the customer's own verified bank account). `VortexForwarder.isValidSignature` accepts nothing else, so a leaked attestor key can link addresses but can never move funds or change forwarder config. The backend MUST never sign arbitrary hashes with this key. 3. **Signature format matches the contract check** — 65-byte `r ‖ s ‖ v` with `v ∈ {27, 28}` and low-s (the contract rejects malleable signatures). Enforced by construction (viem canonical signatures) and pinned by unit test `attestor.test.ts`. 4. **Webhook HMAC follows Monerium's current v1 protocol** — `webhook-signature` must contain exactly `v1,`. The HMAC-SHA256 input is `..` and the key is the decoded 24–64-byte payload of the configured `whsec_` secret. The raw bytes are captured by a route-scoped body-parser hook and compared with `crypto.timingSafeEqual`; malformed or unverified requests are rejected 401 before any database write. 5. **Durable persist before 200 (R06)** — every verified delivery is inserted into `monerium_webhook_events` before the 200 response is sent. Processing happens strictly after the response; a crash between insert and processing loses nothing because the inbox row survives. @@ -23,7 +23,7 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 8. **Per-forwarder serialization via advisory lock** — all deposit writes for one forwarder happen inside a transaction holding `pg_advisory_xact_lock(hashtextextended('monerium-b2b:' || lower(forwarderAddress), 0))`, so concurrent processors (multiple API instances, webhook-triggered plus scheduled runs) apply events for an account strictly one at a time. This is the same serialization point the execution/attribution logic (R04) will use. 9. **Deposit identity and scope are verified** — authenticated payloads must pass the shared Monerium wire schema. Only EUR issue orders on the configured chain and the mapped account's Monerium profile are accepted; `meta.txHashes` is used only when it contains exactly one hash. `monerium_order_id` is unique and cannot move between accounts; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are positive 18-decimal base-unit strings converted from provider decimals, never floats. An amount-only mint match is accepted only when exactly one same-account candidate exists. A late real order in a minted provider state reconciles its unique exact same-account unattributed mint by amount and transaction hash: a missing provider row adopts the synthetic row, while an existing provider row receives the chain identity and allocations atomically before the synthetic row is removed. Pending or terminal provider states never adopt a synthetic mint. Ambiguity is quarantined and alerted, never guessed. Malformed authenticated deliveries are terminally discarded so they cannot poison the inbox. 10. **Client credentials are env-only and requests are bounded** — all provider calls go through the shared white-label client ([monerium.md](./monerium.md)): credentials come from env (`MONERIUM_WHITELABEL_CLIENT_ID/SECRET`), every call carries an explicit timeout, HTTPS base URLs only, successful responses are validated against the consumed wire schemas, and upstream failures surface with redacted response bodies. The B2B adapter (`monerium-api.ts`) adds no transport of its own. -11. **No KYB submission path exists** — the whitelabel KYB mechanism is contractually unsettled (adr-0005 registry T3), so no identity-data submission code path exists in the B2B module or the shared client. Pilot corporates do not need one: they are onboarded and approved by Monerium under the partner's KYC reliance, and the admin mapping imports that outcome as an approved `kyc_cases` row. +11. **No KYB submission path exists** — the whitelabel KYB submission mechanism is deliberately unbuilt (adr-0005 registry T3), so no identity-data submission code path exists in the B2B module or the shared client. Clients do not need one: they are onboarded and KYB-approved by Monerium, and the admin mapping imports that outcome as an approved `kyc_cases` row. 12. **Account mapping is admin-or-registration, atomic, and rooted in a trusted factory** — an account is created only by `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) or by the keeper for a partner registration, and both go through the same `provisionMoneriumB2bAccount`. A registration (`POST /v1/monerium-b2b/accounts`, `registration.ts`) is accepted only from the manager bound to the white-label app by `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID`, never with an admin impersonation token (403 `IMPERSONATION_NOT_ALLOWED`; [admin-impersonation.md](../01-auth/admin-impersonation.md) invariant 18), and only for a profile that app can read and Monerium has neither rejected nor closed (422 `MONERIUM_B2B_PROFILE_UNAVAILABLE`; a Monerium failure, timeout, rate limit or 403 is 503 `MONERIUM_B2B_PROVIDER_UNAVAILABLE`, never blamed on the partner's input). It is create-only per Monerium profile (unique row): an identical replay returns its state, any difference in destination, client reference or contact email (compared case-insensitively) is 409 `MONERIUM_B2B_DESTINATION_CONFLICT`, a profile that already has an account is 409, and a client conflict — the client reference already used with a different contact email, by a non-business or with another Monerium profile, the contact email used by another client, or another non-rejected registration with the same reference or email — is 409 `MONERIUM_B2B_CLIENT_CONFLICT`, checked at request time and again by the keeper before it pays for a deployment. The partner may register a rejected profile again, with the same or corrected data: the row is reset to `requested` (202). The registration reads the profile's state, never its kind, so a personal Monerium profile is accepted and mapped like a corporate one (an accepted scope decision, not a control). While `requested`, the snapshot carries `waitingReason`: `monerium_profile_pending` (created, incomplete, pending or review), `monerium_profile_not_visible` (Monerium answers 404), `deployment_pending`, `deployer_not_ready` (Vortex's deployer lacks the factory role or gas), `manager_inactive`, or `temporary_error` (RPC, Monerium or another transient failure). The keeper rejects a registration only for a definite reason: Monerium rejected or closed the profile; the factory refused the deployment's arguments (`InvalidConfigAddress`, `ZeroAddress`, `InvalidFeePolicy`); a client conflict; a mapping conflict that a retry cannot change; an operator mapped the profile by hand to a different destination; or an operator withdrew the registration. Every transient failure waits and is retried instead. Each cycle it evaluates the 20 least recently checked requested registrations (waiting ones never starve newer ones; one waiting for Monerium is asked again only after a minute) within a 15-second budget and sends at most one deployment (one key, implicit nonces), after the money steps and isolated in its own try/catch. It waits for the profile to be `approved`, then deploys the clone with the factory deployer key (a guardian-granted role, never the guardian key; its reach is bounded in Keeper invariant 1) at the CREATE2 salt `keccak256(abi.encode(moneriumProfileId, destination))`, which the factory binds to the whole initialization (destination, recovery address, fee policy), so nobody can occupy a client's predicted address with other settings, and a crash adopts the predicted clone instead of deploying twice. A deployment still in flight is waited for; one that reverted, or has no receipt after 10 minutes, is sent again. A clone found at the predicted address but out of the registry long after any deployment was revoked by the guardian; the registration is rejected, since the same destination would always land on it. An account an operator mapped by hand for the same profile is adopted when its destination matches the registration and it belongs to one of the registering manager's clients, and the registration is rejected otherwise. The keeper writes an outcome only while the row is still the one it loaded (status `requested`, same destination, reference and email) and holds a row lock while it maps, so an operator withdrawal or the partner's new attempt during a cycle wins and is never overwritten. Operators see every registration and the keeper's progress through `GET /v1/admin/monerium-b2b/registrations?status=requested|mapped|rejected` (ADMIN_SECRET; adds the registration id, contact email, manager profile, deployment hash and send time, and last check) and may withdraw one that is still `requested` with `POST /v1/admin/monerium-b2b/registrations/:registrationId/withdraw`: it becomes `rejected` with a Vortex-written reason and the partner registers the profile again with corrected data (409 `MONERIUM_B2B_REGISTRATION_NOT_WITHDRAWABLE` for any other status, 404 `MONERIUM_B2B_REGISTRATION_NOT_FOUND`). A clone already deployed for a withdrawn registration stays unused (no IBAN is linked to it) and the guardian may revoke it. Activation gates the money: only an `active`, non-dormant account swaps or forwards (`canConvert`), so a payment that reaches an account before the operator checked its destination waits on the clone (`DEPOSIT_UPDATED` and the deposits read show `waitingReason: account_not_active` for a minted or converting deposit on an account that cannot convert, cleared once it can) and the deadline refunds it. Accounts from registrations activate on their own only in the sandbox (`SANDBOX_ENABLED=true`, i.e. `DEPLOYMENT_ENV=sandbox`), once their IBAN is issued; in development, staging and production activation is the operator call. The gate is a keeper-side policy, not an on-chain guarantee: the deadline refund is automatic only with `MONERIUM_B2B_AUTO_RECOVERY=auto`, after `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (default 120; `alert` only logs REFUND DUE until an operator marks the deposit, `off` does nothing), and once a clone's batch has been open for `TRIGGER_DELAY` (24 h) `swap` and `forwardAll` are permissionless on an unpaused clone, so an early payment nobody marked for recovery can be converted and forwarded to the unchecked destination. The mapping then verifies that the forwarder's immutable `FACTORY()` equals `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, queries `isForwarder` on that configured factory (never a self-reported address; a clone the guardian revoked is refused), and reads back destination/targetPpm/floorPpm before persistence (the fee policy is validated as `0 <= targetPpm <= floorPpm <= 10000`, mirroring the contract; the clone has no fallback role and no destination setter). The managed child, customer entity, approved KYB mirror, and account then commit in one database transaction, so a late uniqueness conflict leaves no orphan identity records. Identical replay returns existing records; any divergence is 409, never an overwrite. A Monerium profile, forwarder, and managed profile can each back at most one account (migrations 069/071). 13. **Onboarding provider writes are exactly-once and provider reads are scoped** — the automated link (`POST /addresses`) and IBAN request (`POST /ibans`) run through the profile-scoped `financial_operations` ledger (flow `monerium-b2b-onboarding`), so crashes and retries never repeat a claimed provider call; interrupted calls reconcile by re-reading the linked addresses / issued IBANs. Every linked-address and IBAN selection requires the exact mapped profile, configured Monerium chain, and forwarder address; multiple exact IBAN matches are rejected rather than selected arbitrarily. Automation only touches accounts in `onboarding` status that have a managed-profile owner and no recorded IBAN (an account with its IBAN is fully linked and only awaits activation; the IBAN landing is logged). Activation precedes the penny test and is the operator's call except for sandbox registrations (invariant 12): only an `active` account converts. 14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. @@ -40,7 +40,7 @@ The keeper loop (`workers/monerium-b2b.worker.ts`, every `MONERIUM_B2B_KEEPER_CY 3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. A reserved nonce that is never mined (the process died before broadcasting, the send threw, or a private relay dropped the transaction) would hold the keeper's nonce, the account and its refund path forever, so once the row has been idle for the five-minute pre-send deadline (`updated_at`, its last write: the reservation or the hash write) and the nonce is not yet mined with nothing pending at the keeper's next nonce, the keeper (first filling any gap below the row's nonce, left by a dropped `poke()`, with zero-value self-transfers) re-sends the row's exact `expectedCalldata` at that same nonce, under the keeper send lock (taken after the forwarder lock, never the reverse) so a live owner still about to send is waited for, and persists the new hash. One transaction per nonce can be mined and either copy is the expected call, so the exact-match recovery below adopts whichever lands. If the call no longer executes (gas estimation reverts, e.g. a stale persisted reference; a non-revert error such as an RPC timeout or an unfunded keeper only leaves the row pending), a revert-protecting private relay would never mine it, so the keeper consumes the nonce with a zero-value self-transfer instead; a swap or forward of an account that cannot convert (not activated, suspended, dormant or closed: `canConvert`) is treated the same way, so neither the activation gate nor an operator's suspension is ever overridden by a re-send, while a `recover` (the refund path) is exempt from that status gate and only consumed by the no-op if its own estimate reverts; the next cycle sees the nonce consumed without the expected transaction, fails the row, and the retry backoff and a fresh plan apply. A failed re-send leaves the row pending. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. 4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. The one exception is the send-only re-send of an idle, never-mined reserved nonce (invariant 3): it runs under the forwarder lock (keeper lock inside it), only after the idle deadline, and a lost hash write is recovered by the exact scan. 5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. -6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. +6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, counted from the later of the last confirmed conversion and the activation (else the account's creation, so an account activated long after it was mapped is not paused at once), the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. 8. **The refund runs one payment at a time per client on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit, and only for a client with no open row: at most one open row per client, each stepped at most once per keeper cycle; the keeper refuses a second `recover` for an account while one of its refunds is in flight (`activeRecoveryExists(accountId)`), and a refund parked in `recovery_failed` blocks only its own client's later refunds. Clients' refunds run side by side, but the shared EURe float wallet (implicit nonces over an RPC with no coherent pending pool) is serialized: per cycle, steps that cannot send from it run concurrently (one client's slow receipt wait does not hold the others' steps within the cycle, but the cycle lasts until the slowest wait ends: a cycle can last several minutes, since each receipt wait times out after 3 minutes and a float step can wait on more than one; other clients advance one step per cycle; and because clients share the one float wallet, a slow float transfer can delay another client's float step), alongside at most one step that sends from it (`sendEure`, `sendReverseSwap`, including the refund wallet's gas top-up), and none while a `topping_up` recovery still waits on its float transfer's receipt (a parked one is never stepped and does not hold the gate; so a second EURe top-up cannot reuse the first one's pending nonce or read the float balance before its debit). The gate covers top-up transfers whose hash is recorded; a float gas top-up whose receipt wait times out inside a step is retried from balances on a later cycle, and on an RPC without a coherent pending pool it can still collide with another client's float send in the meantime, which costs a retry, not funds; likewise an operator who parks a `topping_up` refund by hand while its float transfer is still pending releases the gate, and that transfer can still mine under the next client's float send (the runbook says to check the float's pending nonce, or cancel or replace the transfer, before parking); the float steps start no new step after 90 s. The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Five failed attempts during one refund (counted across its steps, since a sent step can still revert) park the deposit as `recovery_failed` with the phase preserved for the operator's retry; amounts of EUR 15,000 and above, a missing payer, or a rejected order park it for the operator to refund by hand under the same memo and close the deposit as `refunded`, after which the orchestrator closes the recovery without placing an order. A rejected order's id is dropped from the recovery (kept only in its error), so no partner event names an order that paid nothing. The recovery row is marked `redeemed`, and `error` recorded on a park, only after the deposit transition succeeded, so a refused or failed transition keeps the recovery open and is retried on the next cycle. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. 7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. From 03047b64f38626983f6e52a28d478bbd167a04d6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:16:56 +0200 Subject: [PATCH 08/34] docs(api): align B2B code comments with the default fee policy --- .../api/services/monerium-b2b/account-provisioning.ts | 10 +++++----- apps/api/src/api/services/monerium-b2b/dormancy.ts | 3 +-- .../src/api/services/monerium-b2b/manager-events.ts | 2 +- apps/api/src/api/services/monerium-b2b/recovery.ts | 2 +- .../api/services/monerium-b2b/reference-rate.test.ts | 2 +- .../src/api/services/monerium-b2b/reference-rate.ts | 2 +- apps/api/src/api/services/monerium-b2b/registration.ts | 2 +- .../database/migrations/078-monerium-fee-policy-ppm.ts | 2 +- .../079-add-conversion-reference-and-subsidy.ts | 2 +- .../src/models/moneriumConversionExecution.model.ts | 2 +- 10 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts index 5ccf8e6c8..2fed208e7 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -28,7 +28,7 @@ export interface ProvisionMoneriumB2bAccountInput { contactEmail: string; destination: string; externalSubjectId: string; - /** Fee policy in ppm below the reference rate; defaults to the agreed launch policy. */ + /** Fee policy in ppm below the reference rate; defaults to Vortex's default fee policy (B1). */ floorPpm?: number; forwarderAddress: string; managerProfileId: string; @@ -53,7 +53,7 @@ function normalizeAddress(value: string, name: string): string { return value.trim().toLowerCase(); } -/** Launch fee policy (docs/adr-0005-monerium-b2b-onramp.md, B1): 12.5 bps target, 15 bps floor. */ +/** Default fee policy (docs/adr-0005-monerium-b2b-onramp.md, B1): 12.5 bps target, 15 bps floor. */ export const DEFAULT_TARGET_PPM = 1_250; export const DEFAULT_FLOOR_PPM = 1_500; // Mirrors the implementation's immutable MAX_FEE_PPM (ADR-0005 table); the contract re-validates at deploy. @@ -189,7 +189,7 @@ async function verifyForwarderOnChain( } } -// Mirrors the whitelabel KYB outcome for a reliance-onboarded corporate: these +// Mirrors the whitelabel KYB outcome for a Monerium-approved client: these // profiles are onboarded and approved on Monerium's side before they are mapped // here, so the local provider records are imported directly as approved // (docs/operations-monerium-interface.md, profile lifecycle). @@ -312,8 +312,8 @@ export async function provisionMoneriumB2bAccount( let result: { account: { created: boolean; row: MoneriumAccount }; managedProfile: ProvisionManagedProfileResult }; try { result = await sequelize.transaction(async transaction => { - // The pilot reliance scope is KYB'd corporates only, so the child is always a - // business entity. Every local row is created in this transaction so a late + // Every B2B client is mapped as a business (adr-0007 decision 8), so the child is + // always a business entity. Every local row is created in this transaction so a late // account conflict cannot leave an orphaned approved identity behind. const managedProfile = await provisionManagedProfile( { diff --git a/apps/api/src/api/services/monerium-b2b/dormancy.ts b/apps/api/src/api/services/monerium-b2b/dormancy.ts index e8e444c4b..ea4952149 100644 --- a/apps/api/src/api/services/monerium-b2b/dormancy.ts +++ b/apps/api/src/api/services/monerium-b2b/dormancy.ts @@ -13,8 +13,7 @@ import { forwarderAbi, getGuardianWalletClient, getPublicClient } from "./chain" * stays `active` — the pause lives on-chain, `dormant_since` records the detection. * * Un-pause is MANUAL for now: guardian ops call setGuardianPaused(false) after the - * partner re-confirms the client relationship — re-confirmation mechanics are a - * partner-agreement item (adr-0005 registry B5). + * partner re-confirms the destination in writing (adr-0005 registry B5). */ /** Dormancy pause window — registry P5 (docs/adr-0005-monerium-b2b-onramp.md). */ diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.ts b/apps/api/src/api/services/monerium-b2b/manager-events.ts index c3b46bb5f..4427c8201 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -436,7 +436,7 @@ async function emitDepositUpdatedEvents(deps: ManagerEventDeps): Promise { /** ACCOUNT_UPDATED: the account snapshot whenever it changed (IBAN issued, status, dormancy). */ async function emitAccountUpdatedEvents(): Promise { - // ponytail: scans every mapped account each pass; fine at per-partner pilot scale. + // ponytail: scans every mapped account each pass; fine at pilot scale. const accounts = await MoneriumAccount.findAll({ where: { vortexProfileId: { [Op.ne]: null } } }); for (const account of accounts) { try { diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index 6df24956b..5580c3c84 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -58,7 +58,7 @@ import { refundAccountFor } from "./refund-wallet"; */ export const REFUND_MEMO_PREFIX = "vortex-refund:"; -/** Monerium requires a supporting document above this amount; such refunds stay manual (rollout G1). */ +/** Monerium requires a supporting document above this amount; such refunds stay manual (runbook §2.7). */ export const SUPPORTING_DOCUMENT_THRESHOLD_EUR = 15_000; /** Gas the refund wallet's own transactions use (approve, reverse swap, surplus transfer), with margin. */ const REFUND_WALLET_GAS_UNITS = 400_000n; diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts index e698d8d4c..38e6ce474 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, mock, setSystemTime, spyOn } from "bun:test"; import { classifyReferenceVenue, fetchCoinbaseProductStatus, fetchCoinbaseReference, isWithinReferenceBand } from "./reference-rate"; -// The partner reference is the Coinbase EURC-USDC bid/ask midpoint (adr-0005 P12, +// The reference rate is the Coinbase EURC-USDC bid/ask midpoint (adr-0005 P12, // amendment 2026-09-18): spot, no averaging, with a spread guard for thin books. function serve(body: unknown, status = 200) { diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.ts index 69431078d..1876e3403 100644 --- a/apps/api/src/api/services/monerium-b2b/reference-rate.ts +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -1,7 +1,7 @@ import { formatUnits, parseUnits } from "viem"; /** - * Partner reference rate for the forwarder fee bands (docs/adr-0005-monerium-b2b-onramp.md, P12): + * Reference rate for the forwarder fee bands (docs/adr-0005-monerium-b2b-onramp.md, P12): * the Coinbase Exchange EURC-USDC bid/ask midpoint, read fresh before every swap and * recorded on the execution row (rate, source, time) so the partner can check it against * Coinbase's public ticker. Spot rather than an average (amendment 2026-09-18): an average diff --git a/apps/api/src/api/services/monerium-b2b/registration.ts b/apps/api/src/api/services/monerium-b2b/registration.ts index 3a4759012..fec078c4e 100644 --- a/apps/api/src/api/services/monerium-b2b/registration.ts +++ b/apps/api/src/api/services/monerium-b2b/registration.ts @@ -91,7 +91,7 @@ export interface RegistrationDeps { * blamed on the partner's input. */ profileState(moneriumProfileId: string): Promise; - /** The clone's CREATE2 address, bound to its destination, recovery address, launch fee policy and salt. */ + /** The clone's CREATE2 address, bound to its destination, recovery address, default fee policy and salt. */ predictAddress(destination: Address, recoveryAddress: Address, salt: Hex): Promise
; isForwarder(address: Address): Promise; /** Simulates, then sends `deployForwarder` from the deployer key; a contract revert throws. */ diff --git a/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts b/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts index 07bd7ab99..892e5788e 100644 --- a/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts +++ b/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts @@ -2,7 +2,7 @@ import { DataTypes, QueryInterface } from "sequelize"; // The forwarder prices swaps against a reference rate with a per-clone target and floor // in parts per million (docs/adr-0005-monerium-b2b-onramp.md, B1/P11). The flat -// fee_bps mirror is replaced by both policy values; defaults are the agreed launch policy. +// fee_bps mirror is replaced by both policy values; defaults are the B1 default policy. export async function up(queryInterface: QueryInterface): Promise { await queryInterface.addColumn("monerium_accounts", "target_ppm", { allowNull: false, diff --git a/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts index 18bcd794d..d1040f747 100644 --- a/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts +++ b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts @@ -1,6 +1,6 @@ import { DataTypes, QueryInterface } from "sequelize"; -// Every swap is priced against a partner reference rate (the Coinbase bid/ask midpoint) +// Every swap is priced against a reference rate (the Coinbase bid/ask midpoint) // and may draw a subsidy from the vault up to the keeper's tier for the chunk // (docs/architecture-monerium-b2b-onramp.md, fees section). The reference, the chosen // route and the tier are persisted before broadcast (crash-recovery calldata identity + diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index 06d620720..3f091d379 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -34,7 +34,7 @@ export interface MoneriumConversionExecutionAttributes { subsidyRaw: string | null; usdcNetRaw: string | null; destination: string; - /** Partner reference the swap was priced against, ORACLE_DECIMALS; persisted before broadcast. */ + /** Reference rate the swap was priced against, ORACLE_DECIMALS; persisted before broadcast. */ referenceRateRaw: string | null; referenceSource: string | null; referenceAt: Date | null; From 54283f7fa30e61b6b6af7ebe48582fab417dc21d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:16:57 +0200 Subject: [PATCH 09/34] docs(repo): narrow the B2B rollout doc to engineering readiness --- docs/operations-monerium-b2b-rollout.md | 143 ++---------------------- 1 file changed, 7 insertions(+), 136 deletions(-) diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 8db49e7d0..4d43d11f1 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -1,50 +1,15 @@ # Monerium B2B Onramp — Rollout -What still stands between the implemented system and a live pilot: the gates, the deploy -checklist, and the engineering inputs for terms drafting. Decisions and parameters are +What still stands between the implemented system and a live pilot: the launch-readiness +gates, the deploy checklist, and the open items ledger. Decisions and parameters are final in [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). ## Gates -**G1 — written approval package from Monerium.** Everything below exists only as -verbal/Telegram statements; consolidate into the MSA or a side letter: - -1. Attestor-pattern acceptance (verbally accepted, conditional on fallback capability — - **re-approval needed (2026-09-17):** the fallback is now a Vortex-held refund wallet - per client, linked to the client's own profile, which refunds out of the client's - IBAN (2026-10-01; one address per profile and the reusable supporting document were - confirmed 2026-09-30) — mandatory by design, so the condition is met). -2. Redemption-limitation disclosure obligation (their request; our commitment — §Terms 1). -3. Issuer recovery backstop: burn from a linked address, payout only to the customer's - own external bank account, no fees, re-verification possible — **including the - 2026-08-26 statement that recovery validates the same ownership message as linking** - (which is why it works against the forwarder as built). -4. IBAN pinning: authorization requirements for `PATCH /ibans` / `POST /addresses` on - whitelabel profiles (the S1 preventive control). -5. OAuth→whitelabel profile portability and whether the whitelabel `client_id` - auto-accesses existing profiles. -6. SEPA recall / fraud loss allocation after conversion+forwarding. -7. Per-IBAN suspension capability for incident response. -8. Corporate KYB mechanism for direct (non-reliance) clients — not needed for the - partner pilot, still an MSA item. -9. Advance notice of any change to the EIP-1271 ownership/link message (and the - recovery message): the forwarder whitelists their exact hashes, so an unannounced - change fail-closes new onboarding. - -**G2 — legal review** (not started): custody opinion on the attestor construction; MiCA -exchange/transfer-service scoping (non-custody is not the whole question); disclosure -enforceability; DPA with Monerium; sanctions screening for destinations; scope of the -bounded, pre-announced guardian fee-policy power (P11), the route whitelist (P10) and -the subsidy vault (P13). - **G3 — external contract audit.** Parameters are final (ADR); the internal reviews and the invariant suite are done, but this moves client funds. -**G4 — pilot.** Partner agreement signed (terms inputs below), reliance -attestations per customer, 3–5 clients at **€50k/client/day** (paper control), launch -fee policy 12.5 bps target / 15 bps floor (B1). - ## Deploy checklist (mainnet bring-up) The Sepolia sandbox follows its own procedure in the runbook (§8). @@ -105,113 +70,19 @@ The Sepolia sandbox follows its own procedure in the runbook (§8). that already holds accounts, run the runbook §1.7 check for `onboarding` accounts that already have an IBAN: they stop converting until activated. -## Terms & disclosure inputs (engineering-accurate; G2/partner own final wording) - -1. **Redemption limitation (B6 — mandatory, committed to Monerium).** Draft: - > EURe received at your dedicated forwarding address cannot be redeemed with Monerium - > from that address and cannot be withdrawn by you. It is converted to USDC and sent - > to your payout address as one transfer per payment; a payment that cannot be - > converted within the promised window is refunded by Vortex, in EUR and in full, to - > the bank account it was sent from. Monerium's recovery process, which pays out only - > to your own verified bank account, remains available as a backstop. - - (The recovery backstop is functional as built — T1 resolved — but keep it framed as - Monerium's process, subject to their verification.) -2. **Destination warranty & CEX rotation (B5 — Tier A accepted).** Client/partner - warrants the destination is valid and under the client's control and notifies Vortex - of changes before further deposits; client/partner bears rotation/closure/ - mis-crediting losses; CEX destinations carry an explicit rotation/minimum-deposit - attestation. Vortex's diligence consideration: an operator check of the destination - before activation, an optional 5 USDC penny test right after it (recommended for - exchange destinations), the 60-day dormancy gate, and never sending unconverted EURe to the destination. - Vortex enforces no practical minimum (€1, ADR amendment 2026-09-29), so keeping - payments above an exchange destination's minimum deposit is the client's - responsibility. The destination is - fixed per account: a change means a new forwarding account (and IBAN move) set up by - Vortex on the partner's written instruction; Vortex cannot redirect funds. -3. **Dormancy re-confirmation (P5/B5).** Draft: - > If no conversion completes for 60 days, forwarding pauses automatically and - > resumes only after you (or the partner on your behalf, in writing) re-confirm your - > payout address. Deposits made while paused remain in your forwarding account and - > convert after re-confirmation. A payment received while paused that cannot be - > converted within the promised window is refunded in full to the bank account it - > came from. -4. **Rate, fee and subsidy (B1/P1/P2/P12/P13)** — disclose the guarantee, the fee and - the hard bound separately: - - Reference rate: the midpoint between the best bid and the best ask on the Coinbase - Exchange EURC-USDC market, read immediately before each conversion from the public - ticker and recorded with the conversion; a conversion waits while the spread is - wider than 0.5%. The agreement's "Coinbase EURC oracle" — align the wording; the - source is the exchange market, weekdays and weekends alike. - - Guarantee: each keeper-executed conversion delivers the reference rate minus - 12.5 bps whenever the market allows it, and never less than the reference minus - 15 bps. Vortex's fee is whatever the market delivers above the 12.5 bps target, - contractually capped at the on-chain 1%; below the 15 bps floor Vortex tops the - conversion up from its own subsidy budget. The 12.5 bps target and 15 bps floor are - per client; raising either requires a 24 h on-chain pre-announcement (P11), - lowering is immediate. - - Subsidy limits: top-ups are capped per conversion and per day (P13), and the - amount Vortex is willing to top up grows with the time a chunk has waited for the - market (P14: nothing for the first six minutes, then in steps up to the cap). A - chunk therefore executes as soon as the market delivers the floor on its own, or - once Vortex's willingness to pay meets the shortfall; when neither happens within - the promised window the payment is refunded. After a conversion has waited 24 hours, anyone may execute it at the - unsubsidized Chainlink-bounded terms below; the guarantee applies to conversions - Vortex's keeper executes. - - Hard bound (not a fee): no conversion ever delivers less than the Chainlink - EUR/USD rate minus 0.6% after fee and subsidy, or it does not execute. When the - Coinbase reference sits below that bound, Vortex makes up the difference from its - own budget within the disclosed limits, so the client receives the bound rather - than the reference deal; when the difference exceeds those limits the conversion - waits. Enforced by the contract assuming an honest oracle; not a principal - guarantee under oracle failure or a stablecoin collapse beyond the bound. - - Each payment converts on its own, in chunks when it exceeds the per-conversion cap, - and reaches the payout address as a single transfer once every chunk is done; the - chunks' rates, fees and subsidies are reported per chunk. -5. **Processing SLA (B3 — decided: same business day).** Draft: - > A payment is converted and delivered within two hours of its arrival under normal - > market conditions, on weekends as well. A payment that cannot be converted within - > that window — a market move beyond the conversion bound, a liquidity or subsidy - > shortfall, or an operational fault — is not held: Vortex refunds the full EUR amount - > to the bank account it was sent from. Payments below the minimum are refunded the - > same way. The EUR/USD reference rate updates less frequently outside FX market - > hours (staleness ceiling 52 h), so weekend conversions may execute at a rate up to - > that age — always within the conversion bound. - - Include: the window is enforced on chain (funds cannot move to Vortex's refund wallet - before it elapses); keeper outages beyond 24 h open a permissionless execution path, - so conversion does not depend on Vortex; the refund is automated in a later phase and - operator-run until then (runbook §2.7); a refund reverses the fee (none is kept on a - refunded payment's delivered amount — chunk fees already taken are Vortex's cost). -6. **Vortex powers & custody disclosure (amended 2026-09-17).** What Vortex can do: - deploy the account, run the conversion, pause it, tune bounded parameters, adjust the - fee policy within the disclosed cap and timelock, choose the swap route among an - on-chain validated set, fund or limit its own subsidy budget, and — for a payment - the promised window was missed on, and only then — move that payment to the client's - refund wallet, which Vortex holds, in order to refund it from the client's IBAN. What - Vortex cannot do: redirect funds. The contract can pay only the client's payout - address, Vortex's fee treasury, and the client's fixed refund wallet, and it refuses a recovery before the window has - elapsed. Vortex holds custody of a client's funds only on that refund path; the - client has no key of their own and no unilateral exit — the partner accepts this - (written confirmation, G1). Should Vortex disappear, anyone may complete conversions - permissionlessly after 24 hours. Vortex cannot prevent inbound SEPA to an issued IBAN; - deposits during a pause accumulate safely as EURe until converted or refunded. - ## Open items ledger | Item | Owner | Status | |---|---|---| -| G1 package (9 items) | Marcel ↔ Monerium | All verbal; consolidate in writing | -| G2 legal scope | Counsel | Not started | | G3 audit | External | After PR merge; params final | -| Partner agreement (terms above) | Marcel ↔ partner | Drafting inputs ready | -| Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering (needs Marcel's sandbox login) | Open — only remaining engineering unknown | +| Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering | Open — only remaining engineering unknown | | Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | -| Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint (spot, since 2026-09-18) — confirm that is what was meant | | Subsidy ladder calibration | Ops ↔ product | Launch ladder in P14; retune from the `deferring conversion` shortfall lines and the vault spend after the first weeks; raise the vault's per-swap cap to the ladder's top before enabling | | Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 45 bps after the 2026-09-17 move to 60 bps: the twelve-month replay on spot (2026-09-18, ADR amendment 3) shows six minute-long blips a year at that margin outside the 2025-10 depeg weekend, so ordinary weekends do not refund; the depeg weekend (39.7 h out of the 100 bps band) does, by design | | Refund seed + float wallet | Ops | Generate `MONERIUM_B2B_REFUND_SEED` (32 random bytes; every client's refund wallet derives from it, and onboarding links each to its client's profile) and the EURe float key; fund the float with EURe and ETH (it pays the refund wallets' gas); both into the keeper's KMS. No Vortex company profile is needed | -| Refund automation | Ops | Implemented (`recovery.ts`): ship with `MONERIUM_B2B_AUTO_RECOVERY=alert`, observe one sandbox refund end to end, then `auto` with the float key set; refunds of EUR 15,000 or more stay manual until G1 settles the supporting-document question | -| Sandbox SEPA simulation: payer counterpart | Engineering (needs Marcel's sandbox login) | Capture one real issue-order webhook to confirm `counterpart.identifier.iban` / `details.name` arrive as the spec says (the refund target) | +| Refund automation | Ops | Implemented (`recovery.ts`): ship with `MONERIUM_B2B_AUTO_RECOVERY=alert`, observe one sandbox refund end to end, then `auto` with the float key set; refunds of EUR 15,000 or more stay manual (Monerium requires a supporting document from that amount) | +| Sandbox SEPA simulation: payer counterpart | Engineering | Capture one real issue-order webhook to confirm `counterpart.identifier.iban` / `details.name` arrive as the spec says (the refund target) | +| Memo-routed mint to a refund wallet | Engineering | Open: detect it and handle it as a refund | +| Monerium read-back | Engineering | Open: read the issued IBAN and the issue order's payer IBAN and name back from Monerium's API instead of relying on webhook payloads alone (the payer IBAN is the refund target); check in the sandbox whether listing the app's webhook subscriptions returns their secrets (Monerium's guide says so, the response schema has no secret field), since every holder of the app's credentials can list Vortex's subscription | | Subsidy vault funding and refill cadence | Ops | Before first activation; runbook §2.6 | | GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | From 98fce383d496aeff2f81985b8babe30982741323 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:16:57 +0200 Subject: [PATCH 10/34] docs(repo): remove the B2B flow overview Its technical content now lives in the architecture doc, the ADRs and the runbook. --- docs/README.md | 3 +- docs/product-monerium-b2b-flow.md | 696 ------------------------------ 2 files changed, 1 insertion(+), 698 deletions(-) delete mode 100644 docs/product-monerium-b2b-flow.md diff --git a/docs/README.md b/docs/README.md index 2ee8e30a8..8d4965ea1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,12 +29,11 @@ The smaller set of general project documents stays directly in `docs/`: | [`operations-brl-tax-id-claim-release.md`](operations-brl-tax-id-claim-release.md) | Operator runbook for releasing a BRL tax ID squatted through `createSubaccount` (RISK-026) | | [`operations-demo-environment.md`](operations-demo-environment.md) | Setup and runbook for the sandbox sales-demo account | | [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | -| [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md) | Launch gates, deploy checklist, and terms inputs for the B2B onramp pilot | +| [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md) | Deploy checklist, launch readiness, and open items ledger for the B2B onramp | | [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md) | Operator procedures for the B2B onramp: onboarding, incidents, alert triage, dormancy, migration | | [`operations-monerium-interface.md`](operations-monerium-interface.md) | Monerium onramp boundary across the OAuth and white-label apps plus the profile, address, IBAN, and payment interface reference | | [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | -| [`product-monerium-b2b-flow.md`](product-monerium-b2b-flow.md) | High-level end-to-end flow of the Monerium B2B onramp for Vortex, the partner and Monerium, with the open questions per party | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | | [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md) | Phase-2 proposal for the consumer (Safe + passkey) Monerium onramp; the B2B variant shipped | | [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md deleted file mode 100644 index 3d4085f67..000000000 --- a/docs/product-monerium-b2b-flow.md +++ /dev/null @@ -1,696 +0,0 @@ -# Monerium B2B Onramp: End-to-End Flow - -> **Status:** living overview, draft for alignment. Last updated 2026-10-06 with the -> destination endpoint, after Monerium's written answers and the call of 2026-09-30, and -> the KYB and sandbox decisions sent to the partner and Monerium on 2026-10-01. -> **Audience:** Vortex/SatoshiPay internally, the partner, and Monerium. -> **Scope:** the EUR to USDC onramp for the partner's business clients, as built for the -> pilot on the branch of PR #1375. It is not merged or deployed yet. Open questions carry -> an ID such as **[M2]** (Monerium), **[S1]** (partner) or **[V1]** (Vortex internal) -> and are collected in [section 12](#12-open-questions), with space for the answers. -> Changes proposed but not built yet are marked **Proposed**. Facts from Monerium's -> public API spec (version 2.0.0) and guides are marked as such where Monerium has not -> confirmed them in writing. - -## Contents - -1. [In one paragraph](#1-in-one-paragraph) -2. [Who is involved](#2-who-is-involved) -3. [The flow at a glance](#3-the-flow-at-a-glance) -4. [Client onboarding](#4-client-onboarding) -5. [Payment in: SEPA to EURe](#5-payment-in-sepa-to-eure) -6. [Conversion: EURe to USDC](#6-conversion-eure-to-usdc) -7. [Delivery: one USDC transfer per payment](#7-delivery-one-usdc-transfer-per-payment) -8. [Unhappy path: full EUR refund](#8-unhappy-path-full-eur-refund) -9. [Status, reporting and support](#9-status-reporting-and-support) -10. [What Vortex can and cannot do](#10-what-vortex-can-and-cannot-do) -11. [Key parameters](#11-key-parameters) -12. [Open questions](#12-open-questions) -13. [Related documents](#13-related-documents) - -## 1. In one paragraph - -A partner's business client sends EUR by SEPA to its own dedicated IBAN. Monerium -mints the same amount of EURe to a smart contract that Vortex deployed for that client, -the **forwarder**. Vortex converts the EURe to USDC on chain at a price tied to a public -market reference, and sends the whole payment to the client's wallet as **one USDC -transfer**. If a payment cannot be converted within **two hours**, Vortex refunds the -**full EUR amount** to the bank account it came from. The forwarder can only ever pay -three places: the client's fixed wallet, Vortex's fee treasury, and, for a refund after -the two-hour window, the client's own refund wallet, which Vortex holds. - -## 2. Who is involved - -| Party or component | Role | -|---|---| -| **Partner** | Partner. Brings the business clients, owns the white-label app at Monerium in which their profiles live, performs and submits their KYB under its reliance agreement with Monerium, receives status webhooks, and hands each client its IBAN. | -| **Client** | The business that sends EUR and receives USDC in its own wallet, the **destination**. | -| **Monerium** | Licensed EURe issuer. Hosts each client's profile and IBAN in the partner's white-label app, mints EURe for incoming SEPA payments, and pays out EUR on redemption. | -| **Vortex / SatoshiPay** | Operator. Deploys the contracts, runs the **keeper** service that converts and forwards, runs refunds, and reports status. Uses the partner's white-label app credentials for everything after KYB. | -| **Forwarder contract** | One per client on Ethereum. Receives the minted EURe, swaps it, holds the USDC until the payment is complete, then forwards it. | -| **Subsidy vault** | A Vortex-funded USDC pool that tops up a swap when the market delivers less than the client's guaranteed floor. | -| **Fee treasury** | Vortex multisig that receives the conversion fee. | -| **Refund wallet** | One Vortex-held wallet per client, fixed in the client's forwarder at deployment and linked to the client's Monerium profile. A payment whose window was missed moves there, and the refund is paid out from it through the client's own IBAN. | -| **Float wallet** | A Vortex wallet that covers round-trip losses so a refund is always the exact amount, and pays the refund wallets' gas. | -| **Price sources** | Coinbase Exchange EURC-USDC market for the reference rate, Chainlink EUR/USD as the on-chain safety bound, Uniswap v3 where the swaps execute. | - -## 3. The flow at a glance - -```mermaid -flowchart LR - subgraph Bank["Banking"] - CB["Client's bank account"] - end - subgraph Mon["Monerium"] - IBAN["Client IBAN
points at the forwarder"] - RED["Redemption
EUR payout"] - end - subgraph Eth["Ethereum"] - FWD["Forwarder contract
one per client"] - UNI["Uniswap v3
whitelisted route"] - VAULT["Subsidy vault"] - TREAS["Fee treasury"] - DEST["Client wallet
fixed destination"] - REC["Client refund wallet"] - end - subgraph Vx["Vortex"] - KEEP["Keeper service"] - end - P["Partner"] - - CB -- "1 SEPA EUR" --> IBAN - IBAN -- "2 mint EURe" --> FWD - KEEP -- "3 swap in chunks" --> FWD - FWD <-- "EURe to USDC" --> UNI - VAULT -. "top-up below floor" .-> FWD - FWD -. "fee above target" .-> TREAS - FWD -- "4 one USDC transfer" --> DEST - KEEP -- "status webhooks" --> P - FWD -. "not converted in 2 h" .-> REC - REC -. "refund EUR" .-> RED - RED -. "SEPA refund" .-> CB -``` - -Solid arrows are the happy path. Dotted arrows only happen when needed: a fee or -subsidy on a swap, or the refund path. - -## 4. Client onboarding - -### 4.1 What has to be true when onboarding ends - -- The client has a **KYB-approved corporate profile** in the partner's white-label app at - Monerium, submitted by the partner under its reliance agreement. -- A **forwarder contract** exists with the client's destination wallet written into it. - The destination cannot be changed later. A new wallet means a new forwarder and moving - the IBAN, on the partner's written instruction. -- The forwarder is **linked** to the client's Monerium profile, and the client's **IBAN - points at the forwarder**. Monerium mints to whatever address the IBAN points at, so - this is what routes every payment through the conversion. An IBAN pointing at the - client's own wallet would deliver EURe, not USDC. -- The client's **refund wallet** is linked to the client's profile as well, so a refund - leaves from the client's own IBAN (section 8.3). -- Vortex has mapped the client under **the partner's account**, so webhooks and - API reads reach the partner. - -### 4.2 Onboarding flow - -The partner owns the white-label app at Monerium and submits each client's KYB there. -Monerium returns the new profile's ID, and the partner passes it to Vortex together with -the client's destination wallet. Vortex does everything after KYB with the same app's -credentials. - -```mermaid -sequenceDiagram - autonumber - participant P as Partner - participant M as Monerium - participant V as Vortex - participant C as Ethereum - participant CL as Client - - P->>M: Create the client profile in the partner's app - M-->>P: Profile ID - P->>M: Company details, form and verifications - P->>V: Destination wallet for this profile ID [V6] - V->>M: Check the profile exists in the partner's app - M-->>V: profile.updated, state approved - V->>C: Deploy forwarder with destination and fee policy - V->>M: Link forwarder and refund wallet to the profile - V->>M: Request the IBAN for the forwarder - M-->>V: iban.updated, IBAN issued - V->>M: Read the IBAN back from the API [V7] - V->>P: Client active, IBAN readable through the API [S4, V4, V12] - P->>CL: Hand over IBAN, client starts sending EUR -``` - -Notes on the flow: - -- **The partner's app, shared credentials.** Monerium's reliance agreement is with - the partner, so the client profiles live in a white-label app in the partner's Monerium - account. Only the app that onboarded a profile can read it or act on it, so Vortex - uses the same app's client ID and secret. Monerium's guides describe one credential - pair per app **[M2]**. Each partner gets its own white-label app, so a new partner - means a new app and a new credential pair for Vortex **[V8]**. -- **Division of work.** The partner creates profiles and submits KYB. Vortex links - addresses, requests the IBAN, places refunds, and registers its own webhook - subscription. The partner must never link addresses, request or move IBANs, place - orders, or change Vortex's webhook subscription. Closing a profile also closes its - IBAN, so the partner coordinates closures with Vortex **[S2]**. -- **KYB.** Companies must use Monerium's reliance route: the partner delivers company - details, form, verifications and files to Monerium directly. The partner calls - Monerium's KYB and profile endpoints itself; SatoshiPay does not proxy them (decided - 2026-10-01). Approval takes seconds when the data follows - Monerium's corporate KYB guide. Vortex never handles KYB data, which matches the - security spec. Reading a profile returns only the company name, so KYB details stay - with the partner. -- **Destination handover by profile ID.** Creating a profile returns its ID. - The partner then calls `POST /v1/monerium-b2b/accounts` with that ID, the destination, - its own client reference and a contact email **[V6]**. Vortex checks the profile exists - in the partner's app, stores the destination, and deploys the forwarder once the profile - is approved. The destination is create-only, because it is fixed in the contract; a - change means a new account on the partner's written instruction. Vortex rejects - malformed and zero addresses, and the forwarder refuses token addresses, which marks - the registration rejected. Exchange deposit addresses are accepted like any wallet; - the agreement covers the risk that they rotate. Only the partner's manager key can - register. A registration that is only waiting (Monerium has not approved the profile, - a transient failure on Vortex's side) shows why, and one that cannot proceed is marked - rejected with the reason; the partner may register a rejected profile again, with the - same or corrected data. -- **Why the destination does not go through Monerium.** Monerium's profile API has no - field for it, linking an address needs a signature from its owner, which exchange - deposit addresses cannot give, and only the forwarder contract uses the destination. -- **IBAN order.** Monerium issues an IBAN only for an address already linked to the - profile, so the forwarder is linked first. A profile has one IBAN, which can be moved - to another linked address. -- **Webhooks.** Vortex registers its own subscription on the partner's app; Monerium - lists an app's subscriptions together. Monerium's guide says that list includes each - subscription's secret, while the response schema has no secret field. Either way, - Vortex reads the IBAN and the payer's IBAN back from Monerium's API instead of relying - on webhook payloads alone **[V7]**. -- **Fee.** Monerium charges €10 per corporate account under its agreement **[M3, S10]**. - -### 4.3 What is built today - -- The partner registers the client's destination by Monerium profile ID **[V6]**. Once - Monerium approves the profile, the keeper deploys the forwarder, maps the client, - links the forwarder and the refund wallet, and requests the IBAN **[V1]**. The IBAN is - recorded when Monerium confirms it. -- An operator then activates the account after checking the destination; only in the - sandbox does it activate once the IBAN is recorded. Payments convert only once the - account is active: one that arrives earlier waits, `DEPOSIT_UPDATED` shows it as - `account_not_active`, and it is refunded after two hours, so the partner hands a - client its IBAN once the account is active. The operator path (deploy, then - one admin call) remains for corrections. -- The partner can read the account and its IBAN through the Vortex API with its manager - key. There is no dashboard view **[V3]**. -- The backend that runs the B2B module uses the partner's app credentials and is bound to - the partner's manager key by configuration; separate credentials per partner app follow - with the second partner **[V8]**. Reading the IBAN and the payer's IBAN back from - Monerium's API is still open **[V7]**. -- The keeper already links the client's refund wallet next to the forwarder, built on - 2026-10-01. - -### 4.4 Decisions behind onboarding - -- **One forwarder per client.** Each client gets its own contract, created as a cheap - clone of one shared, verified implementation. A published manifest lets anyone check - every deployed forwarder against it. -- **Fixed destination.** The destination is set at deployment and has no setter, so no - key, including Vortex's, can redirect a client's USDC. -- **Contract-signed link.** A contract cannot sign like a wallet. The forwarder - therefore proves ownership to Monerium by accepting a signature from a Vortex attestor - key, but only over Monerium's exact link message. That key has no power over the - contract's funds. - -### 4.5 Sandbox phase before production credentials - -Monerium releases the production credentials for the partner's white-label app once it -sees test data in the partner's sandbox app: approved test client profiles onboarded -through the partner's KYB integration, IBANs issued, and test payments processed (agreed -on the call of 2026-09-30). Monerium's sandbox runs on Ethereum Sepolia. - -The partner's steps **[S11]**: - -1. Create a white-label app in its Monerium sandbox account at sandbox.monerium.dev, - share the app's client ID and secret with Vortex, and onboard one or two test client - profiles through its KYB integration. -2. Get a test API key from dashboard-sandbox.vortexfinance.co, for the API at - api-sandbox.vortexfinance.co, once Vortex has set the partner's profile up as the - manager of its clients. Register the webhook endpoint with that key through - `POST /v1/webhook`, subscribing to `DEPOSIT_UPDATED` and `ACCOUNT_UPDATED`; the - dashboard has no webhook screen. -3. For each test profile, register the Monerium profile ID and a Sepolia destination - wallet that the partner controls with `POST /v1/monerium-b2b/accounts`. - -Vortex then deploys the forwarder, links it and the client's refund wallet, and requests -the IBAN; in the sandbox the account activates once the IBAN is issued. In a joint session the parties run three test payments: a normal one, a large -one that converts in several chunks, and one that is refunded. The sandbox refunds after -15 minutes instead of two hours, so the refund test fits into the session. - -## 5. Payment in: SEPA to EURe - -```mermaid -sequenceDiagram - participant B as Client's bank - participant M as Monerium - participant F as Forwarder - participant V as Vortex keeper - participant P as Partner - - B->>M: SEPA transfer to the client's IBAN - M-->>V: order.created webhook, EUR arrived - Note over M: Compliance review possible, or rejection before minting - M->>F: Mint EURe to the forwarder - M-->>V: order.updated webhook, order processed - V->>F: Watcher confirms the mint on chain - V->>V: Deposit minted, 2-hour clock starts - V-->>P: DEPOSIT_RECEIVED webhook -``` - -- Vortex listens on **two channels**. Monerium's webhooks carry the order details: - amount, order ID, and the payer's IBAN and name. Vortex's own chain - watcher proves the EURe actually arrived on the forwarder. A deposit becomes eligible - for conversion only when both agree. -- Monerium confirmed that `order.created` arrives when the payment hits the IBAN and - `order.updated` when the order changes state. While an order is **pending**, it is - either minting or under compliance review, and Monerium does not tell the two apart. - Reviews happen during business hours and add time before the two-hour window starts. -- The payer's **IBAN and name are stored** from the order. They are the target of any - refund. -- The **two-hour window starts at the mint**, not at the SEPA transfer, because Vortex - cannot act before the EURe exists. -- Monerium's monitoring may **hold** a payment for review during office hours. If it - needs documents, Monerium contacts the payer directly. If it cannot mint the payment, - it returns the funds to the payer, with a reason on the order. None of this reaches - the forwarder. -- **Third-party payers** are allowed. Monerium watches transaction patterns to make - sure accounts are not misused. -- **Memo routing.** A payer can write a chain and address into the SEPA memo. If that - address is linked to the client's profile, Monerium mints there instead of to the - forwarder. Monerium's guide describes only that case, which implies an unlinked - address is ignored and the payment mints to the forwarder as usual. The only other - address linked to a client profile is the client's refund wallet, which Vortex - controls. Clients are unlikely to use this, so it stays enabled, and a mint to a - refund wallet is handled by operations **[V9]**. - -## 6. Conversion: EURe to USDC - -### 6.1 Chunks - -A payment larger than the per-swap cap of **€10,000** is converted in several chunks, a -few minutes apart. The chunks' USDC waits on the forwarder until the whole payment is -converted. Two payments are never mixed in one swap. A single client's payments are -converted one after the other, while different clients' payments convert in parallel. - -### 6.2 Price - -- **Reference rate:** the midpoint between the best bid and the best ask on the Coinbase - Exchange EURC-USDC market, read immediately before each chunk and recorded with it. A - chunk waits while that market's spread is wider than 0.5%. -- **Client target:** the reference minus 0.125%. Whatever the market delivers above the - target is Vortex's fee, capped at 1% on chain. -- **Client floor:** the reference minus 0.15%. If the market delivers less, Vortex tops - the chunk up from the subsidy vault, within limits. -- **Safety bound:** no chunk ever delivers less than the Chainlink EUR/USD rate minus - 0.6%, or it does not execute. If the Coinbase reference sits below that bound, the - client gets the bound, and Vortex pays the difference within its subsidy limits. -- The reference must also be within 1% of Chainlink, so a bad price feed cannot push a - swap far off market. - -### 6.3 Subsidy ladder - -How much Vortex is willing to top up grows with how long a chunk has waited for the -market. Waiting time counts per chunk, from when it became ready to swap. - -| Waiting time | Maximum top-up | -|---|---| -| 0 to 6 min | none | -| 6 to 8 min | 0.10% | -| 8 to 10 min | 0.20% | -| 10 to 12 min | 0.30% | -| 12 to 14 min | 0.40% | -| 14 to 16 min | 0.50% | -| from 16 min | 1.00% | - -The ladder is a keeper setting that Vortex can retune without touching the contracts. -The contract enforces the cap the keeper passes with each swap, and the vault enforces a -per-swap cap and a daily budget on top. - -### 6.4 Per-chunk decision - -```mermaid -flowchart TD - A["Chunk ready to swap"] --> B{"Coinbase spread
at most 0.5%?"} - B -- no --> W["Wait for the next cycle"] - B -- yes --> C{"Reference within 1%
of Chainlink?"} - C -- no --> W - C -- yes --> D["Quote whitelisted routes,
pick the best"] - D --> E{"Fill at or above
the client floor?"} - E -- yes --> X["Swap. Surplus above target
goes to the fee treasury"] - E -- no --> F{"Shortfall within the ladder
and the vault budget?"} - F -- yes --> Y["Swap. Vault tops up
to the floor"] - F -- no --> W - W --> G{"2 hours since the mint?"} - G -- no --> A - G -- yes --> R["Refund path, section 8"] -``` - -## 7. Delivery: one USDC transfer per payment - -- Once every chunk of a payment is converted, the keeper sends the whole USDC amount to - the client's destination in **one transfer**. -- After the transfer is 32 blocks deep, the partner receives **DEPOSIT_CONVERTED**. It - carries each chunk's reference rate, fee and subsidy, and the transaction hash of the - final transfer. -- **Without Vortex:** if the keeper stops for 24 hours, anyone can convert at the - Chainlink price without subsidy and forward the forwarder's USDC to the destination. A - client's funds never depend on Vortex staying online. -- **Dormancy:** after 60 days without a conversion, forwarding pauses until the - destination is re-confirmed in writing. - -## 8. Unhappy path: full EUR refund - -### 8.1 When a payment is refunded - -- It was **not converted within two hours** of the mint. Typical causes are a market - move beyond the bounds, thin liquidity, an exhausted subsidy budget, or an operational - fault. -- An operator triggers it after a **compliance decision or an incident**. - -A payment is never partly delivered. If any part cannot be converted in time, the whole -payment is refunded, including chunks that were already converted. - -### 8.2 How a refund runs - -```mermaid -sequenceDiagram - participant V as Vortex keeper - participant F as Forwarder - participant R as Client's refund wallet - participant U as Uniswap - participant FL as Float wallet - participant M as Monerium - participant B as Payer's bank - participant P as Partner - - Note over F: 2 hours passed, payment not fully converted - V->>F: recover the payment's EURe and USDC - F->>R: EURe and USDC, contract refuses before 2 hours - R->>U: Swap USDC back to EURe - FL->>R: Top up to the exact original amount - R->>M: Redeem order to the payer's IBAN - M->>B: SEPA payout from the client's IBAN - M-->>V: order.updated, redemption processed - V-->>P: DEPOSIT_RETURNED and DEPOSIT_UPDATED -``` - -- The payer gets back the **exact EUR amount**. Losses from the round trip and fees - already taken on converted chunks are Vortex's cost, paid from the float wallet. -- The contract **enforces the two-hour window**. The forwarder cannot move funds to the - refund wallet any earlier, and it can only move them to that one wallet. -- The refund leaves from the **client's own IBAN**, in the client's name, with a - reference to the original payment. -- SEPA Instant is used when the payer's bank supports it, otherwise next business day. -- Monerium requires a **supporting document** on redemptions above €15,000 and accepts - the same agreement every time, so one standing document per client can be uploaded - once and reused. Until the refund automation attaches it, refunds of €15,000 or more - stay manual **[V10]**. -- Monerium sets **no limits and charges no fees** on refunds. A refund may be reviewed - by Monerium during business hours before it is sent **[M4]**. -- Refunds run one at a time per client, and a refund that waits for Vortex operations - blocks only that client's later refunds. Clients share one float wallet, so a float - transfer that is slow to confirm can delay another client's refund until Vortex - operations resolve it. Refunds - survive a crash of the keeper midway. A refund that - fails its retries goes to **recovery failed** and is handed to Vortex operations. -- Rollout: the automation first runs in **alert mode**, where Vortex operators confirm - each refund. It switches to **automatic** after the first refund has been observed end - to end. - -### 8.3 Refund from the client's own IBAN - -A Monerium profile can have several linked addresses, any linked address can use the -profile's IBAN for outgoing payments, and an address belongs to exactly one profile. The -refund path builds on that, built on 2026-10-01: - -- Each client gets its own **refund wallet**: a plain Vortex-held wallet whose key is - derived from one Vortex secret and the client's Monerium profile ID. One secret covers - every client, and the wallet is known before the forwarder is deployed. -- The wallet is fixed in the client's forwarder at deployment as the only address a - recovery can pay, with no way to change it. Vortex refuses to map a forwarder whose - refund wallet is not the client's derived one. -- Onboarding links the wallet to the client's Monerium profile next to the forwarder. -- On a refund the forwarder sends the stuck EURe and USDC straight to that wallet, which - swaps back, receives the float's top-up, and places the redeem order. Monerium pays it - out of the client's own IBAN. -- The wallet pays gas for its own transactions; the float tops its ETH up before it - sends. -- No Vortex company profile at Monerium is needed, and different clients' refunds never - share a wallet. -- The client authorizes Vortex to send these refunds in the partner terms. - -Status: agreed with Monerium on 2026-09-30 for the pilot. Later, each client may instead -name a fixed refund IBAN at onboarding, so every refund follows the same path **[V13]**. -A refund contract that only pays that fixed IBAN would then replace the plain wallet, -which means a new forwarder for existing clients. - -Alternatives considered: - -- **One Vortex recovery wallet on a Vortex/SatoshiPay company profile.** The previous - design. The payer would see SatoshiPay as the sender, and one company profile would - pay many unrelated payers. -- **Redeem signed by the forwarder contract.** The refund would also leave from the - client's IBAN, but it needs an allowlist of payer IBANs per client and a larger - contract change. - -## 9. Status, reporting and support - -### 9.1 Deposit status - -```mermaid -stateDiagram-v2 - direction LR - [*] --> pending: Monerium order - pending --> returned: Monerium rejects it - pending --> minted: EURe on the forwarder - minted --> converting: first chunk swapped - converting --> forwarded: one USDC transfer - minted --> recovering: refund path - converting --> recovering - recovering --> refunded: EUR back to payer - recovering --> recovery_failed - recovery_failed --> recovering: operator retry - recovery_failed --> refunded: refunded by hand - forwarded --> [*] - returned --> [*] - refunded --> [*] -``` - -Statuses only move forward. A late or repeated webhook can never move a deposit back. -Monerium does not report a separate compliance-review state, so a payment under review -shows as pending. The `held` status in the API is therefore never set and will be -removed **[V11]**. - -### 9.2 What the partner receives - -| Event | When | Key content | -|---|---|---| -| `DEPOSIT_UPDATED` | Any change of a deposit | The full deposit snapshot: IDs, amounts, timestamps, waiting and refund reasons, every conversion chunk | -| `ACCOUNT_UPDATED` | Any change of an account, including the IBAN being issued | The account snapshot: IBAN, status, IDs, destination, fee policy | -| `DEPOSIT_RECEIVED` | EURe minted to the client's forwarder | Milestone: deposit ID, account, amount, mint transaction | -| `DEPOSIT_CONVERTED` | The USDC transfer is 32 blocks deep | Milestone: per chunk reference rate, fee and subsidy, and the forward transaction | -| `DEPOSIT_RETURNED` | The refund was processed by Monerium | Milestone: refunded amount, masked payer IBAN, redemption ID, recovery transaction | - -| API call | Returns | -|---|---| -| Accounts, all clients of the manager | Each client's account with IBAN and status, filterable by Monerium profile ID | -| Registrations, all of the manager | Each destination registration: waiting with what it waits for, mapped to its account, or rejected with the reason | -| Account, per client | IBAN, status, IDs, destination, forwarder address, fee policy | -| Deposits, per client | Every deposit as its full snapshot, the same shape as `DEPOSIT_UPDATED` | - -- The accounts call uses the partner's manager API key alone. The per-client calls add a - header naming the client's Vortex profile ID, or use a key issued to the client. -- Webhooks are signed. The partner verifies each one against Vortex's published public - key and deduplicates on the event ID. -- **Fallback:** the deposits call returns the current snapshot of every deposit, for - polling if a webhook is missed. -- **Reference IDs** in every snapshot: deposit, account, Vortex profile, Monerium - profile, Monerium order, the partner's client reference, and every transaction hash. -- Amounts come as a EUR decimal and in base units: 18 decimals for EUR and EURe, 6 for - USDC. - -### 9.3 What the partner asked for - -The partner's requirements of 2026-09-30, built on 2026-10-01: - -- The API and webhooks expose the **full lifecycle**, from deposit through conversion to - delivery, including IDs, amounts, timestamps, and hold or failure status. -- **API-first:** the partner's frontend fetches each sub-account's IBAN from the Vortex - backend. - -How each stage is reported: - -| Stage | Status | Reported through | -|---|---|---| -| Payment arrived at Monerium, not minted yet | pending | `DEPOSIT_UPDATED` with waiting reason `monerium_pending`. Monerium does not tell minting apart from a compliance review | -| Returned by Monerium before minting | returned | `DEPOSIT_UPDATED` with Monerium's rejection reason | -| EURe minted to the forwarder | minted | `DEPOSIT_UPDATED` with the mint time and transaction, plus `DEPOSIT_RECEIVED` | -| Conversion chunk sent and confirmed | converting | `DEPOSIT_UPDATED` with each chunk's pricing, transaction, and sent and confirmed times | -| Conversion held because the account cannot convert | minted or converting | `DEPOSIT_UPDATED` with waiting reason `account_not_active` and start time: the account is not activated yet, suspended or paused. Cleared once it can convert | -| Conversion waiting on the market | minted or converting | `DEPOSIT_UPDATED` with waiting reason and start time: `below_floor`, `reference_unavailable`, `reference_out_of_band`, `oracle_unavailable` or `no_route` | -| Delivered as one USDC transfer | forwarded | `DEPOSIT_UPDATED` with delivery time and transaction once 32 blocks deep, plus `DEPOSIT_CONVERTED` | -| Refund started | recovering | `DEPOSIT_UPDATED` with refund reason and start time: `window_missed`, `compliance`, `incident` or `operator` | -| Refunded | refunded | `DEPOSIT_UPDATED` with refund time, plus `DEPOSIT_RETURNED` | -| Refund needs an operator | recovery failed | `DEPOSIT_UPDATED` | -| Account set up, IBAN issued, status changed | | `ACCOUNT_UPDATED`, and the accounts call | - -Not included yet: the incoming payment's SEPA reference and the payer's name. Add them -when the partner needs them. - -### 9.4 Exceptions and escalation - -- Vortex monitors stuck payments, refunds due, failed refunds, the subsidy budget, the - float balance, IBAN changes at Monerium, and the Coinbase market status. -- Operators can pause conversion, force a refund, or correct a deposit's status through - admin endpoints. The runbook covers each case. -- A joint Slack channel with Monerium is the agreed channel for payment questions. - Named owners and the escalation path between Vortex, the partner and Monerium are - still to be agreed **[V5]**. - -## 10. What Vortex can and cannot do - -**Vortex can:** - -- Deploy forwarders, run conversions, and pause them. -- Choose the swap route from an on-chain whitelist. -- Change a client's fee policy within the 1% cap. Raising it takes effect only after a - 24-hour on-chain notice. Lowering it is immediate. -- Fund or limit its own subsidy budget. -- Move a payment to the client's refund wallet, only after the two-hour window, to - refund it from the client's own IBAN. - -**Vortex cannot:** - -- Redirect funds. A forwarder pays only the client's fixed destination, the fee - treasury, and, after two hours, the client's own refund wallet. -- Deliver a conversion below the Chainlink rate minus 0.6%. -- Stop a client's conversion permanently. After 24 hours anyone can complete it. -- Prevent incoming SEPA payments. Payments made during a pause wait safely as EURe and - are converted or refunded later. - -## 11. Key parameters - -| Parameter | Value | Changeable | -|---|---|---| -| Refund window | 2 hours from the mint | Fixed in the contract | -| Permissionless fallback | 24 hours | Fixed in the contract | -| Client target and floor | Reference minus 0.125% and minus 0.15% | Per client, increases need 24 h notice | -| Fee cap | 1% | Fixed in the contract | -| Safety bound against Chainlink | 0.6% | Fixed in the contract | -| Reference band against Chainlink | 1% | Fixed in the contract | -| Maximum Coinbase spread | 0.5% | Keeper code | -| Subsidy ladder | none for 6 min, rising to 1% from 16 min | Keeper setting | -| Chunk size | up to €10,000 | Operational, ceiling €50,000 | -| Minimum swap | €1 | Operational, can be raised but never below €1 | -| Manual refunds | €15,000 and above | Until the refund automation attaches the standing agreement [V10] | -| Monerium account fee | €10 per corporate account | Monerium agreement | -| Dormancy pause | 60 days without a conversion | Keeper code | -| Pilot volume | 3 to 5 clients, €50,000 per client per day | Contractual | - -## 12. Open questions - -### 12.1 Assumptions (2026-09-30) - -- Refunds run through per-client refund wallets. A fixed refund IBAN per client may - replace the dynamic payer IBAN later **[V13]**. -- The partner delivers KYB directly to its own white-label app and calls Monerium's KYB - endpoints itself; SatoshiPay does not proxy them. Vortex never handles KYB data. -- No partner client has an existing Monerium profile. -- Monerium does not need to know or screen the client's final wallet. -- Memo routing stays enabled, because clients are unlikely to use it. -- Testing runs in the partner's Monerium sandbox app on Sepolia before Monerium releases - the production credentials (section 4.5). - -### 12.2 Answered by Monerium (2026-09-30) - -| Topic | Answer | -|---|---| -| Whose white-label app | A dedicated app in the partner's Monerium account, under the partner's reliance agreement. Vortex uses that app's client ID and secret. | -| Onboarding steps | Confirmed: create profile, submit details, form and verifications, wait for `profile.updated` approved, link the forwarder and the refund wallet, request the IBAN. | -| KYB route and speed | Corporates use the reliance endpoints. Approval takes seconds when the data follows Monerium's guidelines. | -| Profile visibility | Only the credentials of the app that onboarded a profile can read it. | -| Readable profile data | Only the bare minimum, such as the name. Full KYB details are not returned. | -| Payment notifications | `order.created` when the payment hits the IBAN, `order.updated` on state changes. Pending covers both minting and compliance review. | -| IBAN | Only for an address already linked to the profile. It can be moved to another linked address. | -| Batching | None. Each client is its own request. | -| Refund wallet | One address per profile, so one refund wallet per client. A redeem from it leaves from the profile's IBAN. | -| Supporting document above €15,000 | The same agreement can be reused every time. | -| Refund limits and fees | None. Some refunds are reviewed during business hours. | -| Account fee | €10 per corporate account, per the agreement. | -| Partner apps | Each partner gets its own white-label app. The partner delivers KYB data and files directly; Vortex could proxy those calls later as tech provider. Decided 2026-10-01: no proxy, the partner calls the KYB endpoints itself. | -| Production credentials | Released after Monerium sees test data in the partner's sandbox app: approved test profiles onboarded through the partner's KYB integration, IBANs issued, test payments processed (section 4.5). | -| Held and rejected payments | Monerium's monitoring holds payments for review during office hours, contacts the payer directly if it needs documents, and returns the funds if it cannot mint them. | -| Third-party payers | Allowed. Monerium watches transaction patterns so accounts are not misused. | -| Refund wallet approach | Agreed for the pilot. A fixed refund IBAN per client may replace it later. | -| Communication | A joint Slack channel with Monerium is to be set up. | -| The partner's onboarding at Monerium | Documents in review on 2026-09-30, onboarding starting 2026-10-01. | - -### 12.3 Monerium - -| ID | Question | Why it matters | Status | Answer | -|---|---|---|---|---| -| M2 | Can the partner's app have separate credentials for Vortex and the partner, or can linking addresses, requesting or moving IBANs, and managing webhooks be restricted to Vortex? If not, is there an audit log or notification for those actions? | Whoever holds the app credentials can redirect future mints | Partly answered 2026-09-30 | One app per partner. Separate credentials within one app not confirmed. | -| M3 | Is the €10 per corporate account billed to the partner or to Vortex, and is it one-off or recurring? | Commercial planning | Renegotiation ongoing | A discount for onboarding all partner clients is being negotiated. | -| M4 | What triggers a review on a refund, and can refunds to the original payer be cleared in advance? | Refund timing promise | Open | | -| M7 | Written confirmation of the items agreed verbally so far: the redemption-limitation disclosure, the issuer recovery backstop, SEPA recall and fraud loss allocation, per-IBAN suspension, and advance notice of changes to the link message. | Launch gate | Open | | - -### 12.4 Partner - -| ID | Question or item to agree | Why it matters | Status | Answer | -|---|---|---|---|---| -| S1 | How many clients, and when? | Planning, and when the destination endpoint is needed | Open | | -| S2 | Share the white-label app's production credentials with Vortex, and agree the usage rules in section 4.2: no address links, IBAN requests or moves, orders, or changes to Vortex's webhook subscription, and profile closures coordinated with Vortex. | Protects where client payments are minted | Production credentials follow the sandbox sign-off, section 4.5 | | -| S3 | Hand over each destination through the Vortex endpoint by Monerium profile ID. Who at the partner approves a destination? | The destination is fixed in the contract | Endpoint built 2026-10-06; approver at the partner open | | -| S4 | How does the partner get each client's IBAN? | API and dashboard scope | Answered 2026-09-30 | API-first: the partner's frontend fetches the IBAN from the Vortex API, section 9.3. | -| S5 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target. Monerium allows third-party payers | Open | | -| S6 | Are destinations self-custody wallets or exchange deposit addresses? | Exchange addresses need an attestation that they do not rotate and accept contract transfers | Open | | -| S7 | Does the event model in section 9.3 cover the lifecycle requirement? Webhook endpoint and support contacts. | Status reporting | Built 2026-10-01, to confirm with the partner | The partner wants the full lifecycle with IDs, amounts, timestamps, and hold and failure status. | -| S8 | Is a two-hour window before a full refund right? Will clients authorize Vortex to refund from their IBAN? A Monerium review can delay a refund within business hours. | Refund terms in the agreement | Open | | -| S9 | The agreement names a "Coinbase EURC oracle". The implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint. Is that what was meant? | Pricing terms | Open | | -| S10 | Who bears Monerium's €10 per corporate account? | Commercial | Open | | -| S11 | Sandbox phase, section 4.5: create a white-label app in the Monerium sandbox and share its client ID and secret, onboard one or two test profiles through the KYB integration, get a test API key from dashboard-sandbox.vortexfinance.co and register the webhook endpoint, and send the Monerium profile ID and a Sepolia destination wallet per test profile. | Monerium releases production credentials after seeing this test data | Requested 2026-10-01 | | - -### 12.5 Vortex internal - -| ID | Question or task | Depends on | Status | -|---|---|---|---| -| V1 | Start onboarding once the profile is approved and the destination is registered, whichever comes last. | V6, V8 | Built 2026-10-06 | -| V2 | Refunds through per-client refund wallets: derived from one seed, fixed in each forwarder as its recovery address, linked at onboarding. | None | Built 2026-10-01 | -| V3 | Dashboard view for the partner with clients, IBANs, deposits and refunds. Optional, since the partner integrates API-first. | S4 | Deprioritized | -| V4 | Full lifecycle deposit events, section 9.3: snapshot event on every change, IDs, amounts, timestamps, hold and failure reasons, an account event, and the docs fix. | S7 | Built 2026-10-01 | -| V5 | Named owners per alert, and the escalation path between Vortex, the partner and Monerium, including the joint Slack channel with Monerium. | Meeting | Open | -| V6 | Endpoint for the partner to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Built 2026-10-06 | -| V7 | Read the IBAN and the payer's IBAN back from Monerium's API instead of trusting webhook payloads, and check in the sandbox whether listing subscriptions exposes their secrets. | None | Open | -| V8 | Separate Monerium credentials for the B2B module, apart from the retail onramp, with one app and credential pair per partner. | S2 | Partly: the B2B backend runs on the partner's app, bound to its manager key (2026-10-06); per-partner credentials with the second partner | -| V9 | Detect a mint to a refund wallet routed by payment memo, and handle it as a refund. | V2 | Open | -| V10 | Attach the standing agreement to refunds above €15,000 so they can run automatically. | V2 | Open | -| V11 | Remove the unused `held` status. | None | Open | -| V13 | Later: a fixed refund IBAN per client, given at onboarding, with a refund contract that only pays that IBAN. | V2 | Later | -| V12 | Partner account API: list all sub-accounts with IBAN and status, filterable by Monerium profile ID. | S4 | Built 2026-10-01 | - -## 13. Related documents - -For Vortex readers who need the detail behind this overview: - -- [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md): the - complete technical architecture. -- [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md): decisions, final - parameters and accepted risks. -- [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md): launch - gates, deploy checklist and agreement inputs. -- [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md): operator - procedures for onboarding, refunds and incidents. -- [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md): - security invariants. -- [`api/pages/07-webhooks.md`](api/pages/07-webhooks.md): the partner webhook format and - signature check. From 01e2f46ab2f40e0350ae72ea959cbd93a4378d48 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:22:41 +0200 Subject: [PATCH 11/34] docs(api): document webhook registration as 201 created --- docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/api/openapi/vortex.openapi.json | 2 +- docs/api/pages/07-webhooks.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 43ba08d55..75ebd8b44 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -1961,7 +1961,7 @@ export interface paths { }; }; responses: { - 200: { + 201: { headers: { [name: string]: unknown; }; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 524a53171..f5d00be7b 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -11619,7 +11619,7 @@ } }, "responses": { - "200": { + "201": { "content": { "application/json": { "example": { diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index bec865e65..6908ff705 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -39,7 +39,7 @@ Content-Type: application/json For the transaction events, the body must include **exactly one** of `quoteId` or `sessionId`. Use `sessionId` to subscribe to events from a Widget-hosted ramp instead of a partner-created quote. Omitting `events` subscribes to the two transaction events only — deposit events are never a default. -Store the returned webhook ID so you can delete it later. +Vortex responds `201 Created` with the webhook resource. Store its `id` so you can delete the webhook later. ```http DELETE /v1/webhook/{id} From ba85721a0cda3e622043ab6735fd7f3c40c9ac02 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:22:54 +0200 Subject: [PATCH 12/34] docs(api): accept deposit events in the webhook listener example --- docs/api/pages/07-webhooks.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 6908ff705..53407f280 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -321,6 +321,8 @@ Fetch the current public key: GET /v1/public-key ``` +Each environment signs with its own key, so fetch the key from the environment that delivers the webhook: `https://api-sandbox.vortexfinance.co/v1/public-key` in sandbox and `https://api.vortexfinance.co/v1/public-key` in production. A sandbox delivery fails verification against the production key. + Verify signatures using RSA-PSS with SHA-256 over the string `{timestamp}.{body}` — the `X-Vortex-Timestamp` header value, a literal dot, then the raw request body. Reject requests that fail signature verification, are outside an acceptable timestamp window, contain malformed payloads, or do not match the expected event structure, and deduplicate on `eventId`. ### Example: Bun + TypeScript Listener @@ -331,12 +333,19 @@ import crypto, { KeyObject } from "crypto"; const CONFIG = { PORT: Number(process.env.PORT || 3002), + // The environment your webhook is registered with: https://api-sandbox.vortexfinance.co in sandbox. + VORTEX_API_BASE_URL: process.env.VORTEX_API_BASE_URL || "https://api.vortexfinance.co", TIMESTAMP_TOLERANCE_SECONDS: 300 } as const; enum WebhookEventType { TRANSACTION_CREATED = "TRANSACTION_CREATED", - STATUS_CHANGE = "STATUS_CHANGE" + STATUS_CHANGE = "STATUS_CHANGE", + DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", + DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", + DEPOSIT_RETURNED = "DEPOSIT_RETURNED", + DEPOSIT_UPDATED = "DEPOSIT_UPDATED", + ACCOUNT_UPDATED = "ACCOUNT_UPDATED" } class WebhookVerifier { @@ -346,7 +355,7 @@ class WebhookVerifier { private async getPublicKey(): Promise { if (this.publicKey) return this.publicKey; if (!this.publicKeyPem) { - const response = await fetch("https://api.vortexfinance.co/v1/public-key"); + const response = await fetch(`${CONFIG.VORTEX_API_BASE_URL}/v1/public-key`); if (!response.ok) throw new Error(`Failed to fetch public key: ${response.statusText}`); const data = (await response.json()) as { publicKey: string }; this.publicKeyPem = data.publicKey; From 8e688387d0a8cac1c774b9cad2882701bc629fd8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:23:16 +0200 Subject: [PATCH 13/34] docs(api): list the b2b routes in the managed-profile delegation rules --- docs/api/pages/03-authentication-and-partner-keys.md | 4 +++- docs/api/pages/14-managed-profiles.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/api/pages/03-authentication-and-partner-keys.md b/docs/api/pages/03-authentication-and-partner-keys.md index c23cf3721..d7d646bd4 100644 --- a/docs/api/pages/03-authentication-and-partner-keys.md +++ b/docs/api/pages/03-authentication-and-partner-keys.md @@ -66,7 +66,7 @@ X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002 A Supabase Bearer session may replace the secret key. A public `pk_*` value cannot authenticate delegation. Vortex verifies the active manager, direct active child relationship, child's single active customer entity, allowed country, optional customer-type narrowing, and canonical country/type support for corridor-bound mutations. An omitted or null customer-type policy adds no restriction beyond the canonical corridor capability matrix; a configured non-empty list only narrows that matrix. The manager remains the authenticated actor; ownership, KYC/provider lookup, and ramp history resolve from the child subject. Quote pricing uses the child's active profile assignment when present, otherwise the controlling manager profile's active assignment, then default Vortex pricing. This precedence is identical for manager-delegated requests and direct child credentials. -The header is supported for quote creation; ramp registration, update, start, status, history, and errors; exact limits and sanitized ramp info; aggregate onboarding status; BR customer/KYC operations; customer creation, KYC/KYB, and fiat-account operations on the AR, CO, MX, and US corridors; and sender-side recipient operations. Sender-side recipient operations are invite creation, recipient and pending-invitation listing, invitation archive/unarchive, recipient relationship updates, and recipient eligibility reads. These recipient operations currently require a Supabase Bearer session; an `sk_*` key does not authorize them. Invite preview and acceptance remain invitee-scoped and do not support `X-Managed-Profile-Id`; a headless managed child cannot authenticate as an invitee or accept an invitation. Corridor removal blocks mutations and disallowed exact-limit requests but not quote discovery or historical/status reads. EUR onboarding and user-to-corridor provisioning are not delegated; a non-technical managed child can register the direct-API EUR BUY flow only when its approved provider binding, Polygon EOA, and IBAN were provisioned out of band. +The header is supported for quote creation; ramp registration, update, start, status, history, and errors; exact limits and sanitized ramp info; aggregate onboarding status; BR customer/KYC operations; customer creation, KYC/KYB, and fiat-account operations on the AR, CO, MX, and US corridors; the business EUR onramp account and deposit reads (`GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits`, for business children on the `EU` corridor); and sender-side recipient operations. Sender-side recipient operations are invite creation, recipient and pending-invitation listing, invitation archive/unarchive, recipient relationship updates, and recipient eligibility reads. These recipient operations currently require a Supabase Bearer session; an `sk_*` key does not authorize them. Invite preview and acceptance remain invitee-scoped and do not support `X-Managed-Profile-Id`; a headless managed child cannot authenticate as an invitee or accept an invitation. Corridor removal blocks mutations and disallowed exact-limit requests but not quote discovery or historical/status reads. EUR onboarding and user-to-corridor provisioning are not delegated; a non-technical managed child can register the direct-API EUR BUY flow only when its approved provider binding, Polygon EOA, and IBAN were provisioned out of band. `POST /v1/brl/kyc/import-token` is a deliberate exception to direct child credential access. A controlling manager may call it with the manager's secret key or Supabase session plus `X-Managed-Profile-Id`, but a credential owned by the managed child is rejected with `403 MANAGED_PROFILE_ACCESS_DENIED`, even without the selector. Direct non-managed profiles may import for themselves with their own secret key or session. Public keys and ownerless credentials cannot import. The legacy `/v1/brla/kyc/import-token` path remains an equivalent migration alias. @@ -74,6 +74,8 @@ Authentication, direct-child rejection, and managed authorization run before str Webhook registration and deletion do not support managed children. `X-Managed-Profile-Id` returns `400 MANAGED_PROFILE_UNSUPPORTED`, and a direct child credential returns `403 MANAGED_PROFILE_ACCESS_DENIED`. Managed-child integrations must poll the child-scoped ramp status/history endpoints. A manager credential without the selector remains manager-owned and therefore cannot register a webhook for a child-owned quote. +The manager-level business EUR routes (`GET` and `POST /v1/monerium-b2b/accounts`, `GET /v1/monerium-b2b/registrations`) act across all of the manager's children and accept only the manager's own credential: `X-Managed-Profile-Id` returns `400 MANAGED_PROFILE_UNSUPPORTED`, and a direct child credential returns `403 MANAGED_PROFILE_ACCESS_DENIED`. + `X-Managed-Profile-Id` is only a selector. Supplying another manager's child, an inactive/deleted child, a child with an invalid entity layout, or a disallowed mutation corridor returns `403 MANAGED_PROFILE_ACCESS_DENIED`. ### Manage Headless Profiles diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 9afae3614..896e0bd93 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -195,7 +195,7 @@ An account converts payments only once it is `active`, and its IBAN can be issue | Response | Meaning | |---|---| | `403 MANAGED_PROFILE_ACCESS_DENIED` | The selector or child credential failed a check: inactive manager, not your child, deleted child, invalid entity layout, or a corridor/type your policy does not allow. | -| `400 MANAGED_PROFILE_UNSUPPORTED` | The endpoint does not support delegation (currently webhook management). | +| `400 MANAGED_PROFILE_UNSUPPORTED` | The endpoint does not support delegation: webhook management, the manager-level business EUR routes (`GET` and `POST /v1/monerium-b2b/accounts`, `GET /v1/monerium-b2b/registrations`), and invite preview and acceptance. | | `404` on lifecycle routes | The `profileId` does not identify a child of the authenticated manager. | | `200` instead of `201` on create | Idempotent retry — the identical child already exists. | | `409 CREDENTIAL_LIMIT_REACHED` | The child already has five active, non-expired credentials. | From 2c86f160c37a731dcce20be684a5f6910e57a9be Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:23:24 +0200 Subject: [PATCH 14/34] docs(api): show a new b2b registration with an unchecked waitingReason --- docs/api/pages/14-managed-profiles.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 896e0bd93..1b77f318f 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -160,12 +160,12 @@ Content-Type: application/json "moneriumProfileId": "0b8e4d1c-6f3a-4c27-9a51-2d7e8b9c0a14", "rejectedReason": null, "status": "requested", - "waitingReason": "monerium_profile_pending" + "waitingReason": null } } ``` -Vortex waits for the provider to approve the profile, deploys the client's conversion contract with the destination fixed in it, then creates the child and its onramp account, which appears in `GET /v1/monerium-b2b/accounts` and the `ACCOUNT_UPDATED` webhook. Follow the registration with `GET /v1/monerium-b2b/registrations` (filter by `moneriumProfileId`). `status` is `requested` until the account exists (`mapped`, with `accountId`) or the registration is `rejected` (see `rejectedReason`). While it is `requested`, `waitingReason` says what it waits for: +Vortex waits for the provider to approve the profile, deploys the client's conversion contract with the destination fixed in it, then creates the child and its onramp account, which appears in `GET /v1/monerium-b2b/accounts` and the `ACCOUNT_UPDATED` webhook. Follow the registration with `GET /v1/monerium-b2b/registrations` (filter by `moneriumProfileId`). `status` is `requested` until the account exists (`mapped`, with `accountId`) or the registration is `rejected` (see `rejectedReason`). While it is `requested`, `waitingReason` says what it waits for. `null` means Vortex has not checked the registration yet, as in the response to a new or repeated registration; mapped and rejected registrations also report `null`. | `waitingReason` | Meaning | |---|---| From b2f551ba2894be2604e1c144bc03a49479cd908d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:23:25 +0200 Subject: [PATCH 15/34] docs(api): add a sandbox getting-started for the business eur onramp --- docs/api/pages/14-managed-profiles.md | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 1b77f318f..891b59f51 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -133,6 +133,76 @@ Two things behave differently for managed children: - **Pricing** is resolved as: the child's own partner-pricing assignment if one exists, otherwise **your (the manager's) active assignment**, otherwise default Vortex pricing — identically for header-delegated calls and direct child credentials. Children automatically inherit your negotiated fees. - **Transaction webhooks are not supported for managed subjects** — registration returns `400 MANAGED_PROFILE_UNSUPPORTED` with the header and `403` with a child credential. Poll the child-scoped ramp status and history endpoints instead. The exception is the deposit-event family for EUR onramp accounts: the **manager** subscribes with their own credential (no header) and receives the lifecycle events `DEPOSIT_UPDATED`/`ACCOUNT_UPDATED` and the milestones `DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED`/`DEPOSIT_RETURNED` for all their children's accounts — see the Webhooks page. +## Get Started With The Business EUR Onramp In Sandbox + +The business EUR onramp is open for testing in sandbox as a preliminary release; production activation is pending. A business client pays EUR by SEPA to its own IBAN, and Vortex converts the payment to USDC and sends it to the client's destination address. Every call below goes to `https://api-sandbox.vortexfinance.co` with your sandbox secret key. + +**Set up with Vortex** + +1. Sign up at with your email, open **API keys**, and create a credential. Keep the secret key (`sk_test_...`) on your backend. +2. Email the address you signed up with. The dashboard does not show your profile ID; Vortex looks it up from the email. +3. Vortex enables your profile as a manager for the `EU` corridor and business customers, and sends you the EUR provider's sandbox profile IDs of the test companies you can register. During this pilot those companies live in Vortex's own provider app, and the provider approves each profile in its sandbox before Vortex deploys anything for it. + +**Integrate** + +1. Subscribe to the deposit and account events with your manager key and no `X-Managed-Profile-Id` header. The response is `201` with the webhook `id`. + +```http +POST /v1/webhook +X-API-Key: sk_test_... +Content-Type: application/json + +{ + "url": "https://manager.example.com/vortex/deposits", + "events": ["DEPOSIT_UPDATED", "ACCOUNT_UPDATED"] +} +``` + +2. Register each test company with its provider profile ID and the client's destination address. A new registration returns `202` with `status: "requested"`; see [Register A Business EUR Client](#register-a-business-eur-client) for every response. + +```http +POST /v1/monerium-b2b/accounts +X-API-Key: sk_test_... +Content-Type: application/json + +{ + "moneriumProfileId": "", + "destination": "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc", + "externalSubjectId": "client-1", + "contactEmail": "operations@client.example" +} +``` + +3. Follow the registration until `status` is `mapped`. It reports `waitingReason: "monerium_profile_pending"` until the provider approves the profile, then `deployment_pending` while Vortex deploys the client's conversion contract. + +```http +GET /v1/monerium-b2b/registrations?moneriumProfileId= +X-API-Key: sk_test_... +``` + +4. Wait for an `ACCOUNT_UPDATED` event with `status: "active"` and an `iban`. In sandbox, an account activates on its own once its IBAN is issued. The event's `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. Pay at least EUR 1 to the client's IBAN from a sandbox account at the EUR provider. `DEPOSIT_UPDATED` events follow the payment until the deposit is `forwarded` to the destination. +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. Before the account exists, both return `404 MONERIUM_B2B_ACCOUNT_NOT_FOUND`. + +```http +GET /v1/monerium-b2b/account +X-API-Key: sk_test_... +X-Managed-Profile-Id: +``` + +```http +GET /v1/monerium-b2b/deposits?limit=20&offset=0 +X-API-Key: sk_test_... +X-Managed-Profile-Id: +``` + +**Sandbox specifics** + +- **Network.** Sandbox converts and delivers on Ethereum Sepolia; production uses Ethereum mainnet. The destination must be an Ethereum address the client controls, and it is fixed for the life of the account. +- **Minimum.** Payments from EUR 1 are converted. +- **Refund window.** A payment that is not converted within 15 minutes of its mint (two hours by default in production), for example because the account was not active yet, is refunded in full to the account it came from. The EUR provider pays refunds out only for profiles it has approved. +- **Webhook signatures.** Sandbox signs deliveries with its own key: verify them against `https://api-sandbox.vortexfinance.co/v1/public-key`, not the production key. See [Webhooks](https://api-docs.vortexfinance.co/webhooks). + ## Register A Business EUR Client You register business EUR clients yourself. Create the client's profile and submit its KYB in your own EUR provider app first; no KYB data goes to Vortex. Then register the client's payout wallet with your manager key (`X-API-Key`; the `X-Managed-Profile-Id` header, a child credential, and an admin impersonation session are refused, and only the manager bound to your provider app may register): From 70a25a46e02165aec80489e989b24285e4e5002b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:29:27 +0200 Subject: [PATCH 16/34] docs(api): explain when a b2b registration reports no waiting reason --- docs/api/openapi/vortex.openapi.d.ts | 2 +- docs/api/openapi/vortex.openapi.json | 2 +- docs/api/pages/14-managed-profiles.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 75ebd8b44..8c7081310 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -3091,7 +3091,7 @@ export interface components { */ status: "requested" | "mapped" | "rejected"; /** - * @description What a `requested` registration waits for; null in every other status. `monerium_profile_pending`: the provider has not approved the profile yet (created, incomplete, pending or in review); finish its KYB in your provider app. `monerium_profile_not_visible`: the provider no longer shows the profile to your app; check the profile ID. `deployment_pending`: Vortex is deploying the client's conversion contract or waiting for it to confirm. `deployer_not_ready`: Vortex's deployment wallet lacks its role or gas; Vortex fixes it, nothing to do on your side. `manager_inactive`: your manager profile is not active for business EUR clients; contact Vortex. `temporary_error`: a temporary failure at Vortex or the provider, retried automatically. + * @description What a `requested` registration waits for; null while Vortex has not checked it yet (as in the 202 response) and in every other status. `monerium_profile_pending`: the provider has not approved the profile yet (created, incomplete, pending or in review); finish its KYB in your provider app. `monerium_profile_not_visible`: the provider no longer shows the profile to your app; check the profile ID. `deployment_pending`: Vortex is deploying the client's conversion contract or waiting for it to confirm. `deployer_not_ready`: Vortex's deployment wallet lacks its role or gas; Vortex fixes it, nothing to do on your side. `manager_inactive`: your manager profile is not active for business EUR clients; contact Vortex. `temporary_error`: a temporary failure at Vortex or the provider, retried automatically. * @enum {string|null} */ waitingReason: "monerium_profile_pending" | "monerium_profile_not_visible" | "deployment_pending" | "deployer_not_ready" | "manager_inactive" | "temporary_error" | null; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index f5d00be7b..456311b7b 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2898,7 +2898,7 @@ "type": "string" }, "waitingReason": { - "description": "What a `requested` registration waits for; null in every other status. `monerium_profile_pending`: the provider has not approved the profile yet (created, incomplete, pending or in review); finish its KYB in your provider app. `monerium_profile_not_visible`: the provider no longer shows the profile to your app; check the profile ID. `deployment_pending`: Vortex is deploying the client's conversion contract or waiting for it to confirm. `deployer_not_ready`: Vortex's deployment wallet lacks its role or gas; Vortex fixes it, nothing to do on your side. `manager_inactive`: your manager profile is not active for business EUR clients; contact Vortex. `temporary_error`: a temporary failure at Vortex or the provider, retried automatically.", + "description": "What a `requested` registration waits for; null while Vortex has not checked it yet (as in the 202 response) and in every other status. `monerium_profile_pending`: the provider has not approved the profile yet (created, incomplete, pending or in review); finish its KYB in your provider app. `monerium_profile_not_visible`: the provider no longer shows the profile to your app; check the profile ID. `deployment_pending`: Vortex is deploying the client's conversion contract or waiting for it to confirm. `deployer_not_ready`: Vortex's deployment wallet lacks its role or gas; Vortex fixes it, nothing to do on your side. `manager_inactive`: your manager profile is not active for business EUR clients; contact Vortex. `temporary_error`: a temporary failure at Vortex or the provider, retried automatically.", "enum": [ "monerium_profile_pending", "monerium_profile_not_visible", diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 891b59f51..bc3c35826 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -173,7 +173,7 @@ Content-Type: application/json } ``` -3. Follow the registration until `status` is `mapped`. It reports `waitingReason: "monerium_profile_pending"` until the provider approves the profile, then `deployment_pending` while Vortex deploys the client's conversion contract. +3. Follow the registration until `status` is `mapped`. It reports `waitingReason: null` until Vortex first checks it, `monerium_profile_pending` until the provider approves the profile, then `deployment_pending` while Vortex deploys the client's conversion contract. During this pilot the provider approves the test profiles, not you: if a registration stays at `monerium_profile_pending`, or `POST` returns `422 MONERIUM_B2B_PROFILE_UNAVAILABLE`, check the profile ID against the one Vortex sent and contact Vortex. ```http GET /v1/monerium-b2b/registrations?moneriumProfileId= @@ -235,7 +235,7 @@ Content-Type: application/json } ``` -Vortex waits for the provider to approve the profile, deploys the client's conversion contract with the destination fixed in it, then creates the child and its onramp account, which appears in `GET /v1/monerium-b2b/accounts` and the `ACCOUNT_UPDATED` webhook. Follow the registration with `GET /v1/monerium-b2b/registrations` (filter by `moneriumProfileId`). `status` is `requested` until the account exists (`mapped`, with `accountId`) or the registration is `rejected` (see `rejectedReason`). While it is `requested`, `waitingReason` says what it waits for. `null` means Vortex has not checked the registration yet, as in the response to a new or repeated registration; mapped and rejected registrations also report `null`. +Vortex waits for the provider to approve the profile, deploys the client's conversion contract with the destination fixed in it, then creates the child and its onramp account, which appears in `GET /v1/monerium-b2b/accounts` and the `ACCOUNT_UPDATED` webhook. Follow the registration with `GET /v1/monerium-b2b/registrations` (filter by `moneriumProfileId`). `status` is `requested` until the account exists (`mapped`, with `accountId`) or the registration is `rejected` (see `rejectedReason`). While it is `requested`, `waitingReason` says what it waits for. `null` means Vortex has not checked the registration yet, as in the `202` response to a new registration or to a new attempt after a rejection (an identical replay returns `200` with the current reason); mapped and rejected registrations also report `null`. | `waitingReason` | Meaning | |---|---| From 44dde0ec58a7342148143cb5462f74ecc936ca2b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:29:40 +0200 Subject: [PATCH 17/34] docs(api): clarify sandbox binding, test payments and refunds --- docs/api/pages/14-managed-profiles.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index bc3c35826..4d985746d 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -141,7 +141,7 @@ The business EUR onramp is open for testing in sandbox as a preliminary release; 1. Sign up at with your email, open **API keys**, and create a credential. Keep the secret key (`sk_test_...`) on your backend. 2. Email the address you signed up with. The dashboard does not show your profile ID; Vortex looks it up from the email. -3. Vortex enables your profile as a manager for the `EU` corridor and business customers, and sends you the EUR provider's sandbox profile IDs of the test companies you can register. During this pilot those companies live in Vortex's own provider app, and the provider approves each profile in its sandbox before Vortex deploys anything for it. +3. Vortex enables your profile as a manager for the `EU` corridor and business customers, binds it to the EUR provider app as the manager that registers clients (until then `POST /v1/monerium-b2b/accounts` returns `403 MANAGED_PROFILE_ACCESS_DENIED`), and sends you the EUR provider's sandbox profile IDs of the test companies you can register. During this pilot those companies live in Vortex's own provider app, and the provider approves each profile in its sandbox before Vortex deploys anything for it. **Integrate** @@ -181,7 +181,7 @@ X-API-Key: sk_test_... ``` 4. Wait for an `ACCOUNT_UPDATED` event with `status: "active"` and an `iban`. In sandbox, an account activates on its own once its IBAN is issued. The event's `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. Pay at least EUR 1 to the client's IBAN from a sandbox account at the EUR provider. `DEPOSIT_UPDATED` events follow the payment until the deposit is `forwarded` to the destination. +5. Ask Vortex to send a test payment of at least EUR 1 to the client's IBAN. During this pilot, 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. 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. Before the account exists, both return `404 MONERIUM_B2B_ACCOUNT_NOT_FOUND`. ```http @@ -199,8 +199,8 @@ X-Managed-Profile-Id: **Sandbox specifics** - **Network.** Sandbox converts and delivers on Ethereum Sepolia; production uses Ethereum mainnet. The destination must be an Ethereum address the client controls, and it is fixed for the life of the account. -- **Minimum.** Payments from EUR 1 are converted. -- **Refund window.** A payment that is not converted within 15 minutes of its mint (two hours by default in production), for example because the account was not active yet, is refunded in full to the account it came from. The EUR provider pays refunds out only for profiles it has approved. +- **Minimum.** Payments from EUR 1 are converted; a smaller payment is refunded after the refund window. +- **Refund window.** A payment that is not converted within 15 minutes of its mint (two hours by default in production), for example because the account was not active yet, is refunded in full to the account it came from (operations process the refund by hand where automatic refunds are off). The EUR provider pays refunds out only for profiles it has approved. - **Webhook signatures.** Sandbox signs deliveries with its own key: verify them against `https://api-sandbox.vortexfinance.co/v1/public-key`, not the production key. See [Webhooks](https://api-docs.vortexfinance.co/webhooks). ## Register A Business EUR Client From 06b1e862ee1580cabb61f67501ef75f8f09e5718 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:29:41 +0200 Subject: [PATCH 18/34] docs(api): list active-entity among routes that refuse delegation --- 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 4d985746d..b0be15515 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -265,7 +265,7 @@ An account converts payments only once it is `active`, and its IBAN can be issue | Response | Meaning | |---|---| | `403 MANAGED_PROFILE_ACCESS_DENIED` | The selector or child credential failed a check: inactive manager, not your child, deleted child, invalid entity layout, or a corridor/type your policy does not allow. | -| `400 MANAGED_PROFILE_UNSUPPORTED` | The endpoint does not support delegation: webhook management, the manager-level business EUR routes (`GET` and `POST /v1/monerium-b2b/accounts`, `GET /v1/monerium-b2b/registrations`), and invite preview and acceptance. | +| `400 MANAGED_PROFILE_UNSUPPORTED` | The endpoint does not support delegation: webhook management, the manager-level business EUR routes (`GET` and `POST /v1/monerium-b2b/accounts`, `GET /v1/monerium-b2b/registrations`), invite preview and acceptance, and `PUT /v1/onboarding/active-entity`. | | `404` on lifecycle routes | The `profileId` does not identify a child of the authenticated manager. | | `200` instead of `201` on create | Idempotent retry — the identical child already exists. | | `409 CREDENTIAL_LIMIT_REACHED` | The child already has five active, non-expired credentials. | From bf4061c6ac672ea27dbf83b985915d79166491eb Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:35:41 +0200 Subject: [PATCH 19/34] docs(api): fix b2b sandbox walkthrough examples and event fields --- docs/api/pages/14-managed-profiles.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index b0be15515..87c768e82 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -158,7 +158,7 @@ Content-Type: application/json } ``` -2. Register each test company with its provider profile ID and the client's destination address. A new registration returns `202` with `status: "requested"`; see [Register A Business EUR Client](#register-a-business-eur-client) for every response. +2. Register each test company with its provider profile ID and the client's destination address. A new registration returns `202` with `status: "requested"`; see [Register A Business EUR Client](#register-a-business-eur-client) for every response. The destination cannot be changed after registration, and each test profile can be registered once, so use an address you control. Use a different `externalSubjectId` and `contactEmail` for each company; reusing either for another profile returns `409 MONERIUM_B2B_CLIENT_CONFLICT`. ```http POST /v1/monerium-b2b/accounts @@ -167,33 +167,33 @@ Content-Type: application/json { "moneriumProfileId": "", - "destination": "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc", + "destination": "", "externalSubjectId": "client-1", - "contactEmail": "operations@client.example" + "contactEmail": "operations@client.example.com" } ``` -3. Follow the registration until `status` is `mapped`. It reports `waitingReason: null` until Vortex first checks it, `monerium_profile_pending` until the provider approves the profile, then `deployment_pending` while Vortex deploys the client's conversion contract. During this pilot the provider approves the test profiles, not you: if a registration stays at `monerium_profile_pending`, or `POST` returns `422 MONERIUM_B2B_PROFILE_UNAVAILABLE`, check the profile ID against the one Vortex sent and contact Vortex. +3. Follow the registration (`registrations[0].status` in the response) until it is `mapped`. It reports `waitingReason: null` until Vortex first checks it, `monerium_profile_pending` until the provider approves the profile, then `deployment_pending` while Vortex deploys the client's conversion contract. During this pilot the provider approves the test profiles, not you: if a registration stays at `monerium_profile_pending`, or `POST` returns `422 MONERIUM_B2B_PROFILE_UNAVAILABLE`, check the profile ID against the one Vortex sent and contact Vortex. ```http GET /v1/monerium-b2b/registrations?moneriumProfileId= X-API-Key: sk_test_... ``` -4. Wait for an `ACCOUNT_UPDATED` event with `status: "active"` and an `iban`. In sandbox, an account activates on its own once its IBAN is issued. The event's `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. During this pilot, 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. -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. Before the account exists, both return `404 MONERIUM_B2B_ACCOUNT_NOT_FOUND`. +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. During this pilot, 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. +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 GET /v1/monerium-b2b/account X-API-Key: sk_test_... -X-Managed-Profile-Id: +X-Managed-Profile-Id: ``` ```http GET /v1/monerium-b2b/deposits?limit=20&offset=0 X-API-Key: sk_test_... -X-Managed-Profile-Id: +X-Managed-Profile-Id: ``` **Sandbox specifics** @@ -216,7 +216,7 @@ Content-Type: application/json "moneriumProfileId": "0b8e4d1c-6f3a-4c27-9a51-2d7e8b9c0a14", "destination": "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc", "externalSubjectId": "client-1", - "contactEmail": "operations@client.example" + "contactEmail": "operations@client.example.com" } ``` From 3f83e0355c3207347d98bab8dea079c446d435c9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:35:42 +0200 Subject: [PATCH 20/34] docs(api): point the sandbox page to the b2b getting-started --- docs/api/pages/10-sandbox.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/pages/10-sandbox.md b/docs/api/pages/10-sandbox.md index 9fb5f2474..944ec77a8 100644 --- a/docs/api/pages/10-sandbox.md +++ b/docs/api/pages/10-sandbox.md @@ -23,7 +23,7 @@ The sandbox is self-service. You do not need credentials from Vortex to start. Contact for the parts that Vortex enables: - **Browser origins.** Server-to-server calls work immediately. Browser calls are refused by CORS until your exact origins are approved for sandbox. See [Browser Origin Approval](https://api-docs.vortexfinance.co/authentication-and-partner-keys). -- **Partner features.** Acting for managed customer profiles and business EUR onramp accounts are set up by Vortex. See [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles). +- **Partner features.** Acting for managed customer profiles and business EUR onramp accounts are set up by Vortex. See [Managed Profiles](https://api-docs.vortexfinance.co/managed-profiles). For the business EUR onramp, start with "Get Started With The Business EUR Onramp In Sandbox" on the Managed Profiles page. --- From 421ff2b2a0cab9de570b56de19de6734ab94e3e4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 8 Oct 2026 19:35:42 +0200 Subject: [PATCH 21/34] docs(api): use a valid email domain in the managed child example --- docs/api/pages/14-managed-profiles.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 87c768e82..b591cfe44 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -29,7 +29,7 @@ Content-Type: application/json { "externalSubjectId": "customer-4711", "customerType": "individual", - "contactEmail": "customer-4711@platform.example" + "contactEmail": "customer-4711@platform.example.com" } ``` @@ -43,7 +43,7 @@ Content-Type: application/json "profileId": "00000000-0000-0000-0000-000000000002", "externalSubjectId": "customer-4711", "customerType": "individual", - "contactEmail": "customer-4711@platform.example", + "contactEmail": "customer-4711@platform.example.com", "status": "active", "creationSource": "manager", "deletedAt": null, From 175b6dbeea7e249636098cc8f3766349534a5d8c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 09:36:34 +0200 Subject: [PATCH 22/34] test(api): keep client-side signing tests off public RPCs The BRL and EUR Base cross-chain transaction tests call the real signUnsignedTransactions, whose viem wallet clients ask viem's default public RPC for eth_chainId before signing locally. A public Base RPC outage (HTTP 521) therefore failed an unrelated PR. A fake-world helper now answers eth_chainId for those default RPCs and passes every other request through. --- ...ramp-base-cross-chain.transactions.test.ts | 7 ++-- ...ramp-base-cross-chain.transactions.test.ts | 7 ++-- .../api/src/test-utils/fake-world/fake-evm.ts | 36 ++++++++++++++++++- apps/api/src/test-utils/fake-world/index.ts | 4 +-- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts index abb8c1b6a..f02564086 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts @@ -15,6 +15,7 @@ import { privateKeyToAccount } from "viem/accounts"; import * as evmFundingNamespace from "../core/evm-funding"; import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import { installFakeChainIdRpc } from "../../../../../test-utils/fake-world/fake-evm"; import Big from "big.js"; import { decodeFunctionData, erc20Abi, parseTransaction } from "viem"; import type { FlowMetadata } from "../core/metadata"; @@ -310,13 +311,15 @@ describe("BRL onramp Base cross-chain transactions", () => { type: EphemeralAccountType.EVM }; - const presignedTxs = await signUnsignedTransactions(blocks.unsignedTxs, { evmEphemeral }); + // viem asks the RPC for eth_chainId before signing; any other network use hits the fetch guard. + const restoreChainIdRpc = installFakeChainIdRpc(); + const presignedTxs = await signUnsignedTransactions(blocks.unsignedTxs, { evmEphemeral }).finally(restoreChainIdRpc); expect(presignedTxs.length).toBeGreaterThanOrEqual(blocks.unsignedTxs.length); expect(presignedTxs.every(tx => typeof tx.txData === "string" && tx.txData.startsWith("0x"))).toBe(true); const destinationTransfer = presignedTxs.find(tx => tx.phase === "destinationTransfer"); expect(destinationTransfer).toBeDefined(); expect(parseTransaction(destinationTransfer?.txData as `0x${string}`).maxFeePerGas).toBe(3_000_000_000n); - }, 60_000); + }); it("preserves 18-decimal BSC USDT precision in the destination transfer", async () => { const amountRaw = "17500000000000000000"; diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.transactions.test.ts index d2b4cef3f..9d8efaf0e 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.transactions.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.transactions.test.ts @@ -14,6 +14,7 @@ import { privateKeyToAccount } from "viem/accounts"; import * as evmFundingNamespace from "../core/evm-funding"; import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; +import { installFakeChainIdRpc } from "../../../../../test-utils/fake-world/fake-evm"; const sharedReal = { ...sharedNamespace }; const evmFundingReal = { ...evmFundingNamespace }; @@ -185,7 +186,9 @@ describe("EUR onramp Base cross-chain transactions", () => { }); const evmEphemeral = { address: EPHEMERAL, secret: PRIVATE_KEY, type: EphemeralAccountType.EVM }; - const signed = await signUnsignedTransactions(prepared.unsignedTxs, { evmEphemeral }); + // viem asks the RPC for eth_chainId before signing; any other network use hits the fetch guard. + const restoreChainIdRpc = installFakeChainIdRpc(); + const signed = await signUnsignedTransactions(prepared.unsignedTxs, { evmEphemeral }).finally(restoreChainIdRpc); expect(signed.length).toBeGreaterThanOrEqual(prepared.unsignedTxs.length); expect(prepared.stateMeta.phaseFlow).toEqual([ "initial", @@ -236,5 +239,5 @@ describe("EUR onramp Base cross-chain transactions", () => { expect(prepared.unsignedTxs.find(tx => tx.phase === "nablaApprove")?.txData).toMatchObject({ data: "0xc1" }); expect(prepared.unsignedTxs.find(tx => tx.phase === "squidRouterSwap")?.txData).toMatchObject({ data: "0xa2" }); expect(prepared.unsignedTxs.find(tx => tx.phase === "backupSquidRouterSwap")?.txData).toMatchObject({ data: "0xb2" }); - }, 60_000); + }); }); diff --git a/apps/api/src/test-utils/fake-world/fake-evm.ts b/apps/api/src/test-utils/fake-world/fake-evm.ts index c65b1568b..1e103d396 100644 --- a/apps/api/src/test-utils/fake-world/fake-evm.ts +++ b/apps/api/src/test-utils/fake-world/fake-evm.ts @@ -1,5 +1,6 @@ import { EvmClientManager, type EvmNetworks } from "@vortexfi/shared"; -import { keccak256 } from "viem"; +import { keccak256, toHex } from "viem"; +import { arbitrum, avalanche, base, baseSepolia, bsc, mainnet, polygon, polygonAmoy } from "viem/chains"; export interface RecordedEvmTx { network: string; @@ -35,6 +36,14 @@ const CHAIN_IDS: Record = { const MAX_UINT256 = 2n ** 256n - 1n; +// viem's default public RPC (what a bare http() transport targets, URL-normalized like viem does) → chain id. +const DEFAULT_RPC_CHAIN_IDS = new Map( + [arbitrum, avalanche, base, baseSepolia, bsc, mainnet, polygon, polygonAmoy].map(chain => [ + new URL(chain.rpcUrls.default.http[0]).href, + chain.id + ]) +); + /** * In-memory EVM world standing in for EvmClientManager. Balances are a simple * ledger keyed by network/token/holder; transactions are recorded, assigned a @@ -311,6 +320,31 @@ export class FakeEvm { } } +/** + * Client-side signing (signUnsignedTransactions, also used by the SDK) builds its own viem wallet + * clients outside the EvmClientManager seam, and viem's signTransaction asks the RPC for + * eth_chainId before signing locally. Answers exactly that call for viem's default public RPCs; + * every other request falls through to the fetch guard. Returns the restore function. + */ +export function installFakeChainIdRpc(): () => void { + const guardedFetch = globalThis.fetch; + const fake = (async (input: Parameters[0], init?: Parameters[1]) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const chainId = DEFAULT_RPC_CHAIN_IDS.get(url); + if (chainId !== undefined && typeof init?.body === "string") { + const payload = JSON.parse(init.body) as { id?: number; method?: string }; + if (payload.method === "eth_chainId") { + return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: toHex(chainId) }); + } + } + return guardedFetch(input, init); + }) as typeof fetch; + globalThis.fetch = Object.assign(fake, guardedFetch); + return () => { + globalThis.fetch = guardedFetch; + }; +} + export function installFakeEvm(): { fakeEvm: FakeEvm; restore: () => void } { const original = EvmClientManager.getInstance; const fakeEvm = new FakeEvm(); diff --git a/apps/api/src/test-utils/fake-world/index.ts b/apps/api/src/test-utils/fake-world/index.ts index 2a0f1289d..076796c86 100644 --- a/apps/api/src/test-utils/fake-world/index.ts +++ b/apps/api/src/test-utils/fake-world/index.ts @@ -1,14 +1,14 @@ import { ApiManager } from "@vortexfi/shared"; import { type FakeAlfredpay, type FakeBrla, type FakeMykobo, installFakeAnchors } from "./fake-anchors"; import { installBackgroundWorkTracking } from "./fake-background-work"; -import { type FakeEvm, installFakeEvm } from "./fake-evm"; +import { type FakeEvm, installFakeChainIdRpc, installFakeEvm } from "./fake-evm"; import { type FakeMonerium, installFakeMonerium } from "./fake-monerium"; import { type FakePrices, installFakePrices } from "./fake-prices"; import { type FakeSquidRouter, installFakeSquidRouter } from "./fake-squidrouter"; import { installFetchGuard, uninstallFetchGuard } from "./fetch-guard"; export type { FakeAlfredpay, FakeBrla, FakeEvm, FakeMonerium, FakeMykobo, FakePrices, FakeSquidRouter }; -export { installFetchGuard, uninstallFetchGuard }; +export { installFakeChainIdRpc, installFetchGuard, uninstallFetchGuard }; export interface FakeWorld { evm: FakeEvm; From f8103409c494d559f63243e0bd7ff7d04709bd58 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 09:36:34 +0200 Subject: [PATCH 23/34] test(api): install the fetch guard for every hermetic test file The guard already covers viem's http transport, which goes through global fetch, but only fake-world tests installed it, so unit tests could reach real chains unnoticed. The preload now owns the guard; installFakeWorld no longer uninstalls a guard it did not install. --- .../test-utils/fake-world/fetch-guard.test.ts | 23 +++++++++++++++++++ .../src/test-utils/fake-world/fetch-guard.ts | 11 +++++---- apps/api/src/test-utils/fake-world/index.ts | 6 ++--- apps/api/src/test-utils/preload.ts | 4 ++++ apps/api/src/tests/aaa-leak-probe.test.ts | 4 ++-- 5 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/test-utils/fake-world/fetch-guard.test.ts diff --git a/apps/api/src/test-utils/fake-world/fetch-guard.test.ts b/apps/api/src/test-utils/fake-world/fetch-guard.test.ts new file mode 100644 index 000000000..4024d8563 --- /dev/null +++ b/apps/api/src/test-utils/fake-world/fetch-guard.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "bun:test"; +import { createPublicClient, http } from "viem"; +import { base } from "viem/chains"; +import { installFakeChainIdRpc } from "./fake-evm"; + +// No installFakeWorld here: the preload alone must keep viem RPC traffic off the network. +describe("fetch guard", () => { + const client = createPublicClient({ chain: base, transport: http(undefined, { retryCount: 0 }) }); + + it("rejects viem RPC calls to a real chain", async () => { + await expect(client.getChainId()).rejects.toThrow(/Hermetic test violation/); + }); + + it("lets the fake answer eth_chainId and nothing else", async () => { + const restore = installFakeChainIdRpc(); + try { + expect(await client.getChainId()).toBe(base.id); + await expect(client.getBlockNumber()).rejects.toThrow(/Hermetic test violation/); + } finally { + restore(); + } + }); +}); diff --git a/apps/api/src/test-utils/fake-world/fetch-guard.ts b/apps/api/src/test-utils/fake-world/fetch-guard.ts index ba431afc5..8a2b5d71d 100644 --- a/apps/api/src/test-utils/fake-world/fetch-guard.ts +++ b/apps/api/src/test-utils/fake-world/fetch-guard.ts @@ -2,15 +2,17 @@ * Replaces global fetch with a guard that only allows loopback traffic * (the in-process test app). Any other HTTP call is a hermeticity violation * and fails with a descriptive error instead of silently reaching a real - * service. + * service. viem's http transport goes through global fetch, so RPC calls are + * covered too. The test preload installs it for every hermetic run. */ let originalFetch: typeof fetch | null = null; const LOOPBACK_PATTERN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/; -export function installFetchGuard(): void { +/** Returns the uninstaller; a no-op when an outer caller (the preload) already owns the guard. */ +export function installFetchGuard(): () => void { if (originalFetch) { - return; + return () => undefined; } const realFetch = globalThis.fetch; originalFetch = realFetch; @@ -29,9 +31,10 @@ export function installFetchGuard(): void { }) as typeof fetch; globalThis.fetch = Object.assign(guard, realFetch); + return uninstallFetchGuard; } -export function uninstallFetchGuard(): void { +function uninstallFetchGuard(): void { if (originalFetch) { globalThis.fetch = originalFetch; originalFetch = null; diff --git a/apps/api/src/test-utils/fake-world/index.ts b/apps/api/src/test-utils/fake-world/index.ts index 076796c86..95052f6fb 100644 --- a/apps/api/src/test-utils/fake-world/index.ts +++ b/apps/api/src/test-utils/fake-world/index.ts @@ -5,10 +5,10 @@ import { type FakeEvm, installFakeChainIdRpc, installFakeEvm } from "./fake-evm" import { type FakeMonerium, installFakeMonerium } from "./fake-monerium"; import { type FakePrices, installFakePrices } from "./fake-prices"; import { type FakeSquidRouter, installFakeSquidRouter } from "./fake-squidrouter"; -import { installFetchGuard, uninstallFetchGuard } from "./fetch-guard"; +import { installFetchGuard } from "./fetch-guard"; export type { FakeAlfredpay, FakeBrla, FakeEvm, FakeMonerium, FakeMykobo, FakePrices, FakeSquidRouter }; -export { installFakeChainIdRpc, installFetchGuard, uninstallFetchGuard }; +export { installFakeChainIdRpc, installFetchGuard }; export interface FakeWorld { evm: FakeEvm; @@ -28,7 +28,7 @@ export interface FakeWorld { * test files in one process, so leaked patches bleed into other files. */ export function installFakeWorld(): FakeWorld { - installFetchGuard(); + const uninstallFetchGuard = installFetchGuard(); const { fakeEvm, restore: restoreEvm } = installFakeEvm(); const { fakeAlfredpay, fakeBrla, fakeMykobo, restore: restoreAnchors } = installFakeAnchors(); const { fakeMonerium, restore: restoreMonerium } = installFakeMonerium(); diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 690778c57..809fb8ced 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -71,6 +71,10 @@ if (!process.env.RUN_LIVE_TESTS) { process.env.SUBSIDY_SETTLEMENT_DELAY_MS = "25"; process.env.PHASE_SETTLEMENT_RETRY_BACKOFF_MS = "25"; + // Any real network call (fetch, viem RPC) fails loudly, not only in fake-world tests. + const { installFetchGuard } = await import("./fake-world/fetch-guard"); + installFetchGuard(); + // Close the shared Sequelize pool after the whole run so lingering pg // connections don't surface as unhandled "Connection terminated" errors. const { afterAll } = await import("bun:test"); diff --git a/apps/api/src/tests/aaa-leak-probe.test.ts b/apps/api/src/tests/aaa-leak-probe.test.ts index b76058717..11b9cfab7 100644 --- a/apps/api/src/tests/aaa-leak-probe.test.ts +++ b/apps/api/src/tests/aaa-leak-probe.test.ts @@ -47,8 +47,8 @@ describe("leak canary: no test file leaked a singleton patch", () => { } }); - it("global fetch is not a leftover fetch guard", () => { - expect(globalThis.fetch.name, "the fetch guard was left installed").toBe("fetch"); + it("global fetch is the preload's fetch guard, not a leftover shim", () => { + expect(globalThis.fetch.name, "global fetch was left patched").toBe(process.env.RUN_LIVE_TESTS ? "fetch" : "guard"); }); it("shared getRoute is not a leftover FakeSquidRouter stub", () => { From 92f9cb15f13cdfe95183a622ab7ae67c5f4f8f2c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 09:36:35 +0200 Subject: [PATCH 24/34] test(api): replace per-file eth_chainId shims with the shared fake --- .../brl-onramp-crosschain.scenario.test.ts | 35 ++------------- .../eur-onramp-monerium.scenario.test.ts | 34 ++------------ .../sdk-contract.alfredpay-offramp.test.ts | 38 ++-------------- .../sdk-contract.alfredpay-onramp.test.ts | 38 ++-------------- .../src/tests/sdk-contract.offramp.test.ts | 37 ++-------------- apps/api/src/tests/sdk-contract.test.ts | 44 +++---------------- 6 files changed, 26 insertions(+), 200 deletions(-) diff --git a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts index bb91a68e4..8b4dba82f 100644 --- a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts @@ -25,7 +25,7 @@ import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; import { createTestTaxId, createTestUser, updatePartnerPricing } from "../../test-utils/factories"; -import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { type FakeWorld, installFakeChainIdRpc, installFakeWorld } from "../../test-utils/fake-world"; import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; import { startTestApp, type TestApp } from "../../test-utils/test-app"; @@ -41,33 +41,6 @@ const USDC_ON_ARBITRUM = requireToken(Networks.Arbitrum, EvmToken.USDC).erc20Add const BRLA_ON_BASE = requireToken(Networks.Base, EvmToken.BRLA).erc20AddressSourceChain as `0x${string}`; const TAX_ID = "12345678901"; -const BASE_CHAIN_ID_HEX = "0x2105"; -const ARBITRUM_CHAIN_ID_HEX = "0xa4b1"; - -function installChainIdShim(): { restore: () => void } { - const guardedFetch = globalThis.fetch; - const shim = (async (input: Parameters[0], init?: Parameters[1]) => { - if (typeof init?.body === "string") { - try { - const payload = JSON.parse(init.body) as { id?: number; method?: string }; - if (payload.method === "eth_chainId") { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - const chainId = url.includes("base") ? BASE_CHAIN_ID_HEX : ARBITRUM_CHAIN_ID_HEX; - return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: chainId }); - } - } catch { - // Not a JSON-RPC request; retain the hermetic fetch guard below. - } - } - return guardedFetch(input, init); - }) as typeof fetch; - globalThis.fetch = Object.assign(shim, guardedFetch); - return { - restore: () => { - globalThis.fetch = guardedFetch; - } - }; -} // Unlike the direct pix→BRLA-on-Base corridor, the full swap-and-bridge chain // executes here: Nabla swaps the minted BRLA into USDC on Base, the squid @@ -128,12 +101,12 @@ interface DestinationFundingExpectation { describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Arbitrum)", () => { let world: FakeWorld; let auth: { restore: () => void }; - let chainIdShim: { restore: () => void }; + let restoreChainIdRpc: () => void; let app: TestApp; beforeAll(async () => { world = installFakeWorld(); - chainIdShim = installChainIdShim(); + restoreChainIdRpc = installFakeChainIdRpc(); auth = installFakeSupabaseAuth(); await setupTestDatabase(); app = await startTestApp(); @@ -142,7 +115,7 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar afterAll(async () => { await app?.close(); auth?.restore(); - chainIdShim?.restore(); + restoreChainIdRpc?.(); world?.restore(); }); diff --git a/apps/api/src/tests/corridors/eur-onramp-monerium.scenario.test.ts b/apps/api/src/tests/corridors/eur-onramp-monerium.scenario.test.ts index be2d59b19..493790ac1 100644 --- a/apps/api/src/tests/corridors/eur-onramp-monerium.scenario.test.ts +++ b/apps/api/src/tests/corridors/eur-onramp-monerium.scenario.test.ts @@ -36,7 +36,7 @@ import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; import { createTestUser, updatePartnerPricing } from "../../test-utils/factories"; -import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; +import { type FakeWorld, installFakeChainIdRpc, installFakeWorld } from "../../test-utils/fake-world"; import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; import { startTestApp, type TestApp } from "../../test-utils/test-app"; @@ -49,35 +49,9 @@ const USDC_ON_ARBITRUM = requireToken(Networks.Arbitrum, EvmToken.USDC).erc20Add const EURE_ON_POLYGON = MONERIUM_ISSUE_NETWORKS[Networks.Polygon].eureAddress; const PROFILE_ID = "9e6a92a5-5f6d-48aa-a57b-0f8ae8eb745d"; -const CHAIN_ID_HEX: Record = { arbitrum: "0xa4b1", polygon: "0x89" }; /** Fake Uniswap quoter: 1 EURe (18 decimals) buys 1.16 USDC (6 decimals). */ const EURE_USDC_RATE_MICRO = 1_160_000n; -function installChainIdShim(): { restore: () => void } { - const guardedFetch = globalThis.fetch; - const shim = (async (input: Parameters[0], init?: Parameters[1]) => { - if (typeof init?.body === "string") { - try { - const payload = JSON.parse(init.body) as { id?: number; method?: string }; - if (payload.method === "eth_chainId") { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - const chainId = Object.entries(CHAIN_ID_HEX).find(([name]) => url.includes(name))?.[1] ?? CHAIN_ID_HEX.arbitrum; - return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: chainId }); - } - } catch { - // Not a JSON-RPC request; retain the hermetic fetch guard below. - } - } - return guardedFetch(input, init); - }) as typeof fetch; - globalThis.fetch = Object.assign(shim, guardedFetch); - return { - restore: () => { - globalThis.fetch = guardedFetch; - } - }; -} - const HAPPY_PATH_PHASES: RampPhase[] = [ "initial", "moneriumOnrampMint", @@ -129,13 +103,13 @@ interface CorridorSetup { describe("EUR onramp Monerium corridor (sepa → Polygon mint+swap → USDC on Arbitrum)", () => { let world: FakeWorld; let auth: { restore: () => void }; - let chainIdShim: { restore: () => void }; + let restoreChainIdRpc: () => void; let app: TestApp; const originalIssueFee = config.monerium.issueFeeEur; beforeAll(async () => { world = installFakeWorld(); - chainIdShim = installChainIdShim(); + restoreChainIdRpc = installFakeChainIdRpc(); auth = installFakeSupabaseAuth(); await setupTestDatabase(); app = await startTestApp(); @@ -146,7 +120,7 @@ describe("EUR onramp Monerium corridor (sepa → Polygon mint+swap → USDC on A config.monerium.issueFeeEur = originalIssueFee; await app?.close(); auth?.restore(); - chainIdShim?.restore(); + restoreChainIdRpc?.(); world?.restore(); }); diff --git a/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts index 79c614371..f5bbf6125 100644 --- a/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts +++ b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts @@ -21,11 +21,10 @@ import QuoteTicket from "../models/quoteTicket.model"; import RampState from "../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestAlfredpayCustomer, createTestApiKey, createTestUser } from "../test-utils/factories"; -import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { type FakeWorld, installFakeChainIdRpc, installFakeWorld } from "../test-utils/fake-world"; import { startTestApp, type TestApp } from "../test-utils/test-app"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const POLYGON_CHAIN_ID_HEX = "0x89"; // 137 interface CurrencyCase { fiat: FiatToken; @@ -116,35 +115,6 @@ const FULL_LIFECYCLE_CASES: LifecycleCase[] = [ } ]; -/** - * Same shim as the other SDK contract tests: the SDK's viem wallet client - * issues one eth_chainId RPC before signing locally. The Alfredpay SELL - * corridor only ephemeral-signs on Polygon (the source-of-funds transfer is - * user-broadcast, never SDK-signed), so answer with the Polygon chain id. - */ -function installChainIdShim(): { restore: () => void } { - const guardedFetch = globalThis.fetch; - const shim = (async (input: Parameters[0], init?: Parameters[1]) => { - if (typeof init?.body === "string") { - try { - const payload = JSON.parse(init.body) as { id?: number; method?: string }; - if (payload.method === "eth_chainId") { - return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: POLYGON_CHAIN_ID_HEX }); - } - } catch { - // not JSON — let the guarded fetch decide - } - } - return guardedFetch(input, init); - }) as typeof fetch; - globalThis.fetch = Object.assign(shim, guardedFetch); - return { - restore: () => { - globalThis.fetch = guardedFetch; - } - }; -} - /** * SDK ↔ API contract tests for the Alfredpay SELL rail (USDT on Polygon → * USD/MXN/COP/ARS bank payouts): the real SDK lists the user's registered @@ -155,19 +125,19 @@ function installChainIdShim(): { restore: () => void } { */ describe("SDK ↔ API contract (Alfredpay offramps, USDT on Polygon → bank payout)", () => { let world: FakeWorld; - let chainIdShim: { restore: () => void }; + let restoreChainIdRpc: () => void; let app: TestApp; beforeAll(async () => { world = installFakeWorld(); - chainIdShim = installChainIdShim(); + restoreChainIdRpc = installFakeChainIdRpc(); await setupTestDatabase(); app = await startTestApp(); }); afterAll(async () => { await app?.close(); - chainIdShim?.restore(); + restoreChainIdRpc?.(); world?.restore(); }); diff --git a/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts index 0ff347cda..4be2df9d7 100644 --- a/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts +++ b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts @@ -18,11 +18,10 @@ import QuoteTicket from "../models/quoteTicket.model"; import RampState from "../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestAlfredpayCustomer, createTestApiKey, createTestUser } from "../test-utils/factories"; -import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { type FakeWorld, installFakeChainIdRpc, installFakeWorld } from "../test-utils/fake-world"; import { startTestApp, type TestApp } from "../test-utils/test-app"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const POLYGON_CHAIN_ID_HEX = "0x89"; // 137 interface CurrencyCase { fiat: FiatToken; @@ -98,35 +97,6 @@ const FULL_LIFECYCLE_CASES: CurrencyCase[] = [ } ]; -/** - * Same shim as the other SDK contract tests: the SDK's viem wallet client - * issues one eth_chainId RPC before signing locally with the ephemeral key. - * The Alfredpay BUY corridors only ephemeral-sign on Polygon (the anchor mints - * USDT there), so answer with the Polygon chain id. - */ -function installChainIdShim(): { restore: () => void } { - const guardedFetch = globalThis.fetch; - const shim = (async (input: Parameters[0], init?: Parameters[1]) => { - if (typeof init?.body === "string") { - try { - const payload = JSON.parse(init.body) as { id?: number; method?: string }; - if (payload.method === "eth_chainId") { - return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: POLYGON_CHAIN_ID_HEX }); - } - } catch { - // not JSON — let the guarded fetch decide - } - } - return guardedFetch(input, init); - }) as typeof fetch; - globalThis.fetch = Object.assign(shim, guardedFetch); - return { - restore: () => { - globalThis.fetch = guardedFetch; - } - }; -} - /** * SDK ↔ API contract tests for the Alfredpay BUY rail (fiat → USDT on * Polygon): the real @vortexfi/sdk drives the real in-process API. The full @@ -138,19 +108,19 @@ function installChainIdShim(): { restore: () => void } { */ describe("SDK ↔ API contract (Alfredpay onramps, fiat → USDT on Polygon)", () => { let world: FakeWorld; - let chainIdShim: { restore: () => void }; + let restoreChainIdRpc: () => void; let app: TestApp; beforeAll(async () => { world = installFakeWorld(); - chainIdShim = installChainIdShim(); + restoreChainIdRpc = installFakeChainIdRpc(); await setupTestDatabase(); app = await startTestApp(); }); afterAll(async () => { await app?.close(); - chainIdShim?.restore(); + restoreChainIdRpc?.(); world?.restore(); }); diff --git a/apps/api/src/tests/sdk-contract.offramp.test.ts b/apps/api/src/tests/sdk-contract.offramp.test.ts index 1723f5735..d282dbf51 100644 --- a/apps/api/src/tests/sdk-contract.offramp.test.ts +++ b/apps/api/src/tests/sdk-contract.offramp.test.ts @@ -26,7 +26,7 @@ import { createTestUser, updatePartnerPricing } from "../test-utils/factories"; -import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { type FakeWorld, installFakeChainIdRpc, installFakeWorld } from "../test-utils/fake-world"; import { startTestApp, type TestApp } from "../test-utils/test-app"; function requireToken(network: Networks.Base | Networks.Polygon, token: EvmToken) { @@ -43,35 +43,6 @@ const BRLA_ON_BASE = requireToken(Networks.Base, EvmToken.BRLA).erc20AddressSour const TAX_ID = "12345678901"; const RECEIVER_TAX_ID = "12345678900"; const PIX_KEY = "test-pix-key"; -const BASE_CHAIN_ID_HEX = "0x2105"; // 8453 - -/** - * Same shim as sdk-contract.test.ts: the SDK's viem wallet client issues one - * eth_chainId RPC before signing locally; the SELL corridor only ephemeral-signs - * on Base (the Polygon squid leg is user-broadcast, never SDK-signed). - */ -function installChainIdShim(): { restore: () => void } { - const guardedFetch = globalThis.fetch; - const shim = (async (input: Parameters[0], init?: Parameters[1]) => { - if (typeof init?.body === "string") { - try { - const payload = JSON.parse(init.body) as { id?: number; method?: string }; - if (payload.method === "eth_chainId") { - return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: BASE_CHAIN_ID_HEX }); - } - } catch { - // not JSON — let the guarded fetch decide - } - } - return guardedFetch(input, init); - }) as typeof fetch; - globalThis.fetch = Object.assign(shim, guardedFetch); - return { - restore: () => { - globalThis.fetch = guardedFetch; - } - }; -} /** * SDK ↔ API contract tests for the SELL direction and the user-transaction @@ -84,19 +55,19 @@ function installChainIdShim(): { restore: () => void } { */ describe("SDK ↔ API contract (BRL offramp, USDC on Polygon → pix)", () => { let world: FakeWorld; - let chainIdShim: { restore: () => void }; + let restoreChainIdRpc: () => void; let app: TestApp; beforeAll(async () => { world = installFakeWorld(); - chainIdShim = installChainIdShim(); + restoreChainIdRpc = installFakeChainIdRpc(); await setupTestDatabase(); app = await startTestApp(); }); afterAll(async () => { await app?.close(); - chainIdShim?.restore(); + restoreChainIdRpc?.(); world?.restore(); }); diff --git a/apps/api/src/tests/sdk-contract.test.ts b/apps/api/src/tests/sdk-contract.test.ts index 7d9850a1f..d30efbe81 100644 --- a/apps/api/src/tests/sdk-contract.test.ts +++ b/apps/api/src/tests/sdk-contract.test.ts @@ -14,7 +14,7 @@ import { VortexSdk, VortexSdkError } from "../../../../packages/sdk/src"; import RampState from "../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestApiKey, createTestTaxId, createTestUser } from "../test-utils/factories"; -import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { type FakeWorld, installFakeChainIdRpc, installFakeWorld } from "../test-utils/fake-world"; import { startTestApp, type TestApp } from "../test-utils/test-app"; function requireBrlaOnBase() { @@ -28,40 +28,6 @@ const brlaTokenDetails = requireBrlaOnBase(); const BRLA_ON_BASE = brlaTokenDetails.erc20AddressSourceChain as `0x${string}`; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const BASE_CHAIN_ID_HEX = "0x2105"; // 8453 - -/** - * The SDK signs EVM transactions through a real viem wallet client, and viem's - * signTransaction issues a single eth_chainId RPC before signing locally with - * the ephemeral key. Answer that one call in-memory (the corridor only signs on - * Base) and let every other request fall through to the fetch guard, so any - * genuine network use still fails loudly. The SDK's NetworkManager itself is - * inert here: it only opens chain WebSockets when signing Pendulum, Moonbeam, - * or Hydration transactions, and the direct BRL→BRLA-on-Base corridor produces - * a single Base EVM transaction. - */ -function installChainIdShim(): { restore: () => void } { - const guardedFetch = globalThis.fetch; - const shim = (async (input: Parameters[0], init?: Parameters[1]) => { - if (typeof init?.body === "string") { - try { - const payload = JSON.parse(init.body) as { id?: number; method?: string }; - if (payload.method === "eth_chainId") { - return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: BASE_CHAIN_ID_HEX }); - } - } catch { - // not JSON — let the guarded fetch decide - } - } - return guardedFetch(input, init); - }) as typeof fetch; - globalThis.fetch = Object.assign(shim, guardedFetch); - return { - restore: () => { - globalThis.fetch = guardedFetch; - } - }; -} /** * SDK ↔ API contract tests: the real @vortexfi/sdk (imported from source) @@ -71,19 +37,21 @@ function installChainIdShim(): { restore: () => void } { */ describe("SDK ↔ API contract (BRL onramp, pix → BRLA on Base)", () => { let world: FakeWorld; - let chainIdShim: { restore: () => void }; + let restoreChainIdRpc: () => void; let app: TestApp; beforeAll(async () => { world = installFakeWorld(); - chainIdShim = installChainIdShim(); + // The SDK's NetworkManager stays inert: it only opens chain WebSockets for Pendulum, + // Moonbeam, or Hydration signing, and this corridor signs a single Base EVM transaction. + restoreChainIdRpc = installFakeChainIdRpc(); await setupTestDatabase(); app = await startTestApp(); }); afterAll(async () => { await app?.close(); - chainIdShim?.restore(); + restoreChainIdRpc?.(); world?.restore(); }); From 69b9754d6998a0763467717200c9b26db837d7b7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 10:02:07 +0200 Subject: [PATCH 25/34] docs(repo): align B2B doc statements with the code Review found statements that claimed more than the code does: memo-routed payments had no signal or followable procedure, destination rotation on an active account was described as detected, B3 implied automatic refunds in every recovery mode, and the white-label app was named as Vortex's. --- .../src/api/services/monerium-b2b/recovery.ts | 6 +++--- docs/adr-0005-monerium-b2b-onramp.md | 16 +++++++------- ...-0007-monerium-b2b-partner-registration.md | 8 ++++--- docs/architecture-monerium-b2b-onramp.md | 20 +++++++++++------- docs/operations-monerium-b2b-rollout.md | 8 +++---- docs/operations-monerium-b2b-runbook.md | 21 ++++++++++++++++--- .../05-integrations/monerium-b2b.md | 2 +- 7 files changed, 52 insertions(+), 29 deletions(-) diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts index 5580c3c84..e27d0e8b3 100644 --- a/apps/api/src/api/services/monerium-b2b/recovery.ts +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -44,8 +44,8 @@ import { refundAccountFor } from "./refund-wallet"; * IBAN, which pays out of the client's own IBAN, and mark the deposit `refunded` when * Monerium processed it. * - * Crash safety rests on the refund wallet being dedicated and empty between refunds: - * every step re-derives what is still to do from the wallet's balances, so a lost + * Crash safety rests on the refund wallet being dedicated and empty between refunds (a + * payment memo-routed to it breaks that until refunded by hand: runbook §2.7): every step re-derives what is still to do from the wallet's balances, so a lost * transaction hash never repeats a value-moving send (a top-up already on chain makes the * remaining need zero). One recovery at a time PER CLIENT is what keeps those balances * meaningful: the executor refuses a second `recover` for an account while one is in flight @@ -58,7 +58,7 @@ import { refundAccountFor } from "./refund-wallet"; */ export const REFUND_MEMO_PREFIX = "vortex-refund:"; -/** Monerium requires a supporting document above this amount; such refunds stay manual (runbook §2.7). */ +/** Monerium requires a supporting document from this amount; such refunds stay manual (runbook §2.7). */ export const SUPPORTING_DOCUMENT_THRESHOLD_EUR = 15_000; /** Gas the refund wallet's own transactions use (approve, reverse swap, surplus transfer), with margin. */ const REFUND_WALLET_GAS_UNITS = 400_000n; diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index 9de820d5c..4351ddded 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -253,9 +253,10 @@ the promised window. Decisions: 16 minutes, so a payment above roughly €70k could reach the two-hour window. The guardian can change the cap at any time up to the €50k ceiling. - **Penny test optional.** The penny test (B2) is an optional check, recommended for - exchange destinations, and not an activation requirement. A wrong or rotated - destination is now caught by the penny test only when one is run, otherwise by the - dormancy gate. + exchange destinations, and not an activation requirement. A wrong destination is + caught by the penny test only when one is run; an address an exchange retires after a + long idle period is caught by the dormancy gate; a rotation on an account that keeps + converting is caught by neither (B5). ## Amendment 2026-10-01: per-client refund wallets @@ -291,7 +292,7 @@ the promised window. Decisions: |---|---|---| | B1 | Default fee policy | **target 1250 ppm (12.5 bps), floor 1500 ppm (15 bps) below the reference**, per client, guardian-adjustable (amended 2026-09-15; replaces the flat 0 / 15 bps skim) | | B2 | Penny-test amount | 5 USDC, **optional** (amended 2026-09-29): recommended for exchange destinations, not an activation requirement | -| B3 | Conversion window | **2 h from the mint** (P3; `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` default 120), refunded past it; weekend mints execute within the 52 h oracle window at possibly wider spreads | +| B3 | Conversion window | **2 h from the mint** (P3; `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` default 120). Past it the payment is due for a refund: marked automatically with `MONERIUM_B2B_AUTO_RECOVERY=auto`, reported for an operator with `alert`, left to the operator with the default `off` (runbook §2.7). Weekend mints convert too, because the Chainlink bound accepts a price up to 52 h old (P8), at possibly wider spreads | | B4 | Per-client volume limit | **Not enforced by the backend** (GA revisit); `perSwapCap` (P7) bounds each swap, not a client's daily volume | | B5 | Destination responsibility | The partner supplies and confirms each destination; dormancy re-activation on written partner confirmation | | B6 | Redemption limitation | A clone validates no redeem order, so its EURe is never redeemed to an IBAN directly: it leaves only by swap and forward to the destination, by recovery to the client's refund wallet, or through Monerium's issuer recovery (T1) | @@ -347,9 +348,10 @@ example (oversized-deposit allocation). control (authorization requirements on `PATCH /ibans` and `POST /addresses`) can exist only at Monerium; the association monitor is the detective control; response = rotate + suspend (runbook). -- **CEX destination rotation.** Not verifiable on-chain; left to the partner (B5), - with the dormancy gate and an optional penny test. Silent-loss risk - converts to a pause via the dormancy gate. +- **CEX destination rotation.** Not verifiable on-chain; left to the partner (B5). The + dormancy gate turns a rotation after 60 idle days into a pause, and an optional penny + test catches a wrong address when run; a rotation on an account that keeps converting + is not detected, and its forwards keep going to the old address. - **Vortex custody on the refund path** (amendment 2026-09-17). A recovered payment sits in Vortex's own wallet until the bank refund goes out; a compromised keeper plus recovery key could divert a payment the window was missed on. Bounded by the immutable diff --git a/docs/adr-0007-monerium-b2b-partner-registration.md b/docs/adr-0007-monerium-b2b-partner-registration.md index 7802d9056..0adacaa5e 100644 --- a/docs/adr-0007-monerium-b2b-partner-registration.md +++ b/docs/adr-0007-monerium-b2b-partner-registration.md @@ -98,9 +98,11 @@ could deploy. never-converted account runs from it) and every status change is logged with its from and to status, destination and forwarder. Operators find the accounts waiting with `GET /v1/admin/monerium-b2b/accounts?status=onboarding`. -7. **No exchange-address distinction.** Every valid address is accepted the same way; - an exchange rotating a deposit address is caught by the dormancy gate or an optional - penny test (ADR-0005 B5). +7. **No exchange-address distinction.** Every valid address is accepted the same way. + An address an exchange retires after a long idle period is caught by the dormancy + gate, and a wrong address by an optional penny test; a rotation on an account that + keeps converting is not detected, so the destination stays with the partner + (ADR-0005 B5). 8. **Profile kind.** The keeper reads a profile's state, not its kind, so a personal (individual) Monerium profile in the partner's app is accepted like a corporate one and mapped with a mirrored business KYB record. Accepted scope decision of the diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index bcc73778c..7b33c4bc1 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -10,8 +10,8 @@ operator procedures in [`operations-monerium-b2b-runbook.md`](operations-moneriu ## The shape in one paragraph -Each corporate client is onboarded and KYB-approved by Monerium in Vortex's whitelabel -app and owns a dedicated Monerium profile. Vortex +Each corporate client is onboarded and KYB-approved by Monerium in the partner's +white-label app and owns a dedicated Monerium profile. Vortex deploys one `VortexForwarder` contract clone per client, links it to that profile with an attestor signature, and requests an IBAN **for the linked contract address** — the IBAN's default mint destination *is* the forwarder. From then on the flow is passive on @@ -209,12 +209,16 @@ candidate. A live balance by itself is deliberately insufficient: this prevents from outrunning the watcher's reorg window and becoming impossible to attribute safely. A payer can name a chain and address in the SEPA memo; when that address is linked to the -client's profile, Monerium mints there instead of to the IBAN's default address. The only -other address linked to a client profile is the client's refund wallet, so such a payment -lands there: its order is skipped as referencing an unknown forwarder address, and it is -neither converted nor refunded automatically. Operators refund it by hand (runbook §2.7) -before the client's next refund, which would otherwise sweep that EURe to the float as -surplus. +client's profile, Monerium mints there instead of to the IBAN's default address. Besides +the current clone, a profile's linked addresses are the client's refund wallet and, after +a migration (runbook §5) or a refund-seed rotation (runbook §6), the old clone and the old +refund wallet, because Monerium has no unlink. Such a payment's order is skipped as +referencing an unknown forwarder address, and it is neither converted nor refunded +automatically. Detecting it is an open item (rollout ledger, "Memo-routed mint to a refund +wallet"); until then the only signal is that warning (runbook §3), and operators refund +the payment by hand (runbook §2.7, "Memo-routed payment"). On the current refund wallet +this has to happen before the client's next refund, which otherwise treats that EURe as +surplus and sweeps it to the float. ## How the mint watcher walks the chain diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md index 4d43d11f1..16d40f4dc 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -1,7 +1,7 @@ # Monerium B2B Onramp — Rollout -What still stands between the implemented system and a live pilot: the launch-readiness -gates, the deploy checklist, and the open items ledger. Decisions and parameters are +What still stands between the implemented system and a live pilot on the engineering +side: the external audit gate, the deploy checklist, and the open items ledger. Decisions and parameters are final in [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). @@ -42,14 +42,14 @@ The Sepolia sandbox follows its own procedure in the runbook (§8). it from the treasury with the first days of budget. Runbook §2.6 has the commands. 5. Verify factory + implementation source on the block explorer; generate, verify, and publish the manifest. -6. Production whitelabel credentials from Monerium; configure the keeper backend (the +6. Production credentials for the partner's white-label app from Monerium; configure the keeper backend (the mykobo flow variant only): credentials, attestor/keeper/guardian/deployer keys (four distinct; keeper and deployer funded), `MONERIUM_B2B_PARTNER_MANAGER_PROFILE_ID` (the partner manager allowed to register destinations), `MONERIUM_B2B_REFUND_SEED` (derives every client's refund wallet), read RPC + private orderflow RPC, webhook secret, and `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`; the backend needs outbound HTTPS to `api.exchange.coinbase.com` for the reference rate (P12) — without it every swap - defers. Keep `MONERIUM_B2B_ENABLED=false` until every remaining gate is complete. + defers. Keep `MONERIUM_B2B_ENABLED=false` until G3 is done and the launch is signed off. 7. Register the webhook endpoint at Monerium (`profile.updated`, `iban.updated`, `order.created`, `order.updated`). 8. Before first production onboarding, simulate a SEPA deposit end to end (dashboard → diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index 43c4b79f9..ffe99be24 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -451,6 +451,18 @@ be pending. If its float top-up was dropped, park the refund, clear its hash (`U Record deposit id, recover tx, reverse-swap tx, float top-up, redeem order id and payer IBAN (masked) in the ops ledger. +**Memo-routed payment** (no deposit row; found through the §3 warning `webhook order ... +references unknown forwarder address`). Look the order up at Monerium by the id in the +warning: it gives the amount, the address it minted to, and the payer's IBAN and name. If +that address is an old clone (§5), `poke()` it, wait for `RECOVERY_DELAY` (2 h), and have +the keeper `recover` the EURe to that clone's refund wallet; an old refund wallet from a +seed rotation (§6) needs the old seed's key. Then redeem the exact amount from the refund +wallet to the payer as in step 5, with a memo naming the issue order instead of a deposit +(`vortex-refund:` is reserved for deposits). On the client's current refund wallet, do +this before the client's next refund starts: that refund treats any EURe on the wallet as +surplus and sweeps it to the float, from where it must first be moved back. Record the +order id, recover tx (if any) and redeem order id in the ops ledger. + ## 3. Alert triage (monitoring log lines → action) Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium-b2b:`. @@ -464,6 +476,7 @@ Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium- | `subsidy vault ... refill before below-floor swaps start deferring` | Less than a day of budget left, or today's budget spent | §2.6 refill; consider the budget vs. observed spreads | | `no subsidy vault is configured on the factory` | `setSubsidyVault` never ran; below-floor swaps defer | §2.6 | | `route ... could not be quoted` | One whitelisted route's pool is unquotable (drained, removed) | Disable it (§2.1) so the keeper stops trying; keep at least one healthy route | +| `webhook order ... references unknown forwarder address, skipping` (warn) | An issue order minted to an address that is no account's forwarder: usually a payment memo-routed to a client's refund wallet, or to an old clone or refund wallet after a migration or seed rotation | Refund it by hand: §2.7, "Memo-routed payment" | | `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, an address other than the forwarder and the client's own refund wallet linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | | `stranded funds on forwarder ... past RECOVERY_DELAY` (warn) | A batch has been open longer than the promised 2 h window and is neither forwarded nor recovering | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts), `deferring conversion` lines; if the payment cannot complete, mark it for recovery (§2.7) | | `stranded funds ... past TRIGGER_DELAY` (error) | Permissionless path now live; the promised window long missed (keeper outage or a persistent deferral) | Escalate; anyone may call `swap(reference, route, amountIn)` and `forwardAll()` — that path prices against Chainlink and pays no subsidy; communicate the delay | @@ -540,8 +553,10 @@ monitor's alerts are expected, then: 5. Update the `monerium_accounts` row (forwarder address), re-activate (§1.7), then optionally penny-test the new clone: only an active account converts. -There is no unlink at Monerium and no custodial parking position: EURe always mints to -the IBAN's current default address; the old clone stays linked but inert. +There is no unlink at Monerium and no custodial parking position: EURe mints to the +IBAN's current default address. The old clone stays linked, so a payer who names it in +the SEPA memo still mints there; that payment is refunded by hand (§2.7, "Memo-routed +payment"). ## 6. Key compromise quick reference @@ -549,7 +564,7 @@ the IBAN's current default address; the old clone stays linked but inert. |---|---|---| | Attestor | Can link addresses to profiles; never move funds (recovery payouts go only to the client's own bank account) | Rotate key; new forwarders need a new implementation (ATTESTOR is immutable); existing links unaffected | | Keeper | `poke`/`swap`/`forward`/`recover`: can pick any whitelisted route and any reference inside the Chainlink band — worst case the fee reaches the 1% cap or the vault pays up to its caps, plus gas theft — and can move a payment whose batch is 2 h old to the client's refund wallet (never anywhere else, never a redirect) | Rotate; `setKeeper(old,false)` + `setKeeper(new,true)`; pause the vault while rotating; reconcile executions against Coinbase history; audit `Recovered` events against marked deposits; refund gas | -| Refund seed (`MONERIUM_B2B_REFUND_SEED`) | Derives every client's refund wallet; each holds funds only between that client's `recover` and its bank refund, and can redeem them out of the client's IBAN to any IBAN | Set `MONERIUM_B2B_AUTO_RECOVERY=off`, finish or reconcile open refunds by hand, rotate the seed, then give every client a new clone with its new refund wallet and move the IBANs (§5); the old wallets hold nothing between refunds | +| Refund seed (`MONERIUM_B2B_REFUND_SEED`) | Derives every client's refund wallet; each holds funds only between that client's `recover` and its bank refund, or after a payer memo-routes a payment to it until it is refunded by hand (§2.7), and can redeem them out of the client's IBAN to any IBAN | Set `MONERIUM_B2B_AUTO_RECOVERY=off`, finish or reconcile open refunds by hand, rotate the seed, then give every client a new clone with its new refund wallet and move the IBANs (§5); the old wallets hold nothing between refunds unless a payment is memo-routed to them later (keep the old seed for that refund) | | Guardian | Pause/unpause, bounded params, timelocked fee policy, route whitelist (validated), vault limits and withdrawal to treasury, revoking clones — delay-only griefing plus Vortex-money exposure | Two-step `transferGuardian`/`acceptGuardian`; audit pause, pending-policy, route, vault, keeper and deployer state after (below) | | Deployer (`MONERIUM_B2B_DEPLOYER_PRIVATE_KEY`) | Deploys a registered clone for any destination and refund address; no power over existing clones, funds or settings. Each such clone counts as a forwarder, so the vault pays it, up to its `dailyBudget` and `maxSubsidyPpm`, once its batch delay allows a swap | Guardian: `setDeployer(old,false)`, then `revokeForwarder` for its rogue clones (below); fresh key, fund it, `setDeployer(new,true)`, update the env | | Whitelabel API credentials | Control-plane: can re-link/move IBANs (future mints only) — S1 | §2.5 full sequence | diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md index a8f69231d..a2c2bab7b 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -67,7 +67,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving a swap with no deposit to belong to | Conversion candidates require chain-indexed deposits; every swap is bound to one deposit before it is sent, with an explicit `amountIn` | | **Unsolicited USDC inflates deposit reporting** | USDC that arrived outside a swap is credited to a deposit | Execution net is `SwapExecuted.usdcOut - fee + subsidy`; a deposit's forward moves exactly the sum of its chunks' nets; unsolicited USDC leaves only through the keeper's `forwardAll` and is never reported as a conversion | | **Premature or misdirected recovery** | A compromised keeper moves a fresh payment off the clone, or to a wallet of its choosing | `recover` pays only the clone's fixed `recoveryAddress` (the client's derived refund wallet, verified at mapping), only after the clone's batch has been open for `RECOVERY_DELAY`, with explicit amounts bounded by the balances; a deposit enters the refund path only through the admin endpoint (operator) or, once automated, the deadline; the invariant suite proves no early recovery and no other exit | -| **Refund wallet or seed compromise** | A client's refund wallet is drained before the refund, or the seed leaks and exposes every client's wallet | Funds sit on a refund wallet only during that client's recovery; each wallet holds one client's in-flight refund and nothing else; one refund per client at a time; the seed is env-only and never logged; rotating it means new clones for every client (runbook §5 and §6) | +| **Refund wallet or seed compromise** | A client's refund wallet is drained before the refund, or the seed leaks and exposes every client's wallet | Funds sit on a refund wallet during that client's recovery, or after a payer memo-routes a payment to it until an operator refunds it (runbook §2.7, "Memo-routed payment"); each wallet holds only its own client's funds; one refund per client at a time; the seed is env-only and never logged; rotating it means new clones for every client (runbook §5 and §6) | | **Reverse-swap sandwich or thin reverse route** | A searcher moves the pool while the refund's USDC is swapped back, or the pool is too thin | Minimum output at the Chainlink value less `SLIPPAGE_BPS`; private orderflow; a rejected or reverted swap retries on the next keeper cycles and parks the refund for the operator after five failed attempts; the float, not the payer, absorbs slippage | | **Double refund** | A crash between the redeem POST and its persistence places the order twice, or a lost top-up hash sends the float twice | Memo-keyed lookup at Monerium before every placement; balance-derived need (a landed top-up makes the need zero); one open recovery per client on an otherwise empty wallet; float sends serialized across clients | | **Refund to the wrong account** | A tampered or missing counterpart sends the EUR elsewhere | The target is the issue order's own counterpart, recorded once and never overwritten by a later delivery; a deposit without it never refunds automatically (`recovery_failed`, runbook) | From a30fcc0534bd51a68481a9b0681824eb78c96a57 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 10:12:29 +0200 Subject: [PATCH 26/34] docs(api): describe the b2b sandbox steps as current practice --- docs/api/pages/14-managed-profiles.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index b591cfe44..2d356f1be 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -141,7 +141,7 @@ The business EUR onramp is open for testing in sandbox as a preliminary release; 1. Sign up at with your email, open **API keys**, and create a credential. Keep the secret key (`sk_test_...`) on your backend. 2. Email the address you signed up with. The dashboard does not show your profile ID; Vortex looks it up from the email. -3. Vortex enables your profile as a manager for the `EU` corridor and business customers, binds it to the EUR provider app as the manager that registers clients (until then `POST /v1/monerium-b2b/accounts` returns `403 MANAGED_PROFILE_ACCESS_DENIED`), and sends you the EUR provider's sandbox profile IDs of the test companies you can register. During this pilot those companies live in Vortex's own provider app, and the provider approves each profile in its sandbox before Vortex deploys anything for it. +3. Vortex enables your profile as a manager for the `EU` corridor and business customers, binds it to the EUR provider app as the manager that registers clients (until then `POST /v1/monerium-b2b/accounts` returns `403 MANAGED_PROFILE_ACCESS_DENIED`), and sends you the EUR provider's sandbox profile IDs of the test companies you can register. Currently those companies live in Vortex's own provider app, and the provider approves each profile in its sandbox before Vortex deploys anything for it. **Integrate** @@ -173,7 +173,7 @@ Content-Type: application/json } ``` -3. Follow the registration (`registrations[0].status` in the response) until it is `mapped`. It reports `waitingReason: null` until Vortex first checks it, `monerium_profile_pending` until the provider approves the profile, then `deployment_pending` while Vortex deploys the client's conversion contract. During this pilot the provider approves the test profiles, not you: if a registration stays at `monerium_profile_pending`, or `POST` returns `422 MONERIUM_B2B_PROFILE_UNAVAILABLE`, check the profile ID against the one Vortex sent and contact Vortex. +3. Follow the registration (`registrations[0].status` in the response) until it is `mapped`. It reports `waitingReason: null` until Vortex first checks it, `monerium_profile_pending` until the provider approves the profile, then `deployment_pending` while Vortex deploys the client's conversion contract. Currently the provider approves the test profiles, not you: if a registration stays at `monerium_profile_pending`, or `POST` returns `422 MONERIUM_B2B_PROFILE_UNAVAILABLE`, check the profile ID against the one Vortex sent and contact Vortex. ```http GET /v1/monerium-b2b/registrations?moneriumProfileId= @@ -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. During this pilot, 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. 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. 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 198fc76b29aad3ba42768150e2e3c96feafb4348 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 10:42:14 +0200 Subject: [PATCH 27/34] ci(repo): run the deployed quote smoke test in live mode The test preload now installs the fetch guard for every hermetic run, which would reject the smoke test's calls to the deployed APIs. --- .github/workflows/e2e.yml | 2 ++ docs/operations-testing.md | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 78b3d24cb..64205ee26 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -71,6 +71,8 @@ jobs: if: always() working-directory: apps/api env: + # Live test: skips the hermetic preload, whose fetch guard would block these calls. + RUN_LIVE_TESTS: "1" VORTEX_QUOTE_SMOKE_URLS: https://api-staging.vortexfinance.co,https://api.vortexfinance.co run: bun test src/tests/deployed-quotes.e2e.test.ts diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 17f07dde0..53fc98215 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -277,9 +277,11 @@ Tests that hit real RPCs or sandboxes (e.g. XCM dry-runs in `packages/shared`) a `RUN_LIVE_TESTS=1` via `describe.skipIf`. They are for local debugging and optional nightly runs, never PR-blocking. -The e2e workflow sets `VORTEX_QUOTE_SMOKE_URLS` for `deployed-quotes.e2e.test.ts`, which requires -successful cross-chain BUY and SELL quotes from both staging and production. Ordinary local and -PR-blocking API runs leave the variable unset, so this deployment smoke test does not make them +The e2e workflow sets `VORTEX_QUOTE_SMOKE_URLS` and `RUN_LIVE_TESTS=1` for +`deployed-quotes.e2e.test.ts`, which requires successful cross-chain BUY and SELL quotes from both +staging and production. `RUN_LIVE_TESTS=1` is required: without it the test preload installs the +fetch guard, which rejects the calls to the deployed APIs. Ordinary local and PR-blocking API runs +leave `VORTEX_QUOTE_SMOKE_URLS` unset, so this deployment smoke test does not make them network-dependent. ### External API contracts (`apps/api/src/tests/contracts/`) From 966f067b21d7fb367f843fefce7ddc7394bb2970 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 9 Oct 2026 11:01:04 +0200 Subject: [PATCH 28/34] 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 29/34] 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 30/34] 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 31/34] 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 32/34] 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 33/34] 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 34/34] 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