From ebdb66f2aa677f9bf74f3c954c7dd58dbd205b24 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 14 Sep 2026 16:51:54 +0200 Subject: [PATCH 01/42] docs(repo): propose dual Monerium app support Plan for running the Monerium OAuth and white-label applications in parallel on the EUR corridor, stacked on the Monerium reintegration. Records the Phase 0 sandbox probe results: link-at-login is no longer supported, the user token can link wallets and request the single per-profile IBAN, and the white-label app cannot see OAuth-onboarded profiles. --- docs/README.md | 1 + docs/proposal-monerium-dual-app.md | 230 +++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 docs/proposal-monerium-dual-app.md diff --git a/docs/README.md b/docs/README.md index 9349e7b24..429cc0057 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | | [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md) | Phase-2 proposal for the consumer (Safe + passkey) Monerium onramp; the B2B variant shipped | | [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | +| [`proposal-monerium-dual-app.md`](proposal-monerium-dual-app.md) | Implementation plan for running the Monerium OAuth and white-label apps in parallel on the EUR corridor, stacked on PR #1359 | | [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | The root [`README.md`](../README.md) is human onboarding, [`MAP.md`](../MAP.md) is diff --git a/docs/proposal-monerium-dual-app.md b/docs/proposal-monerium-dual-app.md new file mode 100644 index 000000000..0369d1c0e --- /dev/null +++ b/docs/proposal-monerium-dual-app.md @@ -0,0 +1,230 @@ +# Proposal: Dual Monerium App Support (OAuth + White-Label) + +Status: proposed implementation plan for a stacked PR on top of +[PR #1359](https://github.com/pendulum-chain/vortex/pull/1359) (`monerium-reintegration`). +Decision sought: run the Monerium OAuth application and the Monerium white-label +application in parallel for the EUR corridor, resolving the user's profile through the +white-label app first and the OAuth app second. Last updated: 2026-09-14. + +Related material: + +- [`Monerium Interface`](operations-monerium-interface.md) +- [`Monerium Integration (security spec)`](security-spec/05-integrations/monerium.md) +- [`Identity, Customer, and Partner Model`](architecture-identity-model.md) +- [`ADR-0005 Monerium B2B onramp`](adr-0005-monerium-b2b-onramp.md) (unaffected, uses the + white-label client through its own attestor orchestration) + +## Objective + +Give users the EUR/SEPA rail back without white-label onboarding, which is blocked until +KYC sharing exists. At EUR ramp time Vortex resolves the authenticated user's Monerium +profile in this order: + +1. the profile is visible to the **white-label** app (client credentials); +2. otherwise the profile is reachable through the **OAuth** app (the user's backend-held + token); +3. otherwise the user is not onboarded and the client offers OAuth onboarding. + +Both paths feed the same on-chain flow shipped by PR #1359 (mint to the user's linked EOA, +owner permit, self-transfer, Uniswap, Squid). Only the credential used to read profile, +linked address, and IBAN differs. + +## Decisions already taken + +| Topic | Decision | +|---|---| +| Difference between paths | Credential for the registration-time reads only. Execution never calls Monerium. | +| Wallet-address discovery | Not in scope. Detection uses the local binding's profile ID only. Address lookup would adopt a Monerium identity from a bare wallet address, which the spec forbids without an ownership proof. Can be added later behind a signed link-message proof. | +| Wallet link and IBAN for OAuth users | Vortex does it with the user's OAuth token: `POST /addresses` with a client-collected link signature, then `POST /ibans` (or a user-confirmed move). Link-at-login is no longer supported by Monerium (P2). The user never uses Monerium's own app. | +| Client scope | Dashboard, widget, SDK/direct API. | +| OAuth token storage | Backend memory only, as today. Legacy widget kept the token in the persisted ramp machine snapshot in `localStorage`; that does not return. | +| Recording the source | Runtime resolution on every registration, no schema change. After registration the persisted facts (profile, address, IBAN, baseline) make the source irrelevant. | + +## Facts the plan relies on + +- `createRegisterMoneriumIssue` (`apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts`) + takes `resolveProfileId` and a client exposing `getProfile`, `listAddresses`, `listIbans` + by dependency injection. Everything after profile resolution is credential-agnostic. +- The OAuth service (`apps/api/src/api/services/monerium/monerium.service.ts`) already + caches access and rotating refresh tokens per legal entity, mirrors the profile into + `provider_customers` (provider `monerium`, rail `eur`), and surfaces + `MONERIUM_REAUTHENTICATION_REQUIRED` when the token is gone. The dashboard renders that + code as a reconnect prompt. +- Both apps share one `provider_customers` row per entity. The white-label API has no + email lookup, so "known to the white-label app" can only be tested with a profile ID. +- The legacy widget (removed in `32dc0a87c`) linked the wallet during OAuth login using + `/auth?address=&chain=&signature=` and passed the token to the backend at registration. + The on-chain design was the same permit-based self-transfer. +- Dashboard and widget both call `/v1/ramp/*` directly, hold a Supabase session for + logged-in users, and already sign EIP-712 typed data with wagmi. The dashboard has the + Monerium OAuth UI (`MoneriumKycFlow`, `/monerium/callback`) built on the shared + `@vortexfi/kyc` Monerium machine; the widget has none (SEPA onboarding there is the + legacy Mykobo form). The SDK EUR handler drops owner-signed transactions. + +## Phase 0: sandbox probes (before code) + +| Probe | Question | Effect on the plan | +|---|---|---| +| P1 | Does the white-label client see a profile onboarded through the OAuth app (`GET /profiles/{id}` with client credentials)? | If yes, step 1 of the resolution already covers OAuth users and the OAuth read adapter is only a fallback. If no, the fallback is the main path for every OAuth user. | +| P2 | With a user token from the OAuth app: are `GET /ibans?profile=`, `POST /ibans`, and `POST /addresses` permitted? Does `/auth?address&chain&signature` still link at login? | Decides whether Vortex can provision the IBAN itself and whether re-linking needs another authorize round trip. | +| P3 | Sandbox chain: legacy minted on `amoy` in sandbox. Confirm the #1359 flow's sandbox network and the chain used for IBAN and address filters agree. | Configuration only. | + +Record the results in this document and in `operations-monerium-interface.md`. + +Results so far (2026-09-14): + +- P3: the #1359 flow is pinned to Polygon mainnet (`MoneriumIssue(Networks.Polygon)`, mainnet + EURe, mainnet Uniswap pool) regardless of `SANDBOX_ENABLED`. Monerium sandbox profiles + hold `amoy`/`sepolia` addresses, so a sandbox IBAN can never match at registration. + Sandbox end-to-end ramps need either an Amoy variant of the flow or production-only + verification; the API-level probes are unaffected. +- The sandbox "white-label" credentials and the older sandbox credentials from the B2B work + resolve to the same Monerium application. It sees one partner-owned personal profile + (pending) with the B2B forwarder addresses and IBAN. +- Monerium docs (API reference and white-label guide) do not state which token types may + call `POST /ibans` and `POST /addresses`, and the legacy `/auth` link parameters + (`address`, `chain`, `signature`) are no longer documented. Both need the live + user-token probe. +- P2 (run 2026-09-14 with the partner account against the sandbox OAuth app "Vortex"): + - The authorization-code exchange returns a 1-hour access token plus a refresh token. + - `POST /addresses` with the **user** token links a new EOA (`201`, state `linked`). + - The legacy link-at-login parameters (`address`, `chain`, `signature` on `/auth`) are + ignored: the address was not linked. Vortex must link through `POST /addresses`. + - `POST /ibans` with the user token reaches the business rule, not an auth error, and + answers `400 IBAN already requested or provisioned for this profile`: **one IBAN per + profile**. A second chain/address requires moving the IBAN (`PATCH /ibans/{iban}`), + which the OAuth app is permitted to do ("Update IBANs"). + - The OAuth app's enabled permissions are Create wallet address, Read/Create/Update + IBANs, and Create payments. It has no KYC permissions; KYC happens in Monerium's + hosted flow. + - Registered redirect URIs on the OAuth app: `http://localhost:5174/dashboard/monerium/callback`, + `http://localhost:5473/widget`, `http://localhost:5473`, `http://localhost:5474/dashboard`, + `http://localhost:5474`. A mismatch renders the authorization page empty with no error. +- P1 (run 2026-09-14 with a second sandbox user who signed up inside the OAuth flow via + `auth_mode=signup`): **the white-label app cannot see OAuth-onboarded profiles.** + `GET /profiles/{id}`, `GET /addresses?profile=`, and `GET /ibans?profile=` with client + credentials answer `403 ... does not have access to profile ... with required scopes`, + and the profile is absent from the white-label `GET /profiles` list and `/auth/context`. + Only `GET /addresses/{address}` answers `200` for the user-linked address, so an address + lookup can reveal which profile owns an address but cannot read that profile. + Consequences: for OAuth users the user token is the only read path, at onboarding and at + every registration; a lost backend token means reauthentication before ramping; the + resolver order (white-label first, OAuth second) stands. On the fresh profile + `POST /ibans` with the user token answered `202 Accepted`, confirming provisioning. + +## Phase 1: API identity resolution + +New module `apps/api/src/api/services/monerium/identity.ts`: + +``` +resolveMoneriumIdentity(userId, network, transaction) + -> { profileId, source: "whitelabel" | "oauth", client: MoneriumReadClient } +``` + +1. Load the entity's `provider_customers` row (provider `monerium`, rail `eur`). No row or + no `providerCustomerId` → `MONERIUM_ONBOARDING_REQUIRED`. +2. White-label: `MoneriumApiService.getProfile(profileId)`. Visible and `approved` → + source `whitelabel`, client is the shared service. Not visible (404/403) → continue. + Visible but not approved → reject as today. +3. OAuth: cached credentials for the entity → read `/profiles/{id}`, `/addresses`, `/ibans` + with the user bearer token through a small adapter that validates responses with the + shared zod schemas from `packages/shared/src/services/monerium/schemas.ts`. Approved → + source `oauth`. No cached credentials → `MONERIUM_REAUTHENTICATION_REQUIRED`. + +`createRegisterMoneriumIssue` replaces its `resolveProfileId` + `getClient` dependencies +with `resolveIdentity`; the destination matching, EOA check, baseline read, and facts stay +unchanged. The source is logged, not persisted. + +Error contract on `POST /v1/ramp/register`: `MONERIUM_ONBOARDING_REQUIRED` and +`MONERIUM_REAUTHENTICATION_REQUIRED` become documented public error types (OpenAPI, +wire-contract snapshot, SDK error mapping). + +Tests: resolver order with fakes for both clients; registration tests for each source; +hermetic contract coverage for the user-token read schemas. + +## Phase 2: API onboarding and readiness + +1. `POST /v1/monerium/oauth/start` accepts a `client` selector (`dashboard` | `widget`). + The redirect URI comes from an allowlist (`MONERIUM_REDIRECT_URI`, + `MONERIUM_WIDGET_REDIRECT_URI`) and is bound into the OAuth transaction exactly as + today. Both URIs are registered with Monerium. Link-at-login is dead (P2), so the start + request carries no wallet parameters. +2. Wallet link, new route `POST /v1/monerium/wallet` (bearer session): body + `{ address, chain, signature }` where `signature` is the user's EOA signature over the + fixed link message. The backend verifies it with viem `verifyMessage`, rejects + addresses with deployed code (the permit needs an EOA), then calls `POST /addresses` + with the user's OAuth token. `MONERIUM_REAUTHENTICATION_REQUIRED` when no token is + cached. +3. IBAN provisioning (one IBAN per profile, P2): on wallet link and on status refresh, + read `GET /ibans?profile=` with the user token. + - none: `POST /ibans { address, chain }` (`202`), readiness `pending` until it appears; + - present on the linked address and flow chain: `provisioned`; + - present elsewhere: `elsewhere`; the client offers an explicit user-confirmed move + (`PATCH /ibans/{iban}`) because it redirects the user's future SEPA deposits. The + backend never moves an IBAN without that request. + Nothing is persisted; Monerium stays authoritative. +4. Readiness: extend `GET /v1/monerium/status` (and the Monerium account entry of + `GET /v1/onboarding/status`) with + `ramp: { source, linkedAddress, chain, iban: "provisioned" | "pending" | "elsewhere" | "missing" }`. + For OAuth users this read needs a live token; without one the existing + `MONERIUM_REAUTHENTICATION_REQUIRED` error is returned and clients prompt reconnect. + +Managed children, quote simulation, execution, and the B2B onramp are unchanged. + +## Phase 3: dashboard + +- EU corridor card reads `ramp` readiness. Approved without a linked wallet or IBAN shows a + "Link wallet" step: connect wallet, sign the link message, call `POST /v1/monerium/wallet`, + then poll until the IBAN is provisioned (or confirm a move when it is `elsewhere`). +- Transfer machine, EUR BUY: the connected wallet must equal `ramp.linkedAddress` before + registration; the owner permit is signed with the existing `signMultipleTypedData`; + `updateRamp` carries ephemeral presigns plus the permit; `ibanPaymentData` from the + response renders the SEPA instructions; then `startRamp`. +- `MONERIUM_REAUTHENTICATION_REQUIRED` from registration reopens the reconnect prompt and + retries registration afterwards. + +## Phase 4: widget + +- SEPA/EUR onboarding routes to a Monerium flow built on the shared `@vortexfi/kyc` + Monerium machine behind the existing Supabase OTP login. The Mykobo form stays dormant. +- Authorization opens as top-level navigation when the widget is the top document and in a + new tab when embedded; a `/monerium/callback` route completes the exchange. The + persisted ramp snapshot in `localStorage` already survives the redirect; it no longer + carries any token. +- The connected wallet is linked after OAuth completion through `POST /v1/monerium/wallet` + (the widget is wallet-first, so the address and `signMessage` are available). Permit + signing reuses `userSigning.ts`. The registered `http://localhost:5473/widget` callback + matches the legacy widget pattern of using its own route as the redirect target. +- `kybRegions.ts` and the phase messages are updated accordingly. + +## Phase 5: SDK and direct API + +- `VortexSdk.registerRamp` returns user-owned `unsignedTransactions` for SEPA BUY instead + of forcing an empty list; the EUR handler keeps owner-signed transactions and + `updateRamp` accepts the permit signature through `submitUserSignature`. +- README and `ARCHITECTURE.md` drop the "direct API only" caveat and document the + onboarding prerequisite (dashboard or widget) plus the two new error types. + +## Phase 6: documentation and security spec + +- `security-spec/05-integrations/monerium.md`: invariants for the resolution order, the + server-side link-signature verification, the EOA requirement at link time, the redirect + allowlist, the unchanged memory-only token rule, and the still-forbidden caller-supplied + profile identity. The "Deferred OAuth" sections become the active description. +- `RISK-REGISTER.md`: OAuth-app profiles that the white-label app cannot see remain + dependent on backend token presence; migration between apps is still undefined. +- `operations-monerium-interface.md`, API pages, OpenAPI, and wire-contract snapshot. + +## Commit slices + +One stacked PR, one logical commit per phase: probe results (docs), API resolver and +adapter, API onboarding and readiness, dashboard, widget, SDK, docs and security spec. + +## Open items + +- Token persistence: with P1 answered, every OAuth-user registration depends on a cached + backend token. Memory-only is the current decision; an encrypted refresh-token store is + the alternative if reauthentication prompts prove too frequent. +- Embedded-widget authorization strategy (new tab versus popup) once the embed contract is + checked. +- Whether the dormant Mykobo widget form is removed in this PR or later. From 33d12a00e388ee3701b57b22e3de3ae7a4a93d8e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 14 Sep 2026 17:04:09 +0200 Subject: [PATCH 02/42] feat(shared): add a user-token Monerium client MoneriumApiService.forUserAccessToken builds a client that acts as an end user of the Monerium OAuth app with the same transport, schemas, redaction, and timeouts as the white-label singleton. It never requests a client token and lets a 401 surface so the caller can require reauthentication. --- .../monerium/moneriumApiService.test.ts | 28 ++++++++++++ .../services/monerium/moneriumApiService.ts | 43 ++++++++++++++++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/services/monerium/moneriumApiService.test.ts b/packages/shared/src/services/monerium/moneriumApiService.test.ts index eb134acbc..88f13c698 100644 --- a/packages/shared/src/services/monerium/moneriumApiService.test.ts +++ b/packages/shared/src/services/monerium/moneriumApiService.test.ts @@ -331,3 +331,31 @@ describe("MoneriumApiService resource mappings", () => { expect((fetchMock.mock.calls[2][1] as RequestInit).body).toBe(JSON.stringify({ state: "inactive" })); }); }); + +describe("MoneriumApiService.forUserAccessToken", () => { + test("sends the user token, never requests a client token, and does not retry a 401", async () => { + const calls: Array<{ auth: string | null; url: string }> = []; + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ auth: new Headers(init?.headers).get("Authorization"), url: String(input) }); + if (calls.length === 1) { + return Response.json({ + details: { state: "approved" }, + form: { state: "approved" }, + id: PROFILE_ID, + kind: "personal", + name: "Jane Doe", + state: "approved", + verifications: [] + }); + } + return new Response("", { status: 401 }); + }) as unknown as typeof fetch; + + const client = MoneriumApiService.forUserAccessToken("user-token"); + await expect(client.getProfile(PROFILE_ID)).resolves.toMatchObject({ id: PROFILE_ID, state: "approved" }); + await expect(client.getProfile(PROFILE_ID)).rejects.toMatchObject({ status: 401 }); + + expect(calls).toHaveLength(2); + expect(calls.every(call => call.auth === "Bearer user-token" && !call.url.endsWith("/auth/token"))).toBe(true); + }); +}); diff --git a/packages/shared/src/services/monerium/moneriumApiService.ts b/packages/shared/src/services/monerium/moneriumApiService.ts index ad22074b9..1038e6f28 100644 --- a/packages/shared/src/services/monerium/moneriumApiService.ts +++ b/packages/shared/src/services/monerium/moneriumApiService.ts @@ -94,6 +94,18 @@ export function buildMoneriumSepaRedemptionMessage(amount: string, iban: string, return `Send EUR ${amount} to ${iban} at ${minute}`; } +export type MoneriumUserApiClient = Pick< + MoneriumApiService, + | "getAddress" + | "getIban" + | "getProfile" + | "linkAddress" + | "listAddresses" + | "listIbans" + | "requestIban" + | "updateIbanDestination" +>; + export class MoneriumApiService { private static instance: MoneriumApiService; @@ -107,19 +119,28 @@ export class MoneriumApiService { private tokenPromise: Promise | undefined; - private constructor() { + private readonly userAccessToken: string | undefined; + + private constructor(auth?: { accessToken: string }) { if (typeof window !== "undefined") { throw new Error("MoneriumApiService is server-only"); } + this.baseUrl = MONERIUM_API_URL.replace(/\/$/, ""); + if (new URL(this.baseUrl).protocol !== "https:") { + throw new Error("MONERIUM_API_URL must use https://"); + } + if (auth) { + // A user-token client never authenticates itself; the token's lifecycle belongs to the caller. + this.userAccessToken = auth.accessToken; + this.clientId = ""; + this.clientSecret = ""; + return; + } const clientId = process.env.MONERIUM_WHITELABEL_CLIENT_ID; const clientSecret = process.env.MONERIUM_WHITELABEL_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error("MONERIUM_WHITELABEL_CLIENT_ID or MONERIUM_WHITELABEL_CLIENT_SECRET not defined"); } - this.baseUrl = MONERIUM_API_URL.replace(/\/$/, ""); - if (new URL(this.baseUrl).protocol !== "https:") { - throw new Error("MONERIUM_API_URL must use https://"); - } this.clientId = clientId; this.clientSecret = clientSecret; } @@ -131,6 +152,15 @@ export class MoneriumApiService { return MoneriumApiService.instance; } + /** + * Client acting as an end user of the Monerium authorization-code (OAuth) app. Same transport, + * schemas, redaction, and timeouts as the white-label singleton, but authenticated with the + * supplied user access token. A `401` surfaces unchanged so the caller can require reauthentication. + */ + public static forUserAccessToken(accessToken: string): MoneriumUserApiClient { + return new MoneriumApiService({ accessToken }); + } + private async acquireToken(): Promise { const endpoint = "/auth/token"; const form = new URLSearchParams({ @@ -169,6 +199,7 @@ export class MoneriumApiService { } private async getAccessToken(): Promise { + if (this.userAccessToken) return this.userAccessToken; if (this.cachedToken && this.cachedToken.expiresAt - TOKEN_EXPIRY_SKEW_MS > Date.now()) { return this.cachedToken.value; } @@ -199,7 +230,7 @@ export class MoneriumApiService { const serializedBody = body === undefined || body instanceof FormData ? body : JSON.stringify(body); let token = await this.getAccessToken(); let response = await this.performFetch(url, path, method, token, serializedBody); - if (response.status === 401) { + if (response.status === 401 && !this.userAccessToken) { if (this.cachedToken?.value === token) this.cachedToken = undefined; token = await this.getAccessToken(); response = await this.performFetch(url, path, method, token, serializedBody); From 0b9af0665515fe9c6144a629b1f74ca04ce69368 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 14 Sep 2026 17:04:09 +0200 Subject: [PATCH 03/42] feat(api): resolve Monerium identity through either app EUR onramp registration now resolves the bound profile through the white-label app first and, when that app answers 403 or 404 for it, through the user's backend-held OAuth token. The white-label app cannot see profiles that only authorized the OAuth app (sandbox probe P1), so this is the only read path for OAuth-onboarded users. A missing binding fails with MONERIUM_ONBOARDING_REQUIRED and a missing or rejected OAuth session with MONERIUM_REAUTHENTICATION_REQUIRED; the serving app is logged, never persisted. --- .../api/services/monerium/identity.test.ts | 108 ++++++++++++++++++ .../api/src/api/services/monerium/identity.ts | 102 +++++++++++++++++ .../api/services/monerium/monerium.service.ts | 8 ++ .../monerium-issue.registration.test.ts | 49 ++++---- .../phases/monerium-issue/registration.ts | 46 ++------ .../security-spec/05-integrations/monerium.md | 9 +- 6 files changed, 256 insertions(+), 66 deletions(-) create mode 100644 apps/api/src/api/services/monerium/identity.test.ts create mode 100644 apps/api/src/api/services/monerium/identity.ts diff --git a/apps/api/src/api/services/monerium/identity.test.ts b/apps/api/src/api/services/monerium/identity.test.ts new file mode 100644 index 000000000..c13a06289 --- /dev/null +++ b/apps/api/src/api/services/monerium/identity.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, mock } from "bun:test"; +import { MoneriumApiError } from "@vortexfi/shared"; +import { APIError } from "../../errors/api-error"; +import { createResolveMoneriumIdentity, MONERIUM_ONBOARDING_REQUIRED, type MoneriumBinding } from "./identity"; +import { MONERIUM_REAUTHENTICATION_REQUIRED } from "./monerium.service"; + +const PROFILE_ID = "9e6a92a5-5f6d-48aa-a57b-0f8ae8eb745d"; +const binding: MoneriumBinding = { customerEntityId: "entity-1", customerType: "individual", profileId: PROFILE_ID }; + +function profile() { + return { + details: { state: "approved" as const }, + form: { state: "approved" as const }, + id: PROFILE_ID, + kind: "personal" as const, + name: "Ada Example", + state: "approved" as const, + verifications: [] + }; +} + +function apiError(status: number) { + return new MoneriumApiError({ endpoint: "/profiles/:profile", method: "GET", status }); +} + +function client(getProfile: () => Promise>) { + return { getProfile: mock(getProfile), listAddresses: mock(async () => ({ addresses: [] })), listIbans: mock(async () => ({ ibans: [] })) }; +} + +describe("resolveMoneriumIdentity", () => { + it("uses the white-label app when it can read the bound profile", async () => { + const whiteLabel = client(async () => profile()); + const getUserClient = mock(async () => client(async () => profile())); + const resolve = createResolveMoneriumIdentity({ + getUserClient, + getWhiteLabelClient: () => whiteLabel, + loadBinding: async () => binding + }); + + const identity = await resolve("user-1"); + + expect(identity).toMatchObject({ client: whiteLabel, profileId: PROFILE_ID, source: "whitelabel" }); + expect(identity.profile.state).toBe("approved"); + expect(getUserClient).not.toHaveBeenCalled(); + }); + + it.each([403, 404])("falls back to the user's OAuth token when the white-label app answers %i", async status => { + const user = client(async () => profile()); + const getUserClient = mock(async () => user); + const resolve = createResolveMoneriumIdentity({ + getUserClient, + getWhiteLabelClient: () => client(async () => Promise.reject(apiError(status))), + loadBinding: async () => binding + }); + + const identity = await resolve("user-1"); + + expect(identity).toMatchObject({ client: user, profileId: PROFILE_ID, source: "oauth" }); + expect(getUserClient).toHaveBeenCalledWith("entity-1", "individual"); + }); + + it("propagates white-label failures other than invisibility instead of switching apps", async () => { + const getUserClient = mock(async () => client(async () => profile())); + const resolve = createResolveMoneriumIdentity({ + getUserClient, + getWhiteLabelClient: () => client(async () => Promise.reject(apiError(503))), + loadBinding: async () => binding + }); + + await expect(resolve("user-1")).rejects.toMatchObject({ status: 503 }); + expect(getUserClient).not.toHaveBeenCalled(); + }); + + it.each([null, { ...binding, profileId: null }])("requires a Monerium binding with a profile (%p)", async loaded => { + const resolve = createResolveMoneriumIdentity({ + getUserClient: async () => client(async () => profile()), + getWhiteLabelClient: () => client(async () => profile()), + loadBinding: async () => loaded + }); + + const error = await resolve("user-1").catch(caught => caught); + expect(error).toBeInstanceOf(APIError); + expect(error).toMatchObject({ isPublic: true, status: 403, type: MONERIUM_ONBOARDING_REQUIRED }); + }); + + it("surfaces a missing OAuth session as reauthentication required", async () => { + const reauth = new APIError({ message: "Monerium reauthentication is required", status: 404, type: MONERIUM_REAUTHENTICATION_REQUIRED }); + const resolve = createResolveMoneriumIdentity({ + getUserClient: async () => Promise.reject(reauth), + getWhiteLabelClient: () => client(async () => Promise.reject(apiError(403))), + loadBinding: async () => binding + }); + + await expect(resolve("user-1")).rejects.toMatchObject({ type: MONERIUM_REAUTHENTICATION_REQUIRED }); + }); + + it("maps a rejected user token to reauthentication required", async () => { + const resolve = createResolveMoneriumIdentity({ + getUserClient: async () => client(async () => Promise.reject(apiError(401))), + getWhiteLabelClient: () => client(async () => Promise.reject(apiError(404))), + loadBinding: async () => binding + }); + + const error = await resolve("user-1").catch(caught => caught); + expect(error).toBeInstanceOf(APIError); + expect(error).toMatchObject({ isPublic: true, status: 404, type: MONERIUM_REAUTHENTICATION_REQUIRED }); + }); +}); diff --git a/apps/api/src/api/services/monerium/identity.ts b/apps/api/src/api/services/monerium/identity.ts new file mode 100644 index 000000000..80a4f3459 --- /dev/null +++ b/apps/api/src/api/services/monerium/identity.ts @@ -0,0 +1,102 @@ +import { MoneriumApiError, MoneriumApiService, type MoneriumProfile } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import type { Transaction } from "sequelize"; +import ProviderCustomer, { type ProviderCustomerType } from "../../../models/providerCustomer.model"; +import { APIError } from "../../errors/api-error"; +import { getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; +import { getMoneriumUserAccessToken, MONERIUM_REAUTHENTICATION_REQUIRED } from "./monerium.service"; + +export const MONERIUM_ONBOARDING_REQUIRED = "MONERIUM_ONBOARDING_REQUIRED"; + +export type MoneriumIdentitySource = "whitelabel" | "oauth"; + +export type MoneriumIdentityClient = Pick; + +export interface MoneriumBinding { + customerEntityId: string; + customerType: ProviderCustomerType; + profileId: string | null; +} + +export interface MoneriumIdentity { + client: MoneriumIdentityClient; + profile: MoneriumProfile; + profileId: string; + source: MoneriumIdentitySource; +} + +export interface MoneriumIdentityDependencies { + getUserClient: (customerEntityId: string, customerType: ProviderCustomerType) => Promise; + getWhiteLabelClient: () => MoneriumIdentityClient; + loadBinding: (userId: string, transaction?: Transaction) => Promise; +} + +async function loadMoneriumBinding(userId: string, transaction?: Transaction): Promise { + const entity = await getOrCreateCustomerEntityForProfile(userId, undefined, transaction); + const binding = await ProviderCustomer.findOne({ + ...(transaction ? { transaction } : {}), + where: { customerEntityId: entity.id, customerType: entity.type, provider: "monerium", rail: "eur" } + }); + return { customerEntityId: entity.id, customerType: entity.type, profileId: binding?.providerCustomerId ?? null }; +} + +async function getUserClient(customerEntityId: string, customerType: ProviderCustomerType): Promise { + return MoneriumApiService.forUserAccessToken(await getMoneriumUserAccessToken(customerEntityId, customerType)); +} + +function isInvisibleToApp(error: unknown): boolean { + return error instanceof MoneriumApiError && (error.status === 403 || error.status === 404); +} + +/** + * Resolves which Monerium application can read the authenticated user's profile: the white-label + * app first (client credentials), then the OAuth app through the user's backend-held token. Both + * apps share one `provider_customers` binding; the white-label API answers 403/404 for profiles + * that only authorized the OAuth app. The source is decided per call and never persisted. + */ +export function createResolveMoneriumIdentity( + dependencies: MoneriumIdentityDependencies = { + getUserClient, + getWhiteLabelClient: () => MoneriumApiService.getInstance(), + loadBinding: loadMoneriumBinding + } +) { + return async function resolveMoneriumIdentity(userId: string, transaction?: Transaction): Promise { + const binding = await dependencies.loadBinding(userId, transaction); + if (!binding?.profileId) { + throw new APIError({ + isPublic: true, + message: "Monerium onboarding is required before an EUR ramp can be registered", + status: httpStatus.FORBIDDEN, + type: MONERIUM_ONBOARDING_REQUIRED + }); + } + const profileId = binding.profileId; + + const whiteLabel = dependencies.getWhiteLabelClient(); + try { + const profile = await whiteLabel.getProfile(profileId); + return { client: whiteLabel, profile, profileId, source: "whitelabel" }; + } catch (error) { + if (!isInvisibleToApp(error)) throw error; + } + + const user = await dependencies.getUserClient(binding.customerEntityId, binding.customerType); + try { + const profile = await user.getProfile(profileId); + return { client: user, profile, profileId, source: "oauth" }; + } catch (error) { + if (error instanceof MoneriumApiError && error.status === 401) { + throw new APIError({ + isPublic: true, + message: "Monerium reauthentication is required", + status: httpStatus.NOT_FOUND, + type: MONERIUM_REAUTHENTICATION_REQUIRED + }); + } + throw error; + } + }; +} + +export const resolveMoneriumIdentity = createResolveMoneriumIdentity(); diff --git a/apps/api/src/api/services/monerium/monerium.service.ts b/apps/api/src/api/services/monerium/monerium.service.ts index 6c6751263..fb0822a68 100644 --- a/apps/api/src/api/services/monerium/monerium.service.ts +++ b/apps/api/src/api/services/monerium/monerium.service.ts @@ -219,6 +219,14 @@ async function getValidCredentials(customerEntityId: string, customerType: Provi } } +/** Access token of the entity's backend-held OAuth session; throws `MONERIUM_REAUTHENTICATION_REQUIRED` when none is cached. */ +export async function getMoneriumUserAccessToken( + customerEntityId: string, + customerType: ProviderCustomerType +): Promise { + return (await getValidCredentials(customerEntityId, customerType)).accessToken; +} + async function readProfile( credentials: MoneriumCredentials, customerType: ProviderCustomerType diff --git a/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts index 5c0fa9cb7..30a64d09a 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts @@ -40,6 +40,10 @@ function client( }; } +function identity(monerium: ReturnType, state: "approved" | "pending" = "approved") { + return { client: monerium as never, profile: profile(state), profileId: PROFILE_ID, source: "whitelabel" as const }; +} + function context(input: Record = {}, network: MoneriumIssueNetwork = Networks.Base) { return { authenticatedUser: { id: "effective-user-1" }, @@ -53,20 +57,19 @@ function context(input: Record = {}, network: MoneriumIssueNetw describe("MoneriumIssue registration", () => { it("derives the Polygon owner and persists its EURe balance baseline", async () => { const monerium = client({ chain: "polygon" }); - const resolveProfileId = mock(async () => PROFILE_ID); + const resolveIdentity = mock(async () => identity(monerium)); const readOwnerEureBalance = mock(async () => new Big("5000000000000000000")); const register = createRegisterMoneriumIssue({ createReference: () => "VTX00000000000000000000000000000001", - getClient: () => monerium as never, - isContractAddress: async () => false, + isContractAddress: async () => false, readOwnerEureBalance, - resolveProfileId + resolveIdentity }); const result = await register(context({}, Networks.Polygon)); - expect(resolveProfileId).toHaveBeenCalledWith("effective-user-1", undefined); - expect(monerium.getProfile).toHaveBeenCalledWith(PROFILE_ID); + expect(resolveIdentity).toHaveBeenCalledWith("effective-user-1", undefined); + expect(monerium.getProfile).not.toHaveBeenCalled(); expect(monerium.listAddresses).toHaveBeenCalledWith({ chain: "polygon", profile: PROFILE_ID }); expect(monerium.listIbans).toHaveBeenCalledWith({ chain: "polygon", profile: PROFILE_ID }); expect(readOwnerEureBalance).toHaveBeenCalledWith({ @@ -102,10 +105,9 @@ describe("MoneriumIssue registration", () => { const isContractAddress = mock(async () => false); const register = createRegisterMoneriumIssue({ createReference: () => "VTX00000000000000000000000000000002", - getClient: () => monerium as never, - isContractAddress, + isContractAddress, readOwnerEureBalance: async () => new Big(0), - resolveProfileId: async () => PROFILE_ID + resolveIdentity: async () => identity(monerium) }); const result = await register(context({}, Networks.PolygonAmoy)); @@ -117,28 +119,26 @@ describe("MoneriumIssue registration", () => { }); it("rejects caller-controlled identity before resolving any provider customer", async () => { - const resolveProfileId = mock(async () => PROFILE_ID); + const resolveIdentity = mock(async () => identity(client())); const register = createRegisterMoneriumIssue({ createReference: () => "unused", - getClient: () => client() as never, - isContractAddress: async () => false, + isContractAddress: async () => false, readOwnerEureBalance: async () => new Big(0), - resolveProfileId + resolveIdentity }); await expect(register(context({ profileId: "foreign-profile" }))).rejects.toThrow( "Monerium identity is server-derived; profileId must not be supplied" ); - expect(resolveProfileId).not.toHaveBeenCalled(); + expect(resolveIdentity).not.toHaveBeenCalled(); }); it("requires the live profile to remain approved", async () => { const register = createRegisterMoneriumIssue({ createReference: () => "unused", - getClient: () => client({ state: "pending" }) as never, - isContractAddress: async () => false, + isContractAddress: async () => false, readOwnerEureBalance: async () => new Big(0), - resolveProfileId: async () => PROFILE_ID + resolveIdentity: async () => identity(client({ state: "pending" }), "pending") }); await expect(register(context())).rejects.toThrow("The Monerium profile is not approved"); @@ -147,10 +147,9 @@ describe("MoneriumIssue registration", () => { it("rejects a profile-linked contract wallet that cannot sign the EOA permit", async () => { const register = createRegisterMoneriumIssue({ createReference: () => "unused", - getClient: () => client() as never, - isContractAddress: async () => true, + isContractAddress: async () => true, readOwnerEureBalance: async () => new Big(0), - resolveProfileId: async () => PROFILE_ID + resolveIdentity: async () => identity(client()) }); await expect(register(context())).rejects.toThrow("self-transfer requires a profile-linked EOA"); @@ -168,10 +167,9 @@ describe("MoneriumIssue registration", () => { ])("rejects a %s provider-chain IBAN/address match", async (_label, ibans) => { const register = createRegisterMoneriumIssue({ createReference: () => "unused", - getClient: () => client({ ibans }) as never, - isContractAddress: async () => false, + isContractAddress: async () => false, readOwnerEureBalance: async () => new Big(0), - resolveProfileId: async () => PROFILE_ID + resolveIdentity: async () => identity(client({ ibans })) }); await expect(register(context())).rejects.toThrow("Expected exactly one Monerium base IBAN/address match"); @@ -180,12 +178,11 @@ describe("MoneriumIssue registration", () => { it("fails registration when the owner baseline cannot be read", async () => { const register = createRegisterMoneriumIssue({ createReference: () => "unused", - getClient: () => client({ chain: "polygon" }) as never, - isContractAddress: async () => false, + isContractAddress: async () => false, readOwnerEureBalance: async () => { throw new Error("RPC unavailable"); }, - resolveProfileId: async () => PROFILE_ID + resolveIdentity: async () => identity(client({ chain: "polygon" })) }); await expect(register(context({}, Networks.Polygon))).rejects.toThrow("RPC unavailable"); diff --git a/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts b/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts index b85b314e9..ae422d101 100644 --- a/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts +++ b/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts @@ -4,15 +4,14 @@ import { getEvmTokenBalance, type IbanPaymentData, type MoneriumAddress, - MoneriumApiService, type MoneriumIban } from "@vortexfi/shared"; import crypto from "crypto"; import httpStatus from "http-status"; import { isAddress } from "viem"; -import ProviderCustomer, { VerificationStatus } from "../../../../../../models/providerCustomer.model"; +import logger from "../../../../../../config/logger"; import { APIError } from "../../../../../errors/api-error"; -import { getOrCreateCustomerEntityForProfile } from "../../../../customer-entity.service"; +import { type MoneriumIdentity, resolveMoneriumIdentity } from "../../../../monerium/identity"; import type { RegisterCtx, RegistrationResult } from "../../core/types"; import { MONERIUM_EURE, MONERIUM_ISSUE_NETWORKS, type MoneriumIssueMetadata, type MoneriumIssueNetwork } from "./simulation"; @@ -52,37 +51,9 @@ export interface MoneriumIssueResponseArtifacts extends Record interface MoneriumIssueRegistrationDependencies { createReference: () => string; - getClient: () => Pick; isContractAddress?: (network: MoneriumIssueNetwork, address: `0x${string}`) => Promise; readOwnerEureBalance: typeof getEvmTokenBalance; - resolveProfileId: (userId: string, transaction?: RegisterCtx["transaction"]) => Promise; -} - -async function resolveMoneriumProfileIdForUser( - userId: string, - transaction?: RegisterCtx["transaction"] -): Promise { - const entity = await getOrCreateCustomerEntityForProfile(userId, undefined, transaction); - const providerCustomer = await ProviderCustomer.findOne({ - ...(transaction ? { transaction } : {}), - where: { - customerEntityId: entity.id, - customerType: entity.type, - provider: "monerium", - rail: "eur" - } - }); - if ( - !providerCustomer?.providerCustomerId || - providerCustomer.status !== VerificationStatus.Approved || - providerCustomer.statusExternal?.toLowerCase() !== "approved" - ) { - throw new APIError({ - message: "The authenticated legal entity does not have an approved Monerium profile", - status: httpStatus.BAD_REQUEST - }); - } - return providerCustomer.providerCustomerId; + resolveIdentity: (userId: string, transaction?: RegisterCtx["transaction"]) => Promise; } function createPaymentReference(): string { @@ -112,9 +83,8 @@ function matchingDestinations( export function createRegisterMoneriumIssue( dependencies: MoneriumIssueRegistrationDependencies = { createReference: createPaymentReference, - getClient: () => MoneriumApiService.getInstance(), readOwnerEureBalance: getEvmTokenBalance, - resolveProfileId: resolveMoneriumProfileIdForUser + resolveIdentity: resolveMoneriumIdentity } ) { return async function registerMoneriumIssue( @@ -128,12 +98,14 @@ export function createRegisterMoneriumIssue( }); } - const profileId = await dependencies.resolveProfileId(ctx.authenticatedUser.id, ctx.transaction); - const client = dependencies.getClient(); - const profile = await client.getProfile(profileId); + const { client, profile, profileId, source } = await dependencies.resolveIdentity( + ctx.authenticatedUser.id, + ctx.transaction + ); if (profile.id !== profileId || profile.state !== "approved") { throw new APIError({ message: "The Monerium profile is not approved", status: httpStatus.BAD_REQUEST }); } + logger.info(`MoneriumIssue: resolved the Monerium profile through the ${source} app`); const moneriumChain = MONERIUM_ISSUE_NETWORKS[ctx.metadata.network].chain; const [addressResponse, ibanResponse] = await Promise.all([ diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 91d587e2c..e3902dba4 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -12,7 +12,10 @@ movement, EURe redemption orders, supporting-document uploads, and webhook subsc white-label credentials, tokens, and API calls remain backend-only. The client supplies profile status, wallet ownership, and IBAN data for the active Polygon EUR -onramp. New SEPA/EUR BUY quotes resolve exclusively to `MoneriumOnrampPolygonCrossChain`; Mykobo +onramp. Registration resolves the user's profile through the white-label app first and falls back +to the same read operations with the user's backend-held OAuth token when the white-label app cannot +see the profile (`MoneriumApiService.forUserAccessToken`), so users onboarded through either +Monerium application can ramp. New SEPA/EUR BUY quotes resolve exclusively to `MoneriumOnrampPolygonCrossChain`; Mykobo flows remain executable only for persisted legacy quotes and ramps. New SEPA/EUR SELL quotes are rejected with public `400 Bad Request` because no active EUR offramp exists. @@ -89,7 +92,7 @@ the active onramp; the first-party clients cannot yet do so. 13. Live contract mutations MUST target exactly `https://api.monerium.dev` and remain independently opt-in. An order contract test MUST NOT run from credentials alone because it can move sandbox EURe. 14. New SEPA/EUR BUY quotes MUST resolve only to the Polygon Monerium flow. New EUR SELL quotes MUST return a public `400` and MUST NOT fall back to a Mykobo flow. 15. Production startup MUST fail without a Monerium auth-code client ID, exact callback URI, `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET`, and explicit non-negative `MONERIUM_ISSUE_FEE_EUR`. The issue fee MUST NOT silently default to zero. Credentials MUST NOT be accepted from client requests. -16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's canonical legal entity and approved provider customer. It MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. +16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's canonical legal entity and its `monerium`/`eur` provider-customer binding, and MUST read that profile through the white-label app first and, only when the white-label API answers `403` or `404` for it, through the user's backend-held OAuth token (`resolveMoneriumIdentity`). A missing binding MUST fail with `MONERIUM_ONBOARDING_REQUIRED`; a missing or rejected OAuth session MUST fail with `MONERIUM_REAUTHENTICATION_REQUIRED`; any other white-label failure MUST NOT switch apps. The live profile MUST be `approved`. Which app served the profile is logged, never persisted. Registration MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. 17. Self-transfer registration MUST copy only owner, token, chain, and amount from trusted `monerium-issue` facts and MUST reject an owner that is also the EVM ephemeral. Its EURe permit and exact `transferFrom` MUST be independently validated and reconciled; strict presign completeness MUST require both the user-signed permit and ephemeral-signed transfer. A still-current permit MUST be consumed even when allowance already exists, while an advanced nonce or expired deadline may prove it non-replayable. Permit and transfer hashes MUST remain in namespaced block state, and successful execution MUST verify receipts and the exact allowance reduction. 18. The Polygon conversion MUST verify the pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. 19. Issue execution MUST wait for `currentOwnerBalance >= persistedBaseline + quotedPostFeeEureRaw`. Timeouts and exhausted RPC reads are recoverable. Missing or malformed settlement facts are unrecoverable corruption. The executor MUST transfer only the quoted post-fee amount; excess EURe remains in the owner wallet. This non-deterministic attribution exception is accepted only under RISK-023. @@ -121,7 +124,7 @@ the active onramp; the first-party clients cannot yet do so. - [x] Monerium wire schemas have shared unit coverage and an environment-gated API sandbox contract suite; mutating probes are separately opt-in. - [x] Contract-test mutations refuse production and non-root sandbox URLs. - [x] Production configuration requires the auth-code client ID, exact callback URI, white-label credential pair, and explicit non-negative issue fee. -- [x] Issue simulation is auth-free and fee-injected; registration derives an approved profile and exactly one existing Polygon EOA IBAN/address match, rejects contract wallets, and persists the owner's EURe baseline. +- [x] Issue simulation is auth-free and fee-injected; registration resolves the bound profile through the white-label app or, when invisible there, the user's OAuth token (`identity.test.ts`), requires exactly one existing Polygon EOA IBAN/address match, rejects contract wallets, and persists the owner's EURe baseline. - [x] Issue execution waits recoverably for the owner's EURe balance to increase by the quoted post-fee amount and documents the accepted non-deterministic attribution limitation. - [x] Self-transfer preparation binds an owner permit and ephemeral exact `transferFrom`; execution consumes or proves the permit non-replayable and reconciles both operations independently. - [x] Polygon conversion verifies the pinned EURe/USDC Uniswap V3 deployment, quotes exact input, prepares exact approval and `exactInputSingle` transactions, validates their signed semantics, and reconciles allowance, receipt, and output thresholds. From 1acb2092ba0d48c1841f0e6f85d03b2fb28bbee7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 14 Sep 2026 17:27:49 +0200 Subject: [PATCH 04/42] feat(api): select the Monerium OAuth callback per client The widget will run the same backend-driven OAuth flow as the dashboard, so the start request names its client and the backend picks the exact callback from the configured allowlist (MONERIUM_REDIRECT_URI, MONERIUM_WIDGET_REDIRECT_URI) and binds it into the OAuth transaction. Caller-supplied redirect URIs stay impossible; a mismatch with Monerium's registered URIs renders an empty authorization page, so the widget flow is refused outright until its callback is configured. --- apps/api/.env.example | 2 ++ .../api/controllers/monerium.controller.ts | 23 +++++++++++++++++-- .../monerium/monerium.service.test.ts | 16 +++++++++++++ .../api/services/monerium/monerium.service.ts | 18 +++++++++++++-- apps/api/src/config/vars.ts | 4 +++- 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index 4f4709eca..85948e470 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -159,6 +159,8 @@ ALFREDPAY_API_SECRET=your-alfredpay-api-secret MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id MONERIUM_API_URL=https://api.monerium.dev MONERIUM_REDIRECT_URI=http://localhost:5174/dashboard/monerium/callback +# Exact widget callback registered with Monerium; leave unset to disable the widget OAuth flow. +MONERIUM_WIDGET_REDIRECT_URI=http://localhost:5473/widget # Server-to-server white-label access (shared client; also the Monerium B2B onramp # credentials). Keep this backend-only. MONERIUM_WHITELABEL_CLIENT_ID=your-monerium-whitelabel-client-id diff --git a/apps/api/src/api/controllers/monerium.controller.ts b/apps/api/src/api/controllers/monerium.controller.ts index 64f39307a..d032e6ba4 100644 --- a/apps/api/src/api/controllers/monerium.controller.ts +++ b/apps/api/src/api/controllers/monerium.controller.ts @@ -1,7 +1,13 @@ import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; import { APIError } from "../errors/api-error"; -import { completeMoneriumOAuth, getMoneriumStatus, startMoneriumOAuth } from "../services/monerium/monerium.service"; +import { + completeMoneriumOAuth, + getMoneriumStatus, + MONERIUM_OAUTH_CLIENTS, + type MoneriumOAuthClient, + startMoneriumOAuth +} from "../services/monerium/monerium.service"; type CustomerType = "individual" | "business"; @@ -12,6 +18,17 @@ function customerType(value: unknown): CustomerType { return value; } +function oauthClient(value: unknown): MoneriumOAuthClient { + if (value === undefined) return "dashboard"; + if (!MONERIUM_OAUTH_CLIENTS.includes(value as MoneriumOAuthClient)) { + throw new APIError({ + message: `client must be one of: ${MONERIUM_OAUTH_CLIENTS.join(", ")}`, + status: httpStatus.BAD_REQUEST + }); + } + return value as MoneriumOAuthClient; +} + function requiredString(value: unknown, name: string): string { if (typeof value !== "string" || value.length === 0 || value.length > 2048) { throw new APIError({ message: `${name} is required`, status: httpStatus.BAD_REQUEST }); @@ -36,7 +53,9 @@ export async function start(req: Request, res: Response, next: NextFunction): Pr ) { throw new APIError({ message: "email must match the authenticated user", status: httpStatus.BAD_REQUEST }); } - res.status(httpStatus.OK).json(await startMoneriumOAuth(user.userId, user.email, customerType(body.customerType))); + res + .status(httpStatus.OK) + .json(await startMoneriumOAuth(user.userId, user.email, customerType(body.customerType), oauthClient(body.client))); } catch (error) { next(error); } diff --git a/apps/api/src/api/services/monerium/monerium.service.test.ts b/apps/api/src/api/services/monerium/monerium.service.test.ts index c66880b41..8f95f5406 100644 --- a/apps/api/src/api/services/monerium/monerium.service.test.ts +++ b/apps/api/src/api/services/monerium/monerium.service.test.ts @@ -349,6 +349,22 @@ describe("Monerium OAuth", () => { }); }); + it("binds the widget callback from the allowlist and refuses it when unconfigured", async () => { + const previous = config.monerium.widgetRedirectUri; + try { + config.monerium.widgetRedirectUri = "https://widget.example.com/widget"; + const { authorizationUrl } = await service.startMoneriumOAuth("owner", "owner@example.com", "individual", "widget"); + expect(new URL(authorizationUrl).searchParams.get("redirect_uri")).toBe("https://widget.example.com/widget"); + + config.monerium.widgetRedirectUri = undefined; + await expect(service.startMoneriumOAuth("owner", "owner@example.com", "individual", "widget")).rejects.toMatchObject({ + status: 503 + }); + } finally { + config.monerium.widgetRedirectUri = previous; + } + }); + it("rejects a customer type that differs from the authenticated entity", async () => { await expect(service.startMoneriumOAuth("owner", "owner@example.com", "business")).rejects.toMatchObject({ status: 400 }); await expect(service.getMoneriumStatus("owner", "business")).rejects.toMatchObject({ status: 400 }); diff --git a/apps/api/src/api/services/monerium/monerium.service.ts b/apps/api/src/api/services/monerium/monerium.service.ts index fb0822a68..bf2b3cfda 100644 --- a/apps/api/src/api/services/monerium/monerium.service.ts +++ b/apps/api/src/api/services/monerium/monerium.service.ts @@ -16,6 +16,8 @@ import { cache } from "../index"; const OAUTH_TRANSACTION_TTL_SECONDS = 10 * 60; const FETCH_TIMEOUT_MS = 10_000; export const MONERIUM_REAUTHENTICATION_REQUIRED = "MONERIUM_REAUTHENTICATION_REQUIRED"; +export const MONERIUM_OAUTH_CLIENTS = ["dashboard", "widget"] as const; +export type MoneriumOAuthClient = (typeof MONERIUM_OAUTH_CLIENTS)[number]; const TOKEN_EXPIRY_SKEW_MS = 30_000; const CREDENTIAL_TTL_SECONDS = 24 * 60 * 60; const API_V2_ACCEPT = "application/vnd.monerium.api-v2+json"; @@ -313,14 +315,26 @@ async function mirrorProfile( return { customerType, profileId: profile.id, status, statusExternal: profile.state }; } +function redirectUriForClient(client: MoneriumOAuthClient): string { + if (client === "dashboard") return config.monerium.redirectUri; + if (!config.monerium.widgetRedirectUri) { + throw new APIError({ message: "Monerium widget callback is not configured", status: httpStatus.SERVICE_UNAVAILABLE }); + } + return config.monerium.widgetRedirectUri; +} + export async function startMoneriumOAuth( userId: string, email: string, - customerType: ProviderCustomerType + customerType: ProviderCustomerType, + client: MoneriumOAuthClient = "dashboard" ): Promise<{ authorizationUrl: string }> { if (!config.monerium.clientId) { throw new APIError({ message: "Monerium OAuth is not configured", status: httpStatus.SERVICE_UNAVAILABLE }); } + // The callback is chosen from the configured allowlist, never from caller input, and bound into + // the OAuth transaction so the exchange must use the same exact URI. + const redirectUri = redirectUriForClient(client); const entity = await getOrCreateCustomerEntityForProfile(userId, customerType); if (entity.type !== customerType) { throw new APIError({ message: "customerType does not match the authenticated entity", status: httpStatus.BAD_REQUEST }); @@ -349,7 +363,7 @@ export async function startMoneriumOAuth( customerEntityId: entity.id, customerType, expectedEmail: email.trim().toLowerCase(), - redirectUri: config.monerium.redirectUri, + redirectUri, userId, verifier }; diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index f32a0f051..2b1c0c915 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -217,6 +217,7 @@ interface Config { issueFeeEur: string | undefined; redirectUri: string; whiteLabelClientId: string; + widgetRedirectUri: string | undefined; whiteLabelClientSecret: string; }; // B2B whitelabel onramp integration (docs/architecture-monerium-b2b-onramp.md §3). @@ -334,7 +335,8 @@ export const config: Config = { issueFeeEur: process.env.MONERIUM_ISSUE_FEE_EUR ? readNonNegativeDecimalEnv("MONERIUM_ISSUE_FEE_EUR") : undefined, redirectUri: process.env.MONERIUM_REDIRECT_URI || "http://localhost:5174/monerium/callback", whiteLabelClientId: process.env.MONERIUM_WHITELABEL_CLIENT_ID || "", - whiteLabelClientSecret: process.env.MONERIUM_WHITELABEL_CLIENT_SECRET || "" + whiteLabelClientSecret: process.env.MONERIUM_WHITELABEL_CLIENT_SECRET || "", + widgetRedirectUri: process.env.MONERIUM_WIDGET_REDIRECT_URI || undefined }, moneriumB2b: { // Whitelabel API credentials and base URL live with the shared client From 03b84d213b6c35ee82e906a1f7a16dcff62db526 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 14 Sep 2026 17:27:49 +0200 Subject: [PATCH 05/42] feat(api): link Monerium wallets and report EUR ramp readiness POST /v1/monerium/wallet verifies the owner's EOA signature over Monerium's fixed ownership message and the EOA requirement server-side, links the address through whichever app can read the profile, and requests the profile's single IBAN when none exists. POST /v1/monerium/iban/move changes the IBAN destination only on an explicit owner request to an already-linked address, because it redirects future SEPA deposits. GET /v1/monerium/status and the onboarding aggregate report EUR ramp readiness from the same list reads registration uses, without mutating provider state; a persisted approval stays readable when the OAuth session is gone. --- .../api/controllers/monerium.controller.ts | 37 +++- .../api/controllers/onboarding.controller.ts | 6 + apps/api/src/api/routes/v1/monerium.route.ts | 2 + .../api/services/monerium/identity.test.ts | 9 +- .../api/src/api/services/monerium/identity.ts | 5 +- .../src/api/services/monerium/wallet.test.ts | 183 ++++++++++++++++++ apps/api/src/api/services/monerium/wallet.ts | 182 +++++++++++++++++ .../phases/monerium-issue/registration.ts | 2 +- docs/api/openapi/vortex.openapi.d.ts | 9 + docs/api/openapi/vortex.openapi.json | 32 ++- docs/proposal-monerium-dual-app.md | 51 ++--- .../security-spec/05-integrations/monerium.md | 5 + 12 files changed, 494 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/api/services/monerium/wallet.test.ts create mode 100644 apps/api/src/api/services/monerium/wallet.ts diff --git a/apps/api/src/api/controllers/monerium.controller.ts b/apps/api/src/api/controllers/monerium.controller.ts index d032e6ba4..9193f2620 100644 --- a/apps/api/src/api/controllers/monerium.controller.ts +++ b/apps/api/src/api/controllers/monerium.controller.ts @@ -5,9 +5,11 @@ import { completeMoneriumOAuth, getMoneriumStatus, MONERIUM_OAUTH_CLIENTS, + MONERIUM_REAUTHENTICATION_REQUIRED, type MoneriumOAuthClient, startMoneriumOAuth } from "../services/monerium/monerium.service"; +import { getMoneriumRampReadiness, linkMoneriumWallet, moveMoneriumIban } from "../services/monerium/wallet"; type CustomerType = "individual" | "business"; @@ -76,7 +78,40 @@ export async function complete(req: Request, res: Response, next: NextFunction): export async function status(req: Request, res: Response, next: NextFunction): Promise { try { const user = authenticatedUser(req); - res.status(httpStatus.OK).json(await getMoneriumStatus(user.userId, customerType(req.query.customerType))); + const result = await getMoneriumStatus(user.userId, customerType(req.query.customerType)); + if (result.status !== "APPROVED") { + res.status(httpStatus.OK).json(result); + return; + } + // Readiness needs a live read; a persisted approval stays readable when the OAuth session is gone. + try { + res.status(httpStatus.OK).json({ ...result, ramp: await getMoneriumRampReadiness(user.userId) }); + } catch (error) { + if (!(error instanceof APIError && error.type === MONERIUM_REAUTHENTICATION_REQUIRED)) throw error; + res.status(httpStatus.OK).json({ ...result, rampError: { code: error.type, message: error.message } }); + } + } catch (error) { + next(error); + } +} + +export async function linkWallet(req: Request, res: Response, next: NextFunction): Promise { + try { + const user = authenticatedUser(req); + const body = (req.body ?? {}) as Record; + res + .status(httpStatus.OK) + .json(await linkMoneriumWallet(user.userId, { address: body.address, chain: body.chain, signature: body.signature })); + } catch (error) { + next(error); + } +} + +export async function moveIban(req: Request, res: Response, next: NextFunction): Promise { + try { + const user = authenticatedUser(req); + const body = (req.body ?? {}) as Record; + res.status(httpStatus.OK).json(await moveMoneriumIban(user.userId, { address: body.address, chain: body.chain })); } catch (error) { next(error); } diff --git a/apps/api/src/api/controllers/onboarding.controller.ts b/apps/api/src/api/controllers/onboarding.controller.ts index 1983548f5..65bcb70cb 100644 --- a/apps/api/src/api/controllers/onboarding.controller.ts +++ b/apps/api/src/api/controllers/onboarding.controller.ts @@ -29,6 +29,7 @@ import { } from "../services/avenia/avenia-kyc-import.service"; import { selectActiveCustomerEntity } from "../services/customer-entity.service"; import { getMoneriumStatus, MONERIUM_REAUTHENTICATION_REQUIRED } from "../services/monerium/monerium.service"; +import { getMoneriumRampReadiness, type MoneriumRampReadiness } from "../services/monerium/wallet"; // Provider status refreshes piggyback on the dashboard's 15s status poll; cap them per customer so // polling (and multiple open tabs) doesn't hammer the providers. Marking at check time also dedupes @@ -128,6 +129,7 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise< } } const providerErrors = new Map(); + const rampReadiness = new Map(); await Promise.all( providerCustomers @@ -146,6 +148,9 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise< : VerificationStatus.InReview ); customer.set("statusExternal", refreshed.statusExternal); + if (refreshed.status === "APPROVED") { + rampReadiness.set(customer.id, await getMoneriumRampReadiness(userId)); + } } catch (error) { if (error instanceof APIError && error.type === MONERIUM_REAUTHENTICATION_REQUIRED) { providerErrors.set(customer.id, { @@ -368,6 +373,7 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise< : null, provider: customer.provider, rail: customer.rail, + ramp: rampReadiness.get(customer.id) ?? null, state: customer.status, status: customer.status, statusExternal: customer.statusExternal, diff --git a/apps/api/src/api/routes/v1/monerium.route.ts b/apps/api/src/api/routes/v1/monerium.route.ts index 91dc87322..50bc3c35e 100644 --- a/apps/api/src/api/routes/v1/monerium.route.ts +++ b/apps/api/src/api/routes/v1/monerium.route.ts @@ -9,5 +9,7 @@ router.use(requireAuth); router.post("/oauth/start", rejectImpersonation, moneriumController.start); router.post("/oauth/complete", rejectImpersonation, moneriumController.complete); router.get("/status", moneriumController.status); +router.post("/wallet", rejectImpersonation, moneriumController.linkWallet); +router.post("/iban/move", rejectImpersonation, moneriumController.moveIban); export default router; diff --git a/apps/api/src/api/services/monerium/identity.test.ts b/apps/api/src/api/services/monerium/identity.test.ts index c13a06289..741bc4dff 100644 --- a/apps/api/src/api/services/monerium/identity.test.ts +++ b/apps/api/src/api/services/monerium/identity.test.ts @@ -24,7 +24,14 @@ function apiError(status: number) { } function client(getProfile: () => Promise>) { - return { getProfile: mock(getProfile), listAddresses: mock(async () => ({ addresses: [] })), listIbans: mock(async () => ({ ibans: [] })) }; + return { + getProfile: mock(getProfile), + linkAddress: mock(async () => ({ httpStatus: 201 as const })), + listAddresses: mock(async () => ({ addresses: [] })), + listIbans: mock(async () => ({ ibans: [] })), + requestIban: mock(async () => ({ httpStatus: 202 as const })), + updateIbanDestination: mock(async () => undefined) + }; } describe("resolveMoneriumIdentity", () => { diff --git a/apps/api/src/api/services/monerium/identity.ts b/apps/api/src/api/services/monerium/identity.ts index 80a4f3459..f7f05583c 100644 --- a/apps/api/src/api/services/monerium/identity.ts +++ b/apps/api/src/api/services/monerium/identity.ts @@ -10,7 +10,10 @@ export const MONERIUM_ONBOARDING_REQUIRED = "MONERIUM_ONBOARDING_REQUIRED"; export type MoneriumIdentitySource = "whitelabel" | "oauth"; -export type MoneriumIdentityClient = Pick; +export type MoneriumIdentityClient = Pick< + MoneriumApiService, + "getProfile" | "linkAddress" | "listAddresses" | "listIbans" | "requestIban" | "updateIbanDestination" +>; export interface MoneriumBinding { customerEntityId: string; diff --git a/apps/api/src/api/services/monerium/wallet.test.ts b/apps/api/src/api/services/monerium/wallet.test.ts new file mode 100644 index 000000000..7dc8a8260 --- /dev/null +++ b/apps/api/src/api/services/monerium/wallet.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, mock } from "bun:test"; +import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, MoneriumApiError, Networks } from "@vortexfi/shared"; +import { privateKeyToAccount } from "viem/accounts"; +import { APIError } from "../../errors/api-error"; +import type { MoneriumIdentity } from "./identity"; +import { getMoneriumRampReadiness, linkMoneriumWallet, moveMoneriumIban, verifyMoneriumWalletOwnership } from "./wallet"; + +const PROFILE_ID = "9e6a92a5-5f6d-48aa-a57b-0f8ae8eb745d"; +const OWNER = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); +const OTHER = "0x2222222222222222222222222222222222222222"; + +function iban(address: string, chain = "polygon") { + return { address, bic: "DEUTDEFF", chain, iban: "DE89370400440532013000", name: "Monerium EMI", profile: PROFILE_ID }; +} + +function client(options: { addresses?: string[]; ibans?: ReturnType[]; requestIbanError?: Error } = {}) { + return { + getProfile: mock(async () => ({ id: PROFILE_ID, kind: "personal", state: "approved" })), + linkAddress: mock(async () => ({ httpStatus: 201 as const })), + listAddresses: mock(async ({ chain }: { chain?: string }) => ({ + addresses: (options.addresses ?? []).map(address => ({ address, chains: [chain ?? "polygon"], profile: PROFILE_ID })) + })), + listIbans: mock(async () => ({ ibans: options.ibans ?? [] })), + requestIban: mock(async () => { + if (options.requestIbanError) throw options.requestIbanError; + return { httpStatus: 202 as const }; + }), + updateIbanDestination: mock(async () => undefined) + }; +} + +function deps(monerium: ReturnType, overrides: Partial[2]> = {}) { + const identity = { client: monerium, profile: { state: "approved" }, profileId: PROFILE_ID, source: "oauth" } as unknown as MoneriumIdentity; + return { + isContractAddress: async () => false, + resolveIdentity: async () => identity, + verifyOwnership: async () => true, + ...overrides + }; +} + +async function ownerSignature(): Promise<`0x${string}`> { + return OWNER.signMessage({ message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE }); +} + +describe("getMoneriumRampReadiness", () => { + it("reports provisioned when the IBAN points to a linked address on the ramp chain", async () => { + const monerium = client({ addresses: [OWNER.address], ibans: [iban(OWNER.address)] }); + await expect(getMoneriumRampReadiness("user-1", deps(monerium))).resolves.toEqual({ + chain: "polygon", + iban: "provisioned", + linkedAddress: OWNER.address, + source: "oauth" + }); + expect(monerium.listAddresses).toHaveBeenCalledWith({ chain: "polygon", profile: PROFILE_ID }); + }); + + it("reports elsewhere when the profile's IBAN sits on another chain or address", async () => { + const monerium = client({ addresses: [OWNER.address], ibans: [iban(OTHER, "ethereum")] }); + await expect(getMoneriumRampReadiness("user-1", deps(monerium))).resolves.toMatchObject({ + iban: "elsewhere", + linkedAddress: OWNER.address + }); + }); + + it("reports missing with no linked address when nothing is provisioned", async () => { + await expect(getMoneriumRampReadiness("user-1", deps(client()))).resolves.toMatchObject({ iban: "missing", linkedAddress: null }); + }); +}); + +describe("linkMoneriumWallet", () => { + it("verifies the owner signature, links once, and requests the IBAN", async () => { + const monerium = client(); + const signature = await ownerSignature(); + const isContractAddress = mock(async () => false); + const result = await linkMoneriumWallet( + "user-1", + { address: OWNER.address, chain: "polygon", signature }, + deps(monerium, { + isContractAddress, + verifyOwnership: async (address, sig) => address === OWNER.address && sig === signature + }) + ); + + expect(result).toEqual({ address: OWNER.address, chain: "polygon", iban: "pending" }); + expect(isContractAddress).toHaveBeenCalledWith(Networks.Polygon, OWNER.address); + expect(monerium.linkAddress).toHaveBeenCalledWith({ + address: OWNER.address, + chain: "polygon", + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature + }); + expect(monerium.requestIban).toHaveBeenCalledWith({ address: OWNER.address, chain: "polygon" }); + }); + + it("accepts only the owner's signature over the fixed message", async () => { + const signature = await ownerSignature(); + const forged = await privateKeyToAccount("0x2222222222222222222222222222222222222222222222222222222222222222").signMessage({ + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE + }); + await expect(verifyMoneriumWalletOwnership(OWNER.address, signature)).resolves.toBe(true); + await expect(verifyMoneriumWalletOwnership(OWNER.address, forged)).resolves.toBe(false); + await expect(verifyMoneriumWalletOwnership(OWNER.address, "0xab")).resolves.toBe(false); + }); + + it("skips linking when the address is already linked and reports an existing IBAN", async () => { + const monerium = client({ addresses: [OWNER.address], ibans: [iban(OWNER.address)] }); + const result = await linkMoneriumWallet("user-1", { address: OWNER.address, chain: "polygon", signature: await ownerSignature() }, deps(monerium)); + expect(result.iban).toBe("provisioned"); + expect(monerium.linkAddress).not.toHaveBeenCalled(); + expect(monerium.requestIban).not.toHaveBeenCalled(); + }); + + it("reports elsewhere instead of requesting a second IBAN", async () => { + const monerium = client({ ibans: [iban(OTHER, "ethereum")] }); + const result = await linkMoneriumWallet("user-1", { address: OWNER.address, chain: "polygon", signature: await ownerSignature() }, deps(monerium)); + expect(result.iban).toBe("elsewhere"); + expect(monerium.requestIban).not.toHaveBeenCalled(); + }); + + it("treats Monerium's already-requested answer as pending", async () => { + const monerium = client({ requestIbanError: new MoneriumApiError({ endpoint: "/ibans", method: "POST", status: 400 }) }); + const result = await linkMoneriumWallet("user-1", { address: OWNER.address, chain: "polygon", signature: await ownerSignature() }, deps(monerium)); + expect(result.iban).toBe("pending"); + }); + + it.each([ + ["an unsupported chain", { address: OWNER.address, chain: "gnosis", signature: "0xab" }, "chain must be one of"], + ["a malformed address", { address: "nope", chain: "polygon", signature: "0xab" }, "address must be a valid EVM address"], + ["a non-hex signature", { address: OWNER.address, chain: "polygon", signature: "sig" }, "signature must be hex-encoded"] + ])("rejects %s before touching Monerium", async (_label, input, message) => { + const monerium = client(); + await expect(linkMoneriumWallet("user-1", input, deps(monerium))).rejects.toThrow(message); + expect(monerium.listAddresses).not.toHaveBeenCalled(); + }); + + it("rejects a signature that does not prove ownership", async () => { + const monerium = client(); + await expect( + linkMoneriumWallet("user-1", { address: OWNER.address, chain: "polygon", signature: "0xab" }, deps(monerium, { verifyOwnership: async () => false })) + ).rejects.toThrow("signature does not prove ownership"); + expect(monerium.listAddresses).not.toHaveBeenCalled(); + }); + + it("rejects contract wallets", async () => { + const monerium = client(); + const error = await linkMoneriumWallet( + "user-1", + { address: OWNER.address, chain: "polygon", signature: "0xab" }, + deps(monerium, { isContractAddress: async () => true }) + ).catch(caught => caught); + expect(error).toBeInstanceOf(APIError); + expect(error.message).toContain("Contract wallets are not supported"); + expect(monerium.linkAddress).not.toHaveBeenCalled(); + }); +}); + +describe("moveMoneriumIban", () => { + it("moves the single IBAN to an already-linked address", async () => { + const monerium = client({ addresses: [OWNER.address], ibans: [iban(OTHER, "ethereum")] }); + const result = await moveMoneriumIban("user-1", { address: OWNER.address, chain: "polygon" }, deps(monerium)); + expect(result).toEqual({ address: OWNER.address, chain: "polygon", iban: "provisioned" }); + expect(monerium.updateIbanDestination).toHaveBeenCalledWith("DE89370400440532013000", { address: OWNER.address, chain: "polygon" }); + }); + + it("is a no-op when the IBAN already points there", async () => { + const monerium = client({ addresses: [OWNER.address], ibans: [iban(OWNER.address)] }); + await moveMoneriumIban("user-1", { address: OWNER.address, chain: "polygon" }, deps(monerium)); + expect(monerium.updateIbanDestination).not.toHaveBeenCalled(); + }); + + it("requires the destination to be linked first", async () => { + const monerium = client({ ibans: [iban(OTHER, "ethereum")] }); + await expect(moveMoneriumIban("user-1", { address: OWNER.address, chain: "polygon" }, deps(monerium))).rejects.toThrow("is not linked"); + expect(monerium.updateIbanDestination).not.toHaveBeenCalled(); + }); + + it("requires exactly one IBAN", async () => { + const monerium = client({ addresses: [OWNER.address] }); + await expect(moveMoneriumIban("user-1", { address: OWNER.address, chain: "polygon" }, deps(monerium))).rejects.toMatchObject({ status: 409 }); + }); +}); diff --git a/apps/api/src/api/services/monerium/wallet.ts b/apps/api/src/api/services/monerium/wallet.ts new file mode 100644 index 000000000..e07212b2c --- /dev/null +++ b/apps/api/src/api/services/monerium/wallet.ts @@ -0,0 +1,182 @@ +import { + EvmClientManager, + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + MoneriumApiError, + type MoneriumChain, + Networks +} from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { isAddress, isHex, verifyMessage } from "viem"; +import logger from "../../../config/logger"; +import { APIError } from "../../errors/api-error"; +import { matchingDestinations } from "../phases/blocks/phases/monerium-issue/registration"; +import { MONERIUM_ISSUE_NETWORKS, type MoneriumIssueNetwork } from "../phases/blocks/phases/monerium-issue/simulation"; +import { type MoneriumIdentity, type MoneriumIdentitySource, resolveMoneriumIdentity } from "./identity"; + +/** Chain the active EUR onramp mints on; readiness is measured against it. */ +export const MONERIUM_RAMP_CHAIN = MONERIUM_ISSUE_NETWORKS[Networks.Polygon].chain; + +const NETWORK_BY_CHAIN = Object.fromEntries( + Object.entries(MONERIUM_ISSUE_NETWORKS).map(([network, { chain }]) => [chain, network]) +) as Record; + +export type MoneriumIbanReadiness = "provisioned" | "elsewhere" | "missing"; +export type MoneriumIbanLinkOutcome = "provisioned" | "pending" | "elsewhere"; + +export interface MoneriumRampReadiness { + chain: MoneriumChain; + iban: MoneriumIbanReadiness; + linkedAddress: string | null; + source: MoneriumIdentitySource; +} + +export interface MoneriumWalletDestination { + address: string; + chain: MoneriumChain; +} + +export interface MoneriumWalletLinkResult extends MoneriumWalletDestination { + iban: MoneriumIbanLinkOutcome; +} + +export interface MoneriumWalletDependencies { + isContractAddress: (network: MoneriumIssueNetwork, address: `0x${string}`) => Promise; + resolveIdentity: (userId: string) => Promise; + verifyOwnership: (address: `0x${string}`, signature: `0x${string}`) => Promise; +} + +/** EOA signature over Monerium's fixed ownership message; malformed signatures count as not owned. */ +export async function verifyMoneriumWalletOwnership(address: `0x${string}`, signature: `0x${string}`): Promise { + try { + return await verifyMessage({ address, message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, signature }); + } catch { + return false; + } +} + +const defaultDependencies: MoneriumWalletDependencies = { + isContractAddress: async (network, address) => + Boolean(await EvmClientManager.getInstance().getClient(network).getBytecode({ address })), + resolveIdentity: userId => resolveMoneriumIdentity(userId), + verifyOwnership: verifyMoneriumWalletOwnership +}; + +function parseDestination(input: { address?: unknown; chain?: unknown }): { address: `0x${string}`; chain: MoneriumChain } { + if (typeof input.address !== "string" || !isAddress(input.address)) { + throw new APIError({ message: "address must be a valid EVM address", status: httpStatus.BAD_REQUEST }); + } + if (typeof input.chain !== "string" || !(input.chain in NETWORK_BY_CHAIN)) { + throw new APIError({ + message: `chain must be one of: ${Object.keys(NETWORK_BY_CHAIN).join(", ")}`, + status: httpStatus.BAD_REQUEST + }); + } + return { address: input.address, chain: input.chain as MoneriumChain }; +} + +function sameAddress(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +async function readDestinations(identity: MoneriumIdentity, chain: MoneriumChain) { + const [addresses, ibans] = await Promise.all([ + identity.client.listAddresses({ chain, profile: identity.profileId }), + identity.client.listIbans({ profile: identity.profileId }) + ]); + return { + addresses: addresses.addresses.filter(entry => entry.profile === identity.profileId && entry.chains.includes(chain)), + ibans: ibans.ibans.filter(entry => entry.profile === identity.profileId) + }; +} + +/** Whether the profile can register the EUR onramp today, from the same reads registration uses. */ +export async function getMoneriumRampReadiness( + userId: string, + dependencies: MoneriumWalletDependencies = defaultDependencies +): Promise { + const identity = await dependencies.resolveIdentity(userId); + const chain = MONERIUM_RAMP_CHAIN; + const { addresses, ibans } = await readDestinations(identity, chain); + const matches = matchingDestinations(identity.profileId, chain, addresses, ibans); + const linkedAddress = matches[0]?.address.address ?? addresses[0]?.address ?? null; + const iban: MoneriumIbanReadiness = matches.length === 1 ? "provisioned" : ibans.length > 0 ? "elsewhere" : "missing"; + return { chain, iban, linkedAddress, source: identity.source }; +} + +/** + * Links the user's EOA to their Monerium profile and requests the profile's single IBAN when none + * exists. Ownership is proven by the EOA signature over Monerium's fixed message; contract wallets + * are rejected because the onramp's self-transfer needs an ERC-2612 permit from an EOA. + */ +export async function linkMoneriumWallet( + userId: string, + input: { address?: unknown; chain?: unknown; signature?: unknown }, + dependencies: MoneriumWalletDependencies = defaultDependencies +): Promise { + const { address, chain } = parseDestination(input); + if (typeof input.signature !== "string" || !isHex(input.signature)) { + throw new APIError({ message: "signature must be hex-encoded signature bytes", status: httpStatus.BAD_REQUEST }); + } + if (!(await dependencies.verifyOwnership(address, input.signature))) { + throw new APIError({ message: "signature does not prove ownership of address", status: httpStatus.BAD_REQUEST }); + } + if (await dependencies.isContractAddress(NETWORK_BY_CHAIN[chain], address)) { + throw new APIError({ + message: "Contract wallets are not supported; the EUR onramp needs an EOA that can sign a permit", + status: httpStatus.BAD_REQUEST + }); + } + + const identity = await dependencies.resolveIdentity(userId); + const before = await readDestinations(identity, chain); + if (!before.addresses.some(entry => sameAddress(entry.address, address))) { + await identity.client.linkAddress({ + address, + chain, + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: identity.profileId, + signature: input.signature + }); + logger.info(`MoneriumWallet: linked ${address} on ${chain} through the ${identity.source} app`); + } + + if (before.ibans.some(entry => entry.chain === chain && sameAddress(entry.address, address))) { + return { address, chain, iban: "provisioned" }; + } + if (before.ibans.length > 0) return { address, chain, iban: "elsewhere" }; + + try { + await identity.client.requestIban({ address, chain }); + } catch (error) { + // Monerium keeps one IBAN per profile and answers 400 when one is already requested. + if (!(error instanceof MoneriumApiError && error.status === 400)) throw error; + } + logger.info(`MoneriumWallet: requested an IBAN for ${address} on ${chain}`); + return { address, chain, iban: "pending" }; +} + +/** Moves the profile's single IBAN to an already-linked address. Only ever called on the owner's explicit request. */ +export async function moveMoneriumIban( + userId: string, + input: { address?: unknown; chain?: unknown }, + dependencies: MoneriumWalletDependencies = defaultDependencies +): Promise { + const { address, chain } = parseDestination(input); + const identity = await dependencies.resolveIdentity(userId); + const { addresses, ibans } = await readDestinations(identity, chain); + if (!addresses.some(entry => sameAddress(entry.address, address))) { + throw new APIError({ + message: `address is not linked to the Monerium profile on ${chain}`, + status: httpStatus.BAD_REQUEST + }); + } + if (ibans.length !== 1) { + throw new APIError({ message: `Expected exactly one Monerium IBAN, found ${ibans.length}`, status: httpStatus.CONFLICT }); + } + const current = ibans[0]; + if (current.chain !== chain || !sameAddress(current.address, address)) { + await identity.client.updateIbanDestination(current.iban, { address, chain }); + logger.info(`MoneriumWallet: moved the IBAN destination to ${address} on ${chain} through the ${identity.source} app`); + } + return { address, chain, iban: "provisioned" }; +} diff --git a/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts b/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts index ae422d101..0fd69c10f 100644 --- a/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts +++ b/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts @@ -60,7 +60,7 @@ function createPaymentReference(): string { return `VTX${crypto.randomUUID().replaceAll("-", "").toUpperCase()}`; } -function matchingDestinations( +export function matchingDestinations( profileId: string, chain: (typeof MONERIUM_ISSUE_NETWORKS)[MoneriumIssueNetwork]["chain"], addresses: readonly MoneriumAddress[], diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 16fd09176..3a9a82016 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -3047,6 +3047,15 @@ export interface components { /** @enum {string} */ provider: "alfredpay" | "avenia" | "monerium" | "mykobo"; rail: string | null; + /** @description EUR onramp readiness of an approved Monerium account, measured against the chain the active onramp mints on. Null for other providers, for non-approved accounts, and when the account's OAuth session must be renewed (see error). */ + ramp: Record & (null | { + chain: string; + /** @enum {string} */ + iban: "provisioned" | "elsewhere" | "missing"; + linkedAddress: string | null; + /** @enum {string} */ + source: "whitelabel" | "oauth"; + }); /** @enum {string} */ state: "pending" | "started" | "in_review" | "approved" | "rejected"; /** @enum {string} */ diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 68a2d37be..67ff4cf38 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -3031,6 +3031,35 @@ "rail": { "type": ["string", "null"] }, + "ramp": { + "description": "EUR onramp readiness of an approved Monerium account, measured against the chain the active onramp mints on. Null for other providers, for non-approved accounts, and when the account's OAuth session must be renewed (see error).", + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "chain": { + "type": "string" + }, + "iban": { + "enum": ["provisioned", "elsewhere", "missing"], + "type": "string" + }, + "linkedAddress": { + "type": ["string", "null"] + }, + "source": { + "enum": ["whitelabel", "oauth"], + "type": "string" + } + }, + "required": ["chain", "iban", "linkedAddress", "source"], + "type": "object" + } + ], + "type": ["object", "null"] + }, "state": { "enum": ["pending", "started", "in_review", "approved", "rejected"], "type": "string" @@ -3059,7 +3088,8 @@ "state", "status", "statusExternal", - "taxReference" + "taxReference", + "ramp" ], "type": "object" }, diff --git a/docs/proposal-monerium-dual-app.md b/docs/proposal-monerium-dual-app.md index 0369d1c0e..15a3a4f6a 100644 --- a/docs/proposal-monerium-dual-app.md +++ b/docs/proposal-monerium-dual-app.md @@ -144,30 +144,32 @@ hermetic contract coverage for the user-token read schemas. ## Phase 2: API onboarding and readiness -1. `POST /v1/monerium/oauth/start` accepts a `client` selector (`dashboard` | `widget`). - The redirect URI comes from an allowlist (`MONERIUM_REDIRECT_URI`, - `MONERIUM_WIDGET_REDIRECT_URI`) and is bound into the OAuth transaction exactly as - today. Both URIs are registered with Monerium. Link-at-login is dead (P2), so the start - request carries no wallet parameters. -2. Wallet link, new route `POST /v1/monerium/wallet` (bearer session): body +1. `POST /v1/monerium/oauth/start` accepts a `client` selector (`dashboard`, the default, or + `widget`). The redirect URI comes from an allowlist (`MONERIUM_REDIRECT_URI`, + `MONERIUM_WIDGET_REDIRECT_URI`; the widget flow is refused with `503` when the latter is + unset) and is bound into the OAuth transaction exactly as today. Both URIs are registered + with Monerium. Link-at-login is dead (P2), so the start request carries no wallet + parameters. +2. Wallet link, `POST /v1/monerium/wallet` (bearer session, no impersonation): body `{ address, chain, signature }` where `signature` is the user's EOA signature over the - fixed link message. The backend verifies it with viem `verifyMessage`, rejects - addresses with deployed code (the permit needs an EOA), then calls `POST /addresses` - with the user's OAuth token. `MONERIUM_REAUTHENTICATION_REQUIRED` when no token is - cached. -3. IBAN provisioning (one IBAN per profile, P2): on wallet link and on status refresh, - read `GET /ibans?profile=` with the user token. - - none: `POST /ibans { address, chain }` (`202`), readiness `pending` until it appears; - - present on the linked address and flow chain: `provisioned`; - - present elsewhere: `elsewhere`; the client offers an explicit user-confirmed move - (`PATCH /ibans/{iban}`) because it redirects the user's future SEPA deposits. The - backend never moves an IBAN without that request. - Nothing is persisted; Monerium stays authoritative. -4. Readiness: extend `GET /v1/monerium/status` (and the Monerium account entry of - `GET /v1/onboarding/status`) with - `ramp: { source, linkedAddress, chain, iban: "provisioned" | "pending" | "elsewhere" | "missing" }`. - For OAuth users this read needs a live token; without one the existing - `MONERIUM_REAUTHENTICATION_REQUIRED` error is returned and clients prompt reconnect. + fixed link message. The backend verifies it with viem `verifyMessage`, rejects addresses + with deployed code (the permit needs an EOA), resolves the profile through the identity + resolver, links through whichever app can read it (`POST /addresses`, skipped when already + linked), and then handles the profile's single IBAN (P2): none → `POST /ibans` and + `iban: "pending"` (Monerium's "already requested" `400` also maps to `pending`); present + on that address and chain → `provisioned`; present elsewhere → `elsewhere`. +3. IBAN move, `POST /v1/monerium/iban/move` with `{ address, chain }`: moves the single + IBAN (`PATCH /ibans/{iban}`) to an address already linked on that chain. It exists only as + an explicit owner action because it redirects the user's future SEPA deposits; nothing + else moves an IBAN. +4. Readiness: `GET /v1/monerium/status` adds, for approved profiles, + `ramp: { source, linkedAddress, chain, iban: "provisioned" | "elsewhere" | "missing" }` + measured against the chain the active onramp mints on (`MONERIUM_RAMP_CHAIN`, Polygon), + or `rampError: { code: "MONERIUM_REAUTHENTICATION_REQUIRED", message }` when the live + read needs an OAuth session that is gone (a persisted approval stays readable). The + Monerium account entry of `GET /v1/onboarding/status` carries the same `ramp` object + (`null` elsewhere; a lost session surfaces through the existing `error` field). Status + reads never mutate provider state. Nothing is persisted; Monerium stays authoritative. Managed children, quote simulation, execution, and the B2B onramp are unchanged. @@ -175,7 +177,8 @@ Managed children, quote simulation, execution, and the B2B onramp are unchanged. - EU corridor card reads `ramp` readiness. Approved without a linked wallet or IBAN shows a "Link wallet" step: connect wallet, sign the link message, call `POST /v1/monerium/wallet`, - then poll until the IBAN is provisioned (or confirm a move when it is `elsewhere`). + then poll status until the IBAN is `provisioned` (or confirm a move through + `POST /v1/monerium/iban/move` when it is `elsewhere`). - Transfer machine, EUR BUY: the connected wallet must equal `ramp.linkedAddress` before registration; the owner permit is signed with the existing `signMultipleTypedData`; `updateRamp` carries ephemeral presigns plus the permit; `ibanPaymentData` from the diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index e3902dba4..8343e6025 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -97,6 +97,9 @@ the active onramp; the first-party clients cannot yet do so. 18. The Polygon conversion MUST verify the pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. 19. Issue execution MUST wait for `currentOwnerBalance >= persistedBaseline + quotedPostFeeEureRaw`. Timeouts and exhausted RPC reads are recoverable. Missing or malformed settlement facts are unrecoverable corruption. The executor MUST transfer only the quoted post-fee amount; excess EURe remains in the owner wallet. This non-deterministic attribution exception is accepted only under RISK-023. 20. The owner permit expires 24 hours after transaction preparation. An expired permit or consumed nonce MUST stop automatic self-transfer unless a sufficient safe allowance remains; the API MUST NOT fabricate or broaden authorization. The absence of automatic reauthorization/recovery is accepted under RISK-024. +21. `POST /v1/monerium/wallet` MUST verify the EOA signature over the fixed ownership message server-side before any provider call, MUST reject addresses with deployed code, MUST link through the app that can read the profile, and MUST request the profile's single IBAN only when the profile has none. Status reads (`GET /v1/monerium/status`, `GET /v1/onboarding/status`) MUST NOT mutate provider state; they report readiness from the same list reads registration uses. +22. An IBAN destination MUST change only through `POST /v1/monerium/iban/move`, an explicit request by the authenticated owner naming an address already linked on that chain, because it redirects the profile's future SEPA deposits. The backend MUST NOT move an IBAN as a side effect of linking, status, or registration. +23. The OAuth redirect URI MUST come from the configured allowlist (`MONERIUM_REDIRECT_URI`, `MONERIUM_WIDGET_REDIRECT_URI`) selected by the `client` field, never from caller-supplied URLs, and MUST be bound into the OAuth transaction so the code exchange reuses the same exact URI. ### Threat Vectors & Mitigations @@ -130,6 +133,8 @@ the active onramp; the first-party clients cannot yet do so. - [x] Polygon conversion verifies the pinned EURe/USDC Uniswap V3 deployment, quotes exact input, prepares exact approval and `exactInputSingle` transactions, validates their signed semantics, and reconciles allowance, receipt, and output thresholds. - [x] The complete Polygon-to-destination topology is cataloged for supported non-Polygon EVM outputs; Mykobo definitions are legacy-recovery-only and EUR offramps are rejected. - [x] Strict transaction completeness requires the user-signed typed-data permit as well as every ephemeral-signed transaction before payment instructions are released. +- [x] Wallet linking verifies the owner signature and the EOA requirement server-side, links through the resolving app, and requests at most one IBAN; IBAN moves need an explicit owner request to an already-linked address; status reads never mutate provider state (`wallet.test.ts`). +- [x] The OAuth callback is selected from the configured dashboard/widget allowlist and bound into the transaction. - [ ] OAuth-to-white-label migration, KYC/KYB lifecycle orchestration, user-to-corridor binding, wallet linking, and other import mechanisms are deferred; their trust boundary, persistence model, and status reconciliation remain TBD. - [ ] The first-party SDK, dashboard, and widget do not complete the profile-linked owner-wallet signing journey. The active release is direct-API only. - [ ] A permit collected before SEPA settlement can expire or become stale. If no sufficient allowance remains, the ramp stops for manual resolution; no automatic reauthorization path is implemented (RISK-024). From 6fa61b5d428d37daf4a25f7e4e157923efcb6ce2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 14 Sep 2026 17:42:09 +0200 Subject: [PATCH 06/42] feat(kyc): expose Monerium ramp readiness and wallet operations The shared Monerium machine now carries the EUR ramp readiness the backend reports on the status endpoint, and the API client gains the wallet link, IBAN move, and OAuth client selector calls so the dashboard and the widget share one Monerium integration. --- packages/kyc/src/index.ts | 9 ++++- packages/kyc/src/monerium/api.ts | 18 ++++++++-- packages/kyc/src/monerium/machine.ts | 6 ++-- packages/kyc/src/monerium/service.test.ts | 44 +++++++++++++++++++++++ packages/kyc/src/monerium/service.ts | 25 ++++++++++--- packages/kyc/src/monerium/types.ts | 29 +++++++++++++++ 6 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 packages/kyc/src/monerium/service.test.ts diff --git a/packages/kyc/src/index.ts b/packages/kyc/src/index.ts index 5777ceeab..ec3b82f76 100644 --- a/packages/kyc/src/index.ts +++ b/packages/kyc/src/index.ts @@ -48,9 +48,16 @@ export { type UploadIds, type VerifyStatusActorOutput } from "./avenia/types"; -export type { MoneriumKycApi, MoneriumKycDeps } from "./monerium/api"; +export type { MoneriumKycApi, MoneriumKycDeps, MoneriumWalletApi } from "./monerium/api"; export { createMoneriumKycMachine, type MoneriumKycMachine } from "./monerium/machine"; export { createMoneriumKycApi, type MoneriumKycApiClient } from "./monerium/service"; +export type { + MoneriumIbanReadiness, + MoneriumOAuthClient, + MoneriumRampReadiness, + MoneriumWalletLinkInput, + MoneriumWalletLinkResult +} from "./monerium/types"; export { MoneriumAuthorizationRequiredError, type MoneriumCustomerType, diff --git a/packages/kyc/src/monerium/api.ts b/packages/kyc/src/monerium/api.ts index feffa8c09..8f670954b 100644 --- a/packages/kyc/src/monerium/api.ts +++ b/packages/kyc/src/monerium/api.ts @@ -1,12 +1,26 @@ -import type { MoneriumCustomerType, MoneriumStatusResponse } from "./types"; +import type { + MoneriumCustomerType, + MoneriumOAuthClient, + MoneriumStatusResponse, + MoneriumWalletLinkInput, + MoneriumWalletLinkResult +} from "./types"; export interface MoneriumKycApi { completeOAuth(code: string, state: string): Promise; getStatus(customerType: MoneriumCustomerType): Promise; - startOAuth(customerType: MoneriumCustomerType): Promise<{ authorizationUrl: string }>; + startOAuth(customerType: MoneriumCustomerType, client?: MoneriumOAuthClient): Promise<{ authorizationUrl: string }>; +} + +/** Wallet and IBAN readiness operations for an approved profile (POST /v1/monerium/wallet, /iban/move). */ +export interface MoneriumWalletApi { + linkWallet(input: MoneriumWalletLinkInput): Promise; + moveIban(input: { address: string; chain: string }): Promise; } export interface MoneriumKycDeps { api: MoneriumKycApi; + /** Which registered callback the backend binds; defaults to the dashboard callback. */ + client?: MoneriumOAuthClient; openAuthorizationUrl: (url: string) => void; } diff --git a/packages/kyc/src/monerium/machine.ts b/packages/kyc/src/monerium/machine.ts index fbe35f844..64354c88a 100644 --- a/packages/kyc/src/monerium/machine.ts +++ b/packages/kyc/src/monerium/machine.ts @@ -11,7 +11,7 @@ function statusOutput(event: unknown): MoneriumStatusResponse { return (event as DoneActorEvent).output; } -export function createMoneriumKycMachine({ api, openAuthorizationUrl }: MoneriumKycDeps) { +export function createMoneriumKycMachine({ api, client, openAuthorizationUrl }: MoneriumKycDeps) { return setup({ actions: { openAuthorization: ({ context }) => { @@ -21,6 +21,8 @@ export function createMoneriumKycMachine({ api, openAuthorizationUrl }: Monerium customerType: ({ event }) => statusOutput(event).customerType, error: () => undefined, profileId: ({ event }) => statusOutput(event).profileId, + ramp: ({ event }) => statusOutput(event).ramp, + rampError: ({ event }) => statusOutput(event).rampError, status: ({ event }) => statusOutput(event).status, statusExternal: ({ event }) => statusOutput(event).statusExternal }) @@ -30,7 +32,7 @@ export function createMoneriumKycMachine({ api, openAuthorizationUrl }: Monerium completeOAuth: fromPromise(({ input }: { input: { code: string; state: string } }) => api.completeOAuth(input.code, input.state) ), - startOAuth: fromPromise(({ input }: { input: MoneriumKycInput }) => api.startOAuth(input.customerType)) + startOAuth: fromPromise(({ input }: { input: MoneriumKycInput }) => api.startOAuth(input.customerType, client)) }, guards: { callbackHasCode: ({ context }) => !!context.callback && "code" in context.callback, diff --git a/packages/kyc/src/monerium/service.test.ts b/packages/kyc/src/monerium/service.test.ts new file mode 100644 index 000000000..6f732047a --- /dev/null +++ b/packages/kyc/src/monerium/service.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "bun:test"; +import { createMoneriumKycApi, type MoneriumKycApiClient } from "./service"; +import { MoneriumAuthorizationRequiredError } from "./types"; + +function client(status?: number) { + const calls: Array<{ method: string; url: string; data?: unknown; params?: unknown }> = []; + const apiClient: MoneriumKycApiClient = { + async get(url: string, config?: { params?: Record }): Promise { + calls.push({ method: "get", params: config?.params, url }); + if (status) throw { status }; + return { status: "APPROVED" } as T; + }, + async post(url: string, data?: unknown): Promise { + calls.push({ data, method: "post", url }); + return { ok: true } as T; + } + }; + return { api: createMoneriumKycApi(apiClient), calls }; +} + +describe("createMoneriumKycApi", () => { + it("names the OAuth client only when one is given", async () => { + const { api, calls } = client(); + await api.startOAuth("individual"); + await api.startOAuth("business", "widget"); + expect(calls).toEqual([ + { data: { customerType: "individual" }, method: "post", url: "/monerium/oauth/start" }, + { data: { client: "widget", customerType: "business" }, method: "post", url: "/monerium/oauth/start" } + ]); + }); + + it("posts wallet links and IBAN moves to the readiness routes", async () => { + const { api, calls } = client(); + await api.linkWallet({ address: "0xabc", chain: "polygon", signature: "0xsig" }); + await api.moveIban({ address: "0xabc", chain: "polygon" }); + expect(calls.map(call => call.url)).toEqual(["/monerium/wallet", "/monerium/iban/move"]); + expect(calls[0]?.data).toEqual({ address: "0xabc", chain: "polygon", signature: "0xsig" }); + }); + + it.each([401, 404])("maps a %i status read to authorization required", async status => { + const { api } = client(status); + await expect(api.getStatus("individual")).rejects.toBeInstanceOf(MoneriumAuthorizationRequiredError); + }); +}); diff --git a/packages/kyc/src/monerium/service.ts b/packages/kyc/src/monerium/service.ts index 31c4be8e9..b7f62b0b1 100644 --- a/packages/kyc/src/monerium/service.ts +++ b/packages/kyc/src/monerium/service.ts @@ -1,5 +1,11 @@ -import type { MoneriumKycApi } from "./api"; -import type { MoneriumCustomerType, MoneriumStatusResponse } from "./types"; +import type { MoneriumKycApi, MoneriumWalletApi } from "./api"; +import type { + MoneriumCustomerType, + MoneriumOAuthClient, + MoneriumStatusResponse, + MoneriumWalletLinkInput, + MoneriumWalletLinkResult +} from "./types"; import { MoneriumAuthorizationRequiredError } from "./types"; type Params = Record; @@ -15,7 +21,7 @@ function getErrorStatus(error: unknown): number | undefined { : undefined; } -export function createMoneriumKycApi(apiClient: MoneriumKycApiClient): MoneriumKycApi { +export function createMoneriumKycApi(apiClient: MoneriumKycApiClient): MoneriumKycApi & MoneriumWalletApi { return { completeOAuth(code: string, state: string): Promise { return apiClient.post("/monerium/oauth/complete", { code, state }); @@ -30,8 +36,17 @@ export function createMoneriumKycApi(apiClient: MoneriumKycApiClient): MoneriumK throw error; } }, - startOAuth(customerType: MoneriumCustomerType): Promise<{ authorizationUrl: string }> { - return apiClient.post<{ authorizationUrl: string }>("/monerium/oauth/start", { customerType }); + linkWallet(input: MoneriumWalletLinkInput): Promise { + return apiClient.post("/monerium/wallet", input); + }, + moveIban(input: { address: string; chain: string }): Promise { + return apiClient.post("/monerium/iban/move", input); + }, + startOAuth(customerType: MoneriumCustomerType, client?: MoneriumOAuthClient): Promise<{ authorizationUrl: string }> { + return apiClient.post<{ authorizationUrl: string }>("/monerium/oauth/start", { + customerType, + ...(client ? { client } : {}) + }); } }; } diff --git a/packages/kyc/src/monerium/types.ts b/packages/kyc/src/monerium/types.ts index 51a70e82f..d5dd5d8bf 100644 --- a/packages/kyc/src/monerium/types.ts +++ b/packages/kyc/src/monerium/types.ts @@ -1,11 +1,38 @@ export type MoneriumCustomerType = "business" | "individual"; export type MoneriumKycStatus = "APPROVED" | "PENDING" | "REJECTED"; +export type MoneriumOAuthClient = "dashboard" | "widget"; +export type MoneriumIbanReadiness = "provisioned" | "elsewhere" | "missing"; + +/** EUR onramp readiness of an approved profile, measured against the chain the onramp mints on. */ +export interface MoneriumRampReadiness { + chain: string; + iban: MoneriumIbanReadiness; + linkedAddress: string | null; + source: "whitelabel" | "oauth"; +} + export interface MoneriumStatusResponse { customerType: MoneriumCustomerType; profileId: string; status: MoneriumKycStatus; statusExternal: string; + ramp?: MoneriumRampReadiness; + /** Present instead of `ramp` when the live readiness read needs a renewed OAuth session. */ + rampError?: { code: string; message: string }; +} + +export interface MoneriumWalletLinkInput { + address: string; + chain: string; + /** EOA signature over Monerium's fixed wallet-ownership message. */ + signature: string; +} + +export interface MoneriumWalletLinkResult { + address: string; + chain: string; + iban: "provisioned" | "pending" | "elsewhere"; } export type MoneriumOAuthCallback = { code: string; state: string } | { error: string; errorDescription?: string }; @@ -19,6 +46,8 @@ export interface MoneriumKycContext extends MoneriumKycInput { authorizationUrl?: string; error?: Error; profileId?: string; + ramp?: MoneriumRampReadiness; + rampError?: { code: string; message: string }; status?: MoneriumKycStatus; statusExternal?: string; } From 1b4c4aad0de02229401bed98fb6534d74389785b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 14 Sep 2026 17:42:09 +0200 Subject: [PATCH 07/42] feat(dashboard): link Monerium wallets and enable EUR pay-ins EU onboarding runs through Monerium OAuth again. An approved EU sender gets a second step on the corridor card: connect the wallet they will pay in with, sign Monerium's ownership message (no gas), and let Vortex request or move the profile's IBAN to it. The onramp form offers EUR once that wallet is linked and connected, sends it as the ramp's wallet address, and the transfer machine signs the wallet-owned permit before showing the SEPA instructions, which the backend releases only after that signature. --- .../components/onboarding/CorridorCard.tsx | 34 ++- .../onboarding/OnboardingWizard.tsx | 7 + .../monerium/MoneriumWalletLinkFlow.tsx | 199 ++++++++++++++++++ .../src/components/transfer/OnrampForm.tsx | 49 ++++- .../transfer/OnrampPaymentInstructions.tsx | 10 + apps/dashboard/src/domain/corridors.test.ts | 4 +- apps/dashboard/src/domain/corridors.ts | 12 +- apps/dashboard/src/domain/onramp.test.ts | 17 ++ apps/dashboard/src/domain/onramp.ts | 21 +- apps/dashboard/src/domain/types.ts | 3 + apps/dashboard/src/hooks/useActiveAccount.ts | 1 + .../src/hooks/useApprovedCorridors.test.ts | 1 + .../dashboard/src/machines/transfer.actors.ts | 9 +- .../src/machines/transfer.machine.test.ts | 53 +++++ .../src/machines/transfer.machine.ts | 31 ++- .../src/services/api/onboarding.service.ts | 3 + .../src/services/transactions/userSigning.ts | 15 +- docs/product-dashboard.md | 25 ++- 18 files changed, 452 insertions(+), 42 deletions(-) create mode 100644 apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx diff --git a/apps/dashboard/src/components/onboarding/CorridorCard.tsx b/apps/dashboard/src/components/onboarding/CorridorCard.tsx index d18d2c817..d888527a8 100644 --- a/apps/dashboard/src/components/onboarding/CorridorCard.tsx +++ b/apps/dashboard/src/components/onboarding/CorridorCard.tsx @@ -45,13 +45,17 @@ export function CorridorCard({ account, corridor, onStart, verificationReadOnly const onboarding = account.onboardings[corridor.id]; // Suppress every actionable state (start, continue, retry, re-authenticate) while the // corridor is disabled; purely informational buttons (awaiting review, complete) stay. + // An approved Monerium profile still needs the pay-in wallet linked and the IBAN pointed at it. + const walletLinkRequired = + corridor.provider === "monerium" && onboarding?.status === "approved" && onboarding.ramp?.iban !== "provisioned"; const actionable = !onboarding || onboarding.status === "not_started" || onboarding.status === "pending" || onboarding.status === "started" || onboarding.status === "rejected" || - (onboarding.status === "in_review" && onboarding.reauthenticationRequired === true); + (onboarding.status === "in_review" && onboarding.reauthenticationRequired === true) || + (onboarding.status === "approved" && (walletLinkRequired || onboarding.reauthenticationRequired === true)); const disabled = isCorridorOnboardingDisabled(corridor) && actionable; const meta = onboarding ? STATUS_META[onboarding.status] : null; const hint = ROUTE_HINT[routeFor(corridor.id, kind)]; @@ -79,11 +83,15 @@ export function CorridorCard({ account, corridor, onStart, verificationReadOnly {onboarding?.status === "rejected" ? (

Verification was rejected — retry below or contact support.

+ ) : walletLinkRequired ? ( +

Link the wallet you will pay in with to finish EUR setup.

) : hint ? (

@@ -133,6 +141,7 @@ export function CorridorCard({ account, corridor, onStart, verificationReadOnly onStart={onStart} reauthenticationRequired={onboarding.reauthenticationRequired === true} status={onboarding.status} + walletLinkRequired={walletLinkRequired} /> ) : ( ); } + if (reauthenticationRequired) { + return ( + + ); + } + if (walletLinkRequired) { + return ( + + ); + } // Approved: the status badge already says it — no footer action needed. return null; } diff --git a/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx b/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx index b1f433890..489211fe6 100644 --- a/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx +++ b/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx @@ -15,6 +15,7 @@ import { notifyOnboardingStatus } from "@/lib/notify"; import { AlfredpayKycFlow } from "./alfredpay/AlfredpayKycFlow"; import { AveniaKycFlow } from "./avenia/AveniaKycFlow"; import { MoneriumKycFlow } from "./monerium/MoneriumKycFlow"; +import { MoneriumWalletLinkFlow } from "./monerium/MoneriumWalletLinkFlow"; interface OnboardingWizardProps { account: SenderAccount; @@ -86,6 +87,12 @@ export function OnboardingWizard({ account, corridor, onClose }: OnboardingWizar onSettled={onSettled} resume={aveniaResume} /> + ) : isLiveMoneriumKyc && onboarding?.status === "approved" ? ( + ) : isLiveMoneriumKyc ? ( void; + onSettled: (status: OnboardingStatus) => void; +} + +/** + * Second step of EU onboarding: EUR pay-ins mint to a wallet linked to the approved Monerium + * profile and need that wallet's permit, so the sender links the wallet they will pay in with + * and Vortex requests (or moves) the profile's IBAN to it. Readiness comes from + * GET /v1/monerium/status and is polled until the IBAN is provisioned. + */ +export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: MoneriumWalletLinkFlowProps) { + const queryClient = useQueryClient(); + const { address } = useAccount(); + const status = useQuery({ + queryFn: () => api.getStatus(customerType), + queryKey: [...MONERIUM_STATUS_QUERY_KEY, customerType], + refetchInterval: query => (query.state.data?.ramp?.iban === "provisioned" || query.state.error ? false : 5_000), + retry: false + }); + const ramp = status.data?.ramp; + const reported = useRef(false); + + useEffect(() => { + if (ramp?.iban === "provisioned" && !reported.current) { + reported.current = true; + onSettled("approved"); + } + }, [onSettled, ramp?.iban]); + + function refresh() { + queryClient.invalidateQueries({ queryKey: MONERIUM_STATUS_QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: ONBOARDING_STATUS_QUERY_KEY }); + } + + const link = useMutation({ + mutationFn: async () => { + if (!address || !ramp) throw new Error("Connect a wallet first"); + const signature = await signMoneriumWalletLinkMessage(); + return api.linkWallet({ address, chain: ramp.chain, signature }); + }, + onSuccess: refresh + }); + const move = useMutation({ + mutationFn: async () => { + if (!address || !ramp) throw new Error("Connect a wallet first"); + return api.moveIban({ address, chain: ramp.chain }); + }, + onSuccess: refresh + }); + const reauthorize = useMutation({ + mutationFn: () => api.startOAuth(customerType), + onSuccess: ({ authorizationUrl }) => requestAnimationFrame(() => window.location.assign(authorizationUrl)) + }); + + if (status.isPending) { + return ( + + +

Checking your Monerium wallet

+ + ); + } + + if (status.error instanceof MoneriumAuthorizationRequiredError || status.data?.rampError) { + return ( + <> + + +
+

Reconnect Monerium

+

+ Your Monerium session has expired. Reconnect to check the wallet and IBAN for EUR pay-ins. +

+
+
+ + + + + + ); + } + + if (status.error || !ramp) { + return ( + <> + + +
+

Could not read your Monerium status

+

{status.error?.message ?? "Finish Monerium verification first."}

+
+
+ + + + + + ); + } + + if (ramp.iban === "provisioned" && ramp.linkedAddress) { + return ( + <> + + +
+

Ready for EUR pay-ins

+

+ {shortenAddress(ramp.linkedAddress)} is linked to your Monerium profile and your IBAN points to it. Pay in from + the transfer page with that wallet connected. +

+
+
+ + + + + ); + } + + const isLinked = !!address && ramp.linkedAddress?.toLowerCase() === address.toLowerCase(); + const needsMove = ramp.iban === "elsewhere" && isLinked; + const busy = link.isPending || move.isPending; + const failure = link.error ?? move.error; + const requested = link.data?.iban === "pending"; + + return ( + <> + + +
+

Link the wallet you will pay in with

+

+ Your EUR arrives as EURe in this wallet and is swapped from there, so it must be a regular wallet you control (no + smart-contract wallet). Signing proves ownership; it costs no gas. +

+ {needsMove && ( +

+ Your Monerium IBAN currently points to another wallet or chain. Move it to {shortenAddress(address)} so EUR + pay-ins mint here. +

+ )} + {requested && !needsMove && ( +

+ IBAN requested. Monerium is provisioning it; this usually takes a moment. +

+ )} + {failure &&

{failure.message}

} +
+
+ + + {!address ? ( + + ) : needsMove ? ( + + ) : ( + + )} + + + ); +} + +function Centered({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/dashboard/src/components/transfer/OnrampForm.tsx b/apps/dashboard/src/components/transfer/OnrampForm.tsx index 473d3db2e..b732ca965 100644 --- a/apps/dashboard/src/components/transfer/OnrampForm.tsx +++ b/apps/dashboard/src/components/transfer/OnrampForm.tsx @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { Link } from "@tanstack/react-router"; import { getEvmTokensLoadedSnapshot, RampDirection, subscribeEvmTokensLoaded } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { Lock, TriangleAlert } from "lucide-react"; @@ -13,7 +14,8 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { CORRIDORS } from "@/domain/corridors"; -import { getNetworkOptions, getRampTokenOptions, ONRAMP_CORRIDORS } from "@/domain/onramp"; +import { eurOnrampBlocker, getNetworkOptions, getRampTokenOptions, ONRAMP_CORRIDORS } from "@/domain/onramp"; +import { shortenAddress } from "@/domain/transfer"; import type { CorridorId, SenderAccount } from "@/domain/types"; import { useApprovedCorridors } from "@/hooks/useApprovedCorridors"; import { formatCurrencyAmount } from "@/lib/amount"; @@ -108,6 +110,8 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi } : null; const { data: quote, error, isFetching } = useQuote(quoteParams); + const eurRamp = account.onboardings.EU?.ramp ?? null; + const eurBlocker = corridorId === "EU" ? eurOnrampBlocker(eurRamp, address) : null; const transferState = useSelector(transferActor, snapshot => snapshot); const activeOwnerProfileId = transferState.context.activeOwnerProfileId; const belongsToActiveOwner = @@ -140,11 +144,16 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi } function submit(values: OnrampFormValues) { - if (!quote || !quoteParams || !activeOwnerProfileId || activeTransfer) { + if (!quote || !quoteParams || !activeOwnerProfileId || activeTransfer || eurBlocker) { return; } transferActor.send({ - additionalData: { destinationAddress: values.destinationAddress }, + // A EUR pay-in also names the connected wallet: it owns the Monerium-linked address and + // signs the permit that moves the minted EURe on. + additionalData: + values.corridorId === "EU" && address + ? { destinationAddress: values.destinationAddress, walletAddress: address } + : { destinationAddress: values.destinationAddress }, meta: { accountId: account.id, amountIn: quote.inputAmount, @@ -187,7 +196,11 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi - Tokens will be sent here. A wallet connection is not required. + + {corridorId === "EU" + ? "Tokens will be sent here. Your connected Monerium-linked wallet signs the pay-in." + : "Tokens will be sent here. A wallet connection is not required."} + )} @@ -287,6 +300,26 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi )} + {eurBlocker && ( +
+ +

+ {eurBlocker === "link_wallet" ? ( + <> + Link the wallet you will pay in with to your Monerium profile first.{" "} + + Finish EUR setup + + + ) : eurBlocker === "connect_wallet" ? ( + `Connect the wallet linked to Monerium (${shortenAddress(eurRamp?.linkedAddress ?? "")}) to sign the pay-in.` + ) : ( + `Switch to the wallet linked to Monerium (${shortenAddress(eurRamp?.linkedAddress ?? "")}) to sign the pay-in.` + )} +

+
+ )} + {error ? (
@@ -305,8 +338,12 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi
- ) : ( diff --git a/apps/dashboard/src/components/transfer/OnrampPaymentInstructions.tsx b/apps/dashboard/src/components/transfer/OnrampPaymentInstructions.tsx index f05e768a2..f7ed960b7 100644 --- a/apps/dashboard/src/components/transfer/OnrampPaymentInstructions.tsx +++ b/apps/dashboard/src/components/transfer/OnrampPaymentInstructions.tsx @@ -75,6 +75,16 @@ function instructionRows(ramp: RampProcess): Array<{ label: string; value: unkno { label: "Reference", value: payment?.reference }, { label: "Expires", value: payment?.expirationDate } ]; + case "EUR": { + const iban = ramp.ibanPaymentData; + return [ + amountRow, + { label: "Beneficiary", value: iban?.receiverName }, + { label: "IBAN", value: iban?.iban }, + { label: "BIC", value: iban?.bic }, + { label: "Payment reference", value: iban?.reference } + ]; + } default: return []; } diff --git a/apps/dashboard/src/domain/corridors.test.ts b/apps/dashboard/src/domain/corridors.test.ts index 51557decf..c5e5576e6 100644 --- a/apps/dashboard/src/domain/corridors.test.ts +++ b/apps/dashboard/src/domain/corridors.test.ts @@ -16,8 +16,8 @@ describe("isCorridorAvailableForAccountType", () => { }); describe("isCorridorOnboardingDisabled", () => { - it("disables EU onboarding only, leaving every other corridor untouched", () => { - assert.equal(isCorridorOnboardingDisabled(CORRIDORS.EU), true); + it("disables no corridor now that EU onboarding runs through Monerium again", () => { + assert.equal(isCorridorOnboardingDisabled(CORRIDORS.EU), false); for (const corridor of [CORRIDORS.AR, CORRIDORS.BR, CORRIDORS.CO, CORRIDORS.MX, CORRIDORS.US]) { assert.equal(isCorridorOnboardingDisabled(corridor), false); } diff --git a/apps/dashboard/src/domain/corridors.ts b/apps/dashboard/src/domain/corridors.ts index b7cf56207..80c8e5c5b 100644 --- a/apps/dashboard/src/domain/corridors.ts +++ b/apps/dashboard/src/domain/corridors.ts @@ -108,11 +108,11 @@ export function isOnboardingAvailable(corridor: Corridor, kind: OnboardingKind): } /** - * EU onboarding (KYC and KYB) is temporarily switched off: the corridor card replaces every - * actionable button (start, continue, retry, re-authenticate) with a disabled, explanatory - * one, and the wizard refuses the corridor even when opened via the `?onboarding=EU` deep - * link so mid-flow users cannot reach the Monerium flow either. + * Corridors whose onboarding is switched off. When one is, the corridor card replaces every + * actionable button (start, continue, retry, re-authenticate) with a disabled, explanatory one + * and the wizard refuses the corridor even via the `?onboarding=` deep link. None today: + * EU was off while the Monerium onramp was rebuilt and runs through Monerium OAuth again. */ -export function isCorridorOnboardingDisabled(corridor: Corridor): boolean { - return corridor.id === "EU"; +export function isCorridorOnboardingDisabled(_corridor: Corridor): boolean { + return false; } diff --git a/apps/dashboard/src/domain/onramp.test.ts b/apps/dashboard/src/domain/onramp.test.ts index ec50b841b..ac377051f 100644 --- a/apps/dashboard/src/domain/onramp.test.ts +++ b/apps/dashboard/src/domain/onramp.test.ts @@ -8,6 +8,7 @@ import { type RampTokenOption, sortRampTokenOptions } from "./onramp"; +import { eurOnrampBlocker as blocker } from "./onramp"; function option( label: string, @@ -87,3 +88,19 @@ describe("getNetworkOptions", () => { assert.deepEqual(getNetworkOptions([]), []); }); }); + +describe("eurOnrampBlocker", () => { + const ready = { chain: "polygon", iban: "provisioned" as const, linkedAddress: "0xAbC0000000000000000000000000000000000001", source: "oauth" as const }; + + it("requires a provisioned IBAN on a linked wallet first", () => { + assert.equal(blocker(null, ready.linkedAddress), "link_wallet"); + assert.equal(blocker({ ...ready, iban: "missing" }, ready.linkedAddress), "link_wallet"); + assert.equal(blocker({ ...ready, iban: "elsewhere" }, ready.linkedAddress), "link_wallet"); + }); + + it("then requires the linked wallet to be the connected one", () => { + assert.equal(blocker(ready, undefined), "connect_wallet"); + assert.equal(blocker(ready, "0x0000000000000000000000000000000000000002"), "wrong_wallet"); + assert.equal(blocker(ready, ready.linkedAddress.toLowerCase()), null); + }); +}); diff --git a/apps/dashboard/src/domain/onramp.ts b/apps/dashboard/src/domain/onramp.ts index e2ce55464..fb34d54f7 100644 --- a/apps/dashboard/src/domain/onramp.ts +++ b/apps/dashboard/src/domain/onramp.ts @@ -1,3 +1,4 @@ +import type { MoneriumRampReadiness } from "@vortexfi/kyc"; import { doesNetworkSupportRamp, type EvmNetworks, @@ -12,8 +13,24 @@ import { } from "@vortexfi/shared"; import type { CorridorId } from "./types"; -/** Corridors the onramp transfer form can execute — EUR quotes on BUY but has no transfer flow yet. */ -export const ONRAMP_CORRIDORS: CorridorId[] = ["BR", "MX", "CO", "US", "AR"]; +/** Corridors the onramp transfer form can execute. */ +export const ONRAMP_CORRIDORS: CorridorId[] = ["BR", "EU", "MX", "CO", "US", "AR"]; + +export type EurOnrampBlocker = "connect_wallet" | "link_wallet" | "wrong_wallet"; + +/** + * Why an approved EU sender cannot register a EUR pay-in yet. The backend mints to the wallet + * linked to the Monerium profile and needs that wallet's permit, so the connected wallet must be + * the linked one and the profile's IBAN must already point to it. + */ +export function eurOnrampBlocker( + ramp: MoneriumRampReadiness | null | undefined, + connectedAddress: string | undefined +): EurOnrampBlocker | null { + if (!ramp || ramp.iban !== "provisioned" || !ramp.linkedAddress) return "link_wallet"; + if (!connectedAddress) return "connect_wallet"; + return connectedAddress.toLowerCase() === ramp.linkedAddress.toLowerCase() ? null : "wrong_wallet"; +} export interface RampTokenOption { currency: OnChainToken; diff --git a/apps/dashboard/src/domain/types.ts b/apps/dashboard/src/domain/types.ts index 3c562442b..56ec97c3b 100644 --- a/apps/dashboard/src/domain/types.ts +++ b/apps/dashboard/src/domain/types.ts @@ -1,3 +1,4 @@ +import type { MoneriumRampReadiness } from "@vortexfi/kyc"; import { z } from "zod"; /** @@ -46,6 +47,8 @@ export interface Corridor { export interface Onboarding { corridorId: CorridorId; + /** EUR onramp readiness of an approved Monerium account; null until approved or for other providers. */ + ramp?: MoneriumRampReadiness | null; /** Provider-registered company name, when the provider account already exists. */ companyName?: string | null; kind: OnboardingKind; diff --git a/apps/dashboard/src/hooks/useActiveAccount.ts b/apps/dashboard/src/hooks/useActiveAccount.ts index ed1fc199b..f2f98c406 100644 --- a/apps/dashboard/src/hooks/useActiveAccount.ts +++ b/apps/dashboard/src/hooks/useActiveAccount.ts @@ -41,6 +41,7 @@ function deriveOnboardings(entity: OnboardingEntityDto, type: AccountType): Part companyName: account.companyName, corridorId, kind, + ramp: account.ramp ?? null, reauthenticationRequired: account.error?.code === MONERIUM_REAUTHENTICATION_REQUIRED, status, taxReference: account.taxReference, diff --git a/apps/dashboard/src/hooks/useApprovedCorridors.test.ts b/apps/dashboard/src/hooks/useApprovedCorridors.test.ts index fad8e1717..069b4aad8 100644 --- a/apps/dashboard/src/hooks/useApprovedCorridors.test.ts +++ b/apps/dashboard/src/hooks/useApprovedCorridors.test.ts @@ -13,6 +13,7 @@ function account(country: string, state: "approved" | "pending") { kycCase: null, provider: "alfredpay", rail: null, + ramp: null, state, status: state, statusExternal: null, diff --git a/apps/dashboard/src/machines/transfer.actors.ts b/apps/dashboard/src/machines/transfer.actors.ts index 471957e83..d006524fc 100644 --- a/apps/dashboard/src/machines/transfer.actors.ts +++ b/apps/dashboard/src/machines/transfer.actors.ts @@ -121,8 +121,10 @@ export async function registerTransfer(input: RegisterTransferInput): Promise quote.rampType === RampDirection.BUY || tx.signer.toLowerCase() !== walletAddress + tx => !walletAddress || tx.signer.toLowerCase() !== walletAddress ); const apiManager = ApiManager.getInstance(); @@ -158,10 +160,7 @@ export async function registerTransfer(input: RegisterTransferInput): Promise tx.signer.toLowerCase() === walletAddress) - : []; + const userTxs = walletAddress ? (updatedRamp.unsignedTxs ?? []).filter(tx => tx.signer.toLowerCase() === walletAddress) : []; return { ramp: updatedRamp, userTxs }; } diff --git a/apps/dashboard/src/machines/transfer.machine.test.ts b/apps/dashboard/src/machines/transfer.machine.test.ts index 8e6243e55..8080ff24e 100644 --- a/apps/dashboard/src/machines/transfer.machine.test.ts +++ b/apps/dashboard/src/machines/transfer.machine.test.ts @@ -170,6 +170,59 @@ describe("transferMachine", () => { actor.stop(); }); + it("signs the wallet-owned onramp transactions before showing payment instructions", async () => { + const eurRamp = { id: "ramp-eur", inputCurrency: "EUR", type: RampDirection.BUY } as RampProcess; + const permit = { nonce: 0, phase: "moneriumOnrampSelfTransfer", signer: "0x1111111111111111111111111111111111111111" } as UnsignedTx; + let signed = 0; + let startCalls = 0; + const machine = transferMachine.provide({ + actors: { + refreshTransferQuote: fromPromise(async ({ input }) => ({ quote: input.quote })), + registerTransfer: fromPromise(async () => ({ ramp: eurRamp, userTxs: [permit] })), + signUserTransactions: fromPromise(async ({ input }) => { + signed += input.userTxs.length; + return { ...eurRamp, ibanPaymentData: { bic: "MONEEE00", iban: "EE52", receiverName: "Monerium" } } as RampProcess; + }), + startRamp: fromPromise(async () => { + startCalls += 1; + return eurRamp; + }) + } + }); + const actor = createActor(machine).start(); + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" }); + actor.send({ + additionalData: { + destinationAddress: "0x1111111111111111111111111111111111111111", + walletAddress: "0x1111111111111111111111111111111111111111" + }, + meta: { + accountId: "account-1", + amountIn: "100", + amountInToken: "EUR", + corridorId: "EU" as const, + direction: RampDirection.BUY, + fiatPayoutAmount: "107", + ownerProfileId: "profile-1", + payinNetwork: "polygon", + payoutCurrency: "USDC", + recipientEmail: "Your wallet", + recipientId: "", + summary: "107 USDC to your wallet" + }, + ownerProfileId: "profile-1", + quote: { ...quote, id: "quote-eur" } as QuoteResponse, + quoteRequest: { ...quoteRequest, params: { ...quoteRequest.params, corridorId: "EU" as const } }, + type: "START" + }); + + await waitFor(actor, snapshot => snapshot.matches("AwaitingPayment")); + assert.equal(signed, 1); + assert.equal(startCalls, 0); + assert.equal(actor.getSnapshot().context.ramp?.ibanPaymentData?.iban, "EE52"); + actor.stop(); + }); + it("waits for payment confirmation before starting the ramp", async () => { let startCalls = 0; const machine = transferMachine.provide({ diff --git a/apps/dashboard/src/machines/transfer.machine.ts b/apps/dashboard/src/machines/transfer.machine.ts index 0ca1aace1..0648e46af 100644 --- a/apps/dashboard/src/machines/transfer.machine.ts +++ b/apps/dashboard/src/machines/transfer.machine.ts @@ -13,6 +13,7 @@ import { pollRampUntilTerminal, type RefreshTransferQuoteInput, type RegisterTransferInput, + type RegisterTransferOutput, refreshTransferQuote, registerTransfer, signUserTransactions, @@ -123,6 +124,10 @@ export const transferMachine = setup({ }, guards: { isOnramp: ({ context }) => context.quote?.rampType === RampDirection.BUY, + isOnrampWithoutUserTxs: ({ context, event }) => { + const output = (event as unknown as { output?: RegisterTransferOutput }).output; + return context.quote?.rampType === RampDirection.BUY && (output?.userTxs.length ?? 0) === 0; + }, isOwnerEvent: ({ context, event }) => "ownerProfileId" in event && event.ownerProfileId === context.activeOwnerProfileId && @@ -271,7 +276,7 @@ export const transferMachine = setup({ onDone: [ { actions: assign(({ event }) => ({ ramp: event.output.ramp, userTxs: event.output.userTxs })), - guard: "isOnramp", + guard: "isOnrampWithoutUserTxs", target: "AwaitingPayment" }, { @@ -298,10 +303,26 @@ export const transferMachine = setup({ } return { ramp: context.ramp, userTxs: context.userTxs }; }, - onDone: { - actions: assign(({ event }) => ({ ramp: event.output })), - target: "Starting" - }, + onDone: [ + { + // An onramp releases its payment instructions only once the owner-signed + // transactions are in, so keep whatever the update returned. + actions: assign(({ context, event }) => ({ + ramp: { + ...event.output, + achPaymentData: event.output.achPaymentData ?? context.ramp?.achPaymentData, + depositQrCode: event.output.depositQrCode ?? context.ramp?.depositQrCode, + ibanPaymentData: event.output.ibanPaymentData ?? context.ramp?.ibanPaymentData + } + })), + guard: "isOnramp", + target: "AwaitingPayment" + }, + { + actions: assign(({ event }) => ({ ramp: event.output })), + target: "Starting" + } + ], onError: { actions: [ assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), diff --git a/apps/dashboard/src/services/api/onboarding.service.ts b/apps/dashboard/src/services/api/onboarding.service.ts index b6d4415df..e11b7dbec 100644 --- a/apps/dashboard/src/services/api/onboarding.service.ts +++ b/apps/dashboard/src/services/api/onboarding.service.ts @@ -1,3 +1,4 @@ +import type { MoneriumRampReadiness } from "@vortexfi/kyc"; import { apiClient } from "./api-client"; export type OnboardingState = "approved" | "in_review" | "pending" | "rejected" | "started"; @@ -11,6 +12,8 @@ export interface OnboardingAccountDto { rail: string | null; customerType: string | null; error: { code: string; message: string } | null; + /** EUR onramp readiness for an approved Monerium account, null otherwise. */ + ramp: MoneriumRampReadiness | null; status: string; statusExternal: string | null; /** Business tax id (CNPJ) — null for individuals; used to resume a pending company flow. */ diff --git a/apps/dashboard/src/services/transactions/userSigning.ts b/apps/dashboard/src/services/transactions/userSigning.ts index 1ed6addb1..d67a43392 100644 --- a/apps/dashboard/src/services/transactions/userSigning.ts +++ b/apps/dashboard/src/services/transactions/userSigning.ts @@ -1,5 +1,11 @@ -import { getNetworkId, isEvmTransactionData, type SignedTypedData, type UnsignedTx } from "@vortexfi/shared"; -import { getAccount, sendTransaction, signTypedData, switchChain, waitForTransactionReceipt } from "wagmi/actions"; +import { + buildMoneriumWalletLinkMessage, + getNetworkId, + isEvmTransactionData, + type SignedTypedData, + type UnsignedTx +} from "@vortexfi/shared"; +import { getAccount, sendTransaction, signMessage, signTypedData, switchChain, waitForTransactionReceipt } from "wagmi/actions"; import { wagmiConfig } from "@/lib/wagmi"; /** @@ -85,3 +91,8 @@ export async function signAndSubmitEvmTransaction(unsignedTx: UnsignedTx): Promi } } } + +/** Proves ownership of the connected wallet to Monerium with its fixed link message. */ +export async function signMoneriumWalletLinkMessage(): Promise<`0x${string}`> { + return signMessage(wagmiConfig, { message: buildMoneriumWalletLinkMessage() }); +} diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 3bc00a2a4..0e16c3621 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -23,18 +23,20 @@ two people. **Current scope.** The dashboard ships the unified schema (customer entities, provider customers, KYC cases, recipients, notifications), sender/recipient KYC/KYB onboarding, wallet-funded - self-offramps, and fiat-funded self-onramps for BRL, MXN, COP, USD, and ARS. Cross-border + self-offramps, and fiat-funded self-onramps for BRL, EUR, MXN, COP, USD, and ARS. Cross-border fiat-to-fiat transfers, recipient payability, and invited-recipient payout-instrument registration - remain target-state rather than current behavior. The backend now quotes and registers EUR - onramps through Monerium, but the dashboard still lacks the profile-linked owner-wallet signing - journey needed to complete them. EUR offramps are unavailable and return a quote error. The API - and dashboard implement managed headless profiles and route-scoped manager delegation: active - managers can select a child, act through supported dashboard surfaces, and return to their own - account without changing the authenticated manager identity. + remain target-state rather than current behavior. EUR onramps run through Monerium: an EU + sender verifies with Monerium OAuth, links the wallet they will pay in with (Vortex requests or + moves the profile's IBAN to it), and then signs the owner permit with that connected wallet when + registering a EUR pay-in; the SEPA instructions appear after that signature. EUR offramps are + unavailable and return a quote error. The API and dashboard implement managed headless profiles + and route-scoped manager delegation: active managers can select a child, act through supported + dashboard surfaces, and return to their own account without changing the authenticated manager + identity. -The active EUR backend scope assumes the legal entity, approved provider binding, Polygon EOA, and -IBAN were provisioned out of band. Dashboard wallet linking, user-to-corridor binding, KYC/KYB -lifecycle reconciliation, external-profile import, and EUR execution remain deferred. +Monerium profiles onboarded through the OAuth application and profiles the white-label application +can see both work; the backend picks the app that can read the profile at registration. KYC/KYB +lifecycle reconciliation and external-profile import remain deferred. ## User stories @@ -64,6 +66,9 @@ lifecycle reconciliation, external-profile import, and EUR execution remain defe reusing the existing Avenia subaccount and issuing fresh verification links. - As a sender, opening Monerium onboarding immediately marks the EU corridor started; it moves to in review only after Monerium reports that all required information was submitted. +- As an approved EU sender, the corridor card asks me to link the wallet I will pay in with: I + connect it, sign Monerium's ownership message (no gas), and Vortex requests the IBAN or offers + to move an existing one to that wallet. EUR pay-ins stay blocked until the IBAN points to it. - As a sender whose Monerium onboarding is in review, I see status derived from the onboarding profile. Post-migration status ownership remains part of the TBD migration design. - As a sender, I see each corridor's real status — `not_started · started · pending · in_review · From 2d1c46c731a683b74bc65e8d2df04bdd46abe2ab Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 14 Sep 2026 18:00:12 +0200 Subject: [PATCH 08/42] feat(kyc): let a Monerium authorization be re-checked from the redirect A client that can only open the Monerium authorization in another tab (the embedded widget) needs a way to re-read the status once the user returns, so Redirecting accepts REFRESH. --- packages/kyc/src/monerium/machine.test.ts | 22 ++++++++++++++++++++++ packages/kyc/src/monerium/machine.ts | 4 +++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/kyc/src/monerium/machine.test.ts b/packages/kyc/src/monerium/machine.test.ts index 6ad6c163c..de3f59094 100644 --- a/packages/kyc/src/monerium/machine.test.ts +++ b/packages/kyc/src/monerium/machine.test.ts @@ -110,4 +110,26 @@ describe("moneriumKycMachine", () => { await waitFor(actor, snapshot => snapshot.matches("Ready")); }); + + it("re-checks status from Redirecting when the client asks for a refresh", async () => { + const machine = machineWith( + { + completeOAuth: async () => approved, + getStatus: async () => { + throw new MoneriumAuthorizationRequiredError(); + }, + startOAuth: async () => ({ authorizationUrl: "https://example.com/auth" }) + }, + () => undefined + ); + const actor = createActor(machine, { input: { customerType: "individual" } }).start(); + await waitFor(actor, snapshot => snapshot.matches("Ready")); + actor.send({ type: "START_OAUTH" }); + await waitFor(actor, snapshot => snapshot.matches("Redirecting")); + + actor.send({ type: "REFRESH" }); + + await waitFor(actor, snapshot => snapshot.matches("Ready")); + expect(actor.getSnapshot().context.authorizationUrl).toBe("https://example.com/auth"); + }); }); diff --git a/packages/kyc/src/monerium/machine.ts b/packages/kyc/src/monerium/machine.ts index 64354c88a..769be9bd1 100644 --- a/packages/kyc/src/monerium/machine.ts +++ b/packages/kyc/src/monerium/machine.ts @@ -123,7 +123,9 @@ export function createMoneriumKycMachine({ api, client, openAuthorizationUrl }: on: { CLOSE: { target: "Done" }, START_OAUTH: { target: "StartingAuthorization" } } }, Redirecting: { - entry: "openAuthorization" + entry: "openAuthorization", + // A client that could only open the authorization in another tab re-checks on request. + on: { REFRESH: { target: "CheckingStatus" } } }, Rejected: { on: { CLOSE: { target: "Done" }, RETRY: { target: "Ready" } } From 983c67bee1e6d842216415c047209fbc3ed93f7b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 14 Sep 2026 18:00:13 +0200 Subject: [PATCH 09/42] feat(frontend): onboard EUR through Monerium and link the wallet EUR verification in the widget now runs the shared Monerium OAuth machine behind the existing OTP login. Monerium returns to the registered /widget callback; the persisted ramp hands the code and state to the restored verification step, which completes the exchange, and then a second step links the connected EVM wallet by signing Monerium's ownership message and waits for the profile's IBAN to point to it, asking before moving an IBAN that sits elsewhere. EUR pay-ins register the connected wallet as the ramp's wallet address so the existing user-signing actor collects the owner permit before the SEPA instructions appear. The Mykobo child stays only for persisted legacy flows. --- .../components/Monerium/MoneriumKycFlow.tsx | 123 +++++++++++++ .../Monerium/MoneriumWalletFlow.tsx | 77 ++++++++ apps/frontend/src/constants/kybRegions.ts | 5 +- apps/frontend/src/contexts/rampState.tsx | 48 ++++- apps/frontend/src/hooks/useRampUrlParams.ts | 21 ++- .../machines/actors/register.actor.test.ts | 12 ++ .../machines/actors/registerAdditionalData.ts | 10 +- apps/frontend/src/machines/kyc.states.ts | 108 ++++++++++- .../src/machines/moneriumKyc.machine.ts | 24 +++ .../machines/moneriumWallet.machine.test.ts | 116 ++++++++++++ .../src/machines/moneriumWallet.machine.ts | 170 ++++++++++++++++++ apps/frontend/src/machines/ramp.context.ts | 1 + .../src/machines/ramp.machine.test.ts | 89 ++++++--- apps/frontend/src/machines/ramp.machine.ts | 4 + apps/frontend/src/machines/types.ts | 23 +++ apps/frontend/src/pages/widget/index.tsx | 14 ++ apps/frontend/src/translations/en.json | 40 +++++ apps/frontend/src/translations/pt.json | 40 +++++ apps/frontend/src/types/searchParams.ts | 4 + docs/product-dashboard.md | 16 +- docs/proposal-monerium-dual-app.md | 24 +-- 21 files changed, 909 insertions(+), 60 deletions(-) create mode 100644 apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx create mode 100644 apps/frontend/src/components/Monerium/MoneriumWalletFlow.tsx create mode 100644 apps/frontend/src/machines/moneriumKyc.machine.ts create mode 100644 apps/frontend/src/machines/moneriumWallet.machine.test.ts create mode 100644 apps/frontend/src/machines/moneriumWallet.machine.ts diff --git a/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx b/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx new file mode 100644 index 000000000..fb35c1530 --- /dev/null +++ b/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx @@ -0,0 +1,123 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useMoneriumKycActor, useMoneriumKycSelector, useRampActor } from "../../contexts/rampState"; +import { DoneScreen } from "../DoneScreen"; +import { Spinner } from "../Spinner"; + +const LoadingPanel = ({ message }: { message: string }) => ( +
+

{message}

+ +
+); + +interface ActionPanelProps { + title: string; + description: string; + primaryLabel: string; + onPrimary: () => void; + secondaryLabel: string; + onSecondary: () => void; +} + +const ActionPanel = ({ title, description, primaryLabel, onPrimary, secondaryLabel, onSecondary }: ActionPanelProps) => ( +
+

{title}

+

{description}

+ + +
+); + +/** EUR verification through Monerium's hosted OAuth flow, driven by the shared Monerium machine. */ +export const MoneriumKycFlow = () => { + const { t } = useTranslation(); + const actor = useMoneriumKycActor(); + const rampActor = useRampActor(); + const state = useMoneriumKycSelector(); + + const startOAuth = useCallback(() => actor?.send({ type: "START_OAUTH" }), [actor]); + const refresh = useCallback(() => actor?.send({ type: "REFRESH" }), [actor]); + const retry = useCallback(() => actor?.send({ type: "RETRY" }), [actor]); + const close = useCallback(() => actor?.send({ type: "CLOSE" }), [actor]); + const startOver = useCallback(() => rampActor.send({ type: "RESET_RAMP" }), [rampActor]); + + if (!actor || !state) return null; + + const { stateValue, context } = state; + + if ( + stateValue === "Routing" || + stateValue === "CheckingStatus" || + stateValue === "StartingAuthorization" || + stateValue === "CompletingAuthorization" + ) { + return ; + } + + if (stateValue === "Ready") { + return ( + + ); + } + + if (stateValue === "Redirecting") { + return ( + + ); + } + + if (stateValue === "InReview") { + return ( + + ); + } + + if (stateValue === "Approved" || stateValue === "Done") { + return ; + } + + if (stateValue === "Rejected" || stateValue === "Failure") { + return ( +
+

+ {stateValue === "Rejected" ? t("components.moneriumKycFlow.rejected") : t("components.moneriumKycFlow.failure")} +

+ {context.error?.message &&

{context.error.message}

} + + +
+ ); + } + + return null; +}; diff --git a/apps/frontend/src/components/Monerium/MoneriumWalletFlow.tsx b/apps/frontend/src/components/Monerium/MoneriumWalletFlow.tsx new file mode 100644 index 000000000..35558e029 --- /dev/null +++ b/apps/frontend/src/components/Monerium/MoneriumWalletFlow.tsx @@ -0,0 +1,77 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useMoneriumWalletActor, useMoneriumWalletSelector } from "../../contexts/rampState"; +import { Spinner } from "../Spinner"; + +const LoadingPanel = ({ message }: { message: string }) => ( +
+

{message}

+ +
+); + +function shorten(address: string | undefined): string { + return address ? `${address.slice(0, 6)}…${address.slice(-4)}` : ""; +} + +/** Links the connected wallet to the approved Monerium profile and waits for its IBAN. */ +export const MoneriumWalletFlow = () => { + const { t } = useTranslation(); + const actor = useMoneriumWalletActor(); + const state = useMoneriumWalletSelector(); + + const confirmMove = useCallback(() => actor?.send({ type: "CONFIRM_MOVE" }), [actor]); + const retry = useCallback(() => actor?.send({ type: "RETRY" }), [actor]); + const cancel = useCallback(() => actor?.send({ type: "CANCEL" }), [actor]); + + if (!actor || !state) return null; + + const { stateValue, context } = state; + + if (stateValue === "Guard" || stateValue === "Checking") { + return ; + } + if (stateValue === "Linking") { + return ; + } + if (stateValue === "Waiting") { + return ; + } + if (stateValue === "Moving") { + return ; + } + + if (stateValue === "NeedsMove") { + return ( +
+

{t("components.moneriumWalletFlow.needsMove.title")}

+

+ {t("components.moneriumWalletFlow.needsMove.description", { address: shorten(context.address) })} +

+ + +
+ ); + } + + if (stateValue === "Failure") { + return ( +
+

{t("components.moneriumWalletFlow.failure")}

+ {context.error &&

{context.error}

} + + +
+ ); + } + + return null; +}; diff --git a/apps/frontend/src/constants/kybRegions.ts b/apps/frontend/src/constants/kybRegions.ts index 7bd170e81..c6804a533 100644 --- a/apps/frontend/src/constants/kybRegions.ts +++ b/apps/frontend/src/constants/kybRegions.ts @@ -21,9 +21,8 @@ export interface KybRegion { /** * Regions offered in the KYB deep-link selector. Each maps to the fiat token * that determines the KYC/B provider (Brazil → Avenia, Mexico/Colombia/Argentina/USA → Alfredpay). - * Europe/Mykobo is intentionally excluded: it is individual KYC only and requires a connected - * wallet, so it cannot complete a quote-less KYB deep link (the backend's eur recipient rail is - * Monerium, which onboards in the dashboard, not the widget). Add or remove entries here. + * Europe is intentionally excluded: the Monerium flow ends by linking a connected wallet and + * provisioning its IBAN, which a quote-less KYB deep link cannot do. Add or remove entries here. */ export const KYB_REGIONS: KybRegion[] = [ { diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 88b269e69..158ebe8a2 100644 --- a/apps/frontend/src/contexts/rampState.tsx +++ b/apps/frontend/src/contexts/rampState.tsx @@ -8,9 +8,13 @@ import { rampMachine } from "../machines/ramp.machine"; import { AlfredpayKycActorRef, AveniaKycActorRef, + MoneriumKycActorRef, + MoneriumWalletActorRef, MykoboKycActorRef, SelectedAlfredpayData, SelectedAveniaData, + SelectedMoneriumData, + SelectedMoneriumWalletData, SelectedMykoboData } from "../machines/types"; import { AuthService } from "../services/auth"; @@ -53,7 +57,9 @@ type SelectableActorRef = Pick; type ActorSnapshot = TActor extends { getSnapshot(): infer TSnapshot } ? TSnapshot : never; type SelectedKycData = { stateValue: unknown; context: unknown }; -function useKycChildActor(id: "aveniaKyc" | "mykoboKyc" | "alfredpayKyc"): T | undefined { +function useKycChildActor( + id: "aveniaKyc" | "mykoboKyc" | "alfredpayKyc" | "moneriumKyc" | "moneriumWallet" +): T | undefined { const rampActor = useRampActor(); return useSelector(rampActor, snapshot => (snapshot.children as Record)[id]) as T | undefined; } @@ -74,6 +80,8 @@ const PersistenceEffect = () => { const rampActor = useRampActor(); const aveniaActor = useKycChildActor("aveniaKyc"); const mykoboActor = useKycChildActor("mykoboKyc"); + const moneriumActor = useKycChildActor("moneriumKyc"); + const moneriumWalletActor = useKycChildActor("moneriumWallet"); const { rampContext, rampState, isQuoteExpired, quote } = useSelector(rampActor, state => ({ isQuoteExpired: state?.context.isQuoteExpired, @@ -84,6 +92,8 @@ const PersistenceEffect = () => { const aveniaState = useSelector(aveniaActor, state => state?.value); const mykoboState = useSelector(mykoboActor, state => state?.value); + const moneriumState = useSelector(moneriumActor, state => state?.value); + const moneriumWalletState = useSelector(moneriumWalletActor, state => state?.value); // biome-ignore lint/correctness/useExhaustiveDependencies: run when selected snapshot pieces change; isQuoteExpired/quote must persist useEffect(() => { @@ -106,7 +116,17 @@ const PersistenceEffect = () => { markRampEphemeralsTerminal(rampId); } } - }, [rampContext, rampState, aveniaState, mykoboState, isQuoteExpired, quote, rampActor.getPersistedSnapshot]); + }, [ + rampContext, + rampState, + aveniaState, + mykoboState, + moneriumState, + moneriumWalletState, + isQuoteExpired, + quote, + rampActor.getPersistedSnapshot + ]); return null; }; @@ -215,3 +235,27 @@ export function useAlfredpayKycSelector(): SelectedAlfredpayData | undefined { stateValue: snapshot.value })); } + +export function useMoneriumKycActor(): MoneriumKycActorRef | undefined { + return useKycChildActor("moneriumKyc"); +} + +export function useMoneriumKycSelector(): SelectedMoneriumData | undefined { + const actor = useMoneriumKycActor(); + return useKycChildSelector(actor, snapshot => ({ + context: snapshot.context, + stateValue: snapshot.value + })); +} + +export function useMoneriumWalletActor(): MoneriumWalletActorRef | undefined { + return useKycChildActor("moneriumWallet"); +} + +export function useMoneriumWalletSelector(): SelectedMoneriumWalletData | undefined { + const actor = useMoneriumWalletActor(); + return useKycChildSelector(actor, snapshot => ({ + context: snapshot.context, + stateValue: snapshot.value + })); +} diff --git a/apps/frontend/src/hooks/useRampUrlParams.ts b/apps/frontend/src/hooks/useRampUrlParams.ts index 5f26a2c55..96bdb0e8b 100644 --- a/apps/frontend/src/hooks/useRampUrlParams.ts +++ b/apps/frontend/src/hooks/useRampUrlParams.ts @@ -1,4 +1,5 @@ import { useSearch } from "@tanstack/react-router"; +import type { MoneriumOAuthCallback } from "@vortexfi/kyc"; import { AssetHubToken, DestinationType, @@ -50,6 +51,7 @@ interface RampUrlParams { invite?: string; region?: string; kybRegionLocked?: boolean; + moneriumCallback?: MoneriumOAuthCallback; } function findFiatToken(fiatToken?: string): FiatToken | undefined { @@ -207,6 +209,12 @@ export const useRampUrlParams = (): RampUrlParams => { const callbackUrlParam = searchParams.callbackUrl; const externalSessionIdParam = searchParams.externalSessionId; const inviteParam = searchParams.invite; + const moneriumCallback: MoneriumOAuthCallback | undefined = + searchParams.code && searchParams.state + ? { code: searchParams.code, state: searchParams.state } + : searchParams.error + ? { error: searchParams.error, errorDescription: searchParams.error_description } + : undefined; const rampDirection = rampDirectionParam === RampDirection.BUY || rampDirectionParam === RampDirection.SELL @@ -229,6 +237,7 @@ export const useRampUrlParams = (): RampUrlParams => { invite: inviteParam || undefined, kybMode, kybRegionLocked, + moneriumCallback, network, partnerId: partnerIdParam || undefined, paymentMethod: paymentMethodParam || undefined, @@ -261,7 +270,8 @@ export const useSetRampUrlParams = () => { invite, kybMode, region, - kybRegionLocked + kybRegionLocked, + moneriumCallback } = useRampUrlParams(); const onToggle = useRampDirectionToggle(); @@ -297,6 +307,15 @@ export const useSetRampUrlParams = () => { if (!isWidget) return; if (hasInitialized.current) return; + // Back from Monerium OAuth: the persisted ramp restores into the EUR KYC step; hand it the + // callback and drop the one-time params so a reload cannot replay the exchange. + if (moneriumCallback) { + rampActor.send({ callback: moneriumCallback, type: "MONERIUM_CALLBACK" }); + window.history.replaceState({}, "", window.location.pathname); + hasInitialized.current = true; + return; + } + // KYB deep link: jump straight into the email/OTP → region → KYB flow, no quote needed. // Session/partner attribution still applies — the subaccount creation forwards externalSessionId. // An invite token alone implies the recipient hand-off even when the link carries no KYB diff --git a/apps/frontend/src/machines/actors/register.actor.test.ts b/apps/frontend/src/machines/actors/register.actor.test.ts index f4017881d..14c780183 100644 --- a/apps/frontend/src/machines/actors/register.actor.test.ts +++ b/apps/frontend/src/machines/actors/register.actor.test.ts @@ -52,6 +52,18 @@ const baseContext = { } as unknown as RampContext; describe("buildRegisterRampAdditionalData", () => { + it("names the connected wallet for Monerium EUR onramps and never sends the email", () => { + const buyContext = { + ...baseContext, + executionInput: { ...baseContext.executionInput, quote: { id: "quote-1", rampType: RampDirection.BUY } } + } as unknown as RampContext; + expect(buildRegisterRampAdditionalData(buyContext, baseContext.connectedWalletAddress as string)).toEqual({ + destinationAddress: "0x2222222222222222222222222222222222222222", + sessionId: "session-1", + walletAddress: "0x1111111111111111111111111111111111111111" + }); + }); + it("passes email and destination address for Mykobo EUR offramps", () => { expect(buildRegisterRampAdditionalData(baseContext, baseContext.connectedWalletAddress as string)).toMatchObject({ destinationAddress: "0x2222222222222222222222222222222222222222", diff --git a/apps/frontend/src/machines/actors/registerAdditionalData.ts b/apps/frontend/src/machines/actors/registerAdditionalData.ts index fae6f53cb..e62a20957 100644 --- a/apps/frontend/src/machines/actors/registerAdditionalData.ts +++ b/apps/frontend/src/machines/actors/registerAdditionalData.ts @@ -34,14 +34,12 @@ export function buildRegisterRampAdditionalData( } if (rampType === RampDirection.BUY && executionInput.fiatToken === FiatToken.EURC) { - if (!input.userEmail) { - throw new RegisterRampError("User email is required for Mykobo EUR onramp.", RegisterRampErrorType.InvalidInput); - } - + // The Monerium onramp mints to the connected wallet linked to the profile and needs its permit; + // identity is derived server-side from the authenticated user. return { destinationAddress: executionInput.sourceOrDestinationAddress, - email: input.userEmail, - sessionId: input.externalSessionId + sessionId: input.externalSessionId, + walletAddress: connectedWalletAddress }; } diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 6d68bceb3..651ccfee7 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -1,14 +1,23 @@ -import { AlfredpayKycContext, AlfredpayKycOutput, type AveniaKycContext, KycStatus } from "@vortexfi/kyc"; +import { + AlfredpayKycContext, + AlfredpayKycOutput, + type AveniaKycContext, + KycStatus, + type MoneriumKycInput, + type MoneriumKycOutput +} from "@vortexfi/kyc"; import { FiatToken } from "@vortexfi/shared"; import { assign, DoneActorEvent, sendTo } from "xstate"; import { ALFREDPAY_FIAT_TOKEN_TO_COUNTRY } from "../constants/fiatAccountMethods"; +import type { MoneriumWalletInput, MoneriumWalletOutput } from "./moneriumWallet.machine"; import { MykoboKycFiles, MykoboKycFormData, MykoboKycMachineError, MykoboKycMachineErrorType } from "./mykoboKyc.machine"; import { RampContext } from "./types"; -type KycChildId = "aveniaKyc" | "alfredpayKyc" | "mykoboKyc"; +type KycChildId = "aveniaKyc" | "alfredpayKyc" | "moneriumKyc" | "mykoboKyc"; const KYC_CHILD_BY_FIAT: Record = { - [FiatToken.EURC]: "mykoboKyc", + // EUR onboards through Monerium OAuth; the Mykobo child stays only for persisted legacy flows. + [FiatToken.EURC]: "moneriumKyc", [FiatToken.BRL]: "aveniaKyc", [FiatToken.ARS]: "alfredpayKyc", [FiatToken.USD]: "alfredpayKyc", @@ -30,6 +39,9 @@ export interface MykoboKycContext extends RampContext { type MykoboKycOutput = { profileApproved?: boolean; error?: MykoboKycMachineError }; +const moneriumCustomerType = (context: RampContext) => + context.kybLink?.customerType === "business" ? "business" : "individual"; + const clearSigningPhase = assign({ rampSigningPhase: undefined, rampSigningPhaseCurrent: undefined, @@ -148,6 +160,13 @@ export const kycStateNode = { }, target: "Alfredpay" }, + { + guard: ({ context }: { context: RampContext }) => { + const fiatToken = resolveKycFiatToken(context); + return !!fiatToken && KYC_CHILD_BY_FIAT[fiatToken] === "moneriumKyc"; + }, + target: "Monerium" + }, { guard: ({ context }: { context: RampContext }) => { const fiatToken = resolveKycFiatToken(context); @@ -160,6 +179,89 @@ export const kycStateNode = { } ] }, + Monerium: { + invoke: { + id: "moneriumKyc", + input: ({ context }: { context: RampContext }): MoneriumKycInput => ({ + callback: context.moneriumCallback, + customerType: moneriumCustomerType(context) + }), + onDone: [ + { + actions: assign({ moneriumCallback: undefined }), + guard: ({ event }: { event: DoneActorEvent }) => event.output.status === "APPROVED", + target: "MoneriumWallet" + }, + { + // Closed before approval (in review, rejected, cancelled): keep the quote, explain, and let the user retry. + actions: [ + clearSigningPhase, + assign({ + initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => + event.output.error?.message || + (event.output.status ? "Monerium has not approved your verification yet." : undefined), + moneriumCallback: undefined + }) + ], + target: "#ramp.QuoteReady" + } + ], + onError: { + actions: assign({ + initializeFailedMessage: "Monerium verification failed. Please retry.", + moneriumCallback: undefined + }), + target: "#ramp.KycFailure" + }, + src: "moneriumKyc" + }, + on: { + // The OAuth round trip restores the ramp here; restart the child with the callback so it completes the exchange. + MONERIUM_CALLBACK: { + actions: assign({ + moneriumCallback: ({ event }: { event: { callback: RampContext["moneriumCallback"] } }) => event.callback + }), + reenter: true, + target: "Monerium" + }, + MONERIUM_REFRESH: { + actions: sendTo("moneriumKyc", { type: "REFRESH" }) + } + } + }, + MoneriumWallet: { + invoke: { + id: "moneriumWallet", + input: ({ context }: { context: RampContext }): MoneriumWalletInput => ({ + address: context.connectedWalletAddress, + customerType: moneriumCustomerType(context), + // Substrate wallets report a negative chain id; the permit needs an EOA on an EVM chain. + isEvmWallet: context.chainId !== undefined && context.chainId > 0, + signMessage: context.getMessageSignature + }), + onDone: [ + { + guard: ({ event }: { event: DoneActorEvent }) => event.output.ready, + target: "VerificationComplete" + }, + { + actions: [ + clearSigningPhase, + assign({ + initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => + event.output.error || "Your wallet is not linked to Monerium yet." + }) + ], + target: "#ramp.QuoteReady" + } + ], + onError: { + actions: assign({ initializeFailedMessage: "Could not link your wallet to Monerium. Please retry." }), + target: "#ramp.KycFailure" + }, + src: "moneriumWallet" + } + }, Mykobo: { invoke: { id: "mykoboKyc", diff --git a/apps/frontend/src/machines/moneriumKyc.machine.ts b/apps/frontend/src/machines/moneriumKyc.machine.ts new file mode 100644 index 000000000..e758ef51b --- /dev/null +++ b/apps/frontend/src/machines/moneriumKyc.machine.ts @@ -0,0 +1,24 @@ +import { createMoneriumKycApi, createMoneriumKycMachine } from "@vortexfi/kyc"; +import { apiClient } from "../services/api"; + +export const moneriumKycApi = createMoneriumKycApi(apiClient); + +/** + * Top-level navigation keeps the OAuth round trip in one tab; the ramp state is persisted and + * restored on return. An embedded widget cannot leave the host page, so it opens a tab instead + * and re-checks the status when the user comes back. + */ +export function openMoneriumAuthorization(url: string): void { + if (typeof window === "undefined") return; + if (window.self !== window.top) { + window.open(url, "_blank", "noopener"); + return; + } + window.location.assign(url); +} + +export const moneriumKycMachine = createMoneriumKycMachine({ + api: moneriumKycApi, + client: "widget", + openAuthorizationUrl: openMoneriumAuthorization +}); diff --git a/apps/frontend/src/machines/moneriumWallet.machine.test.ts b/apps/frontend/src/machines/moneriumWallet.machine.test.ts new file mode 100644 index 000000000..b1be45d7c --- /dev/null +++ b/apps/frontend/src/machines/moneriumWallet.machine.test.ts @@ -0,0 +1,116 @@ +import type { MoneriumRampReadiness, MoneriumStatusResponse } from "@vortexfi/kyc"; +import { describe, expect, it, vi } from "vitest"; +import { createActor, waitFor } from "xstate"; +import { createMoneriumWalletMachine, type MoneriumWalletInput } from "./moneriumWallet.machine"; + +const ADDRESS = "0x1111111111111111111111111111111111111111"; +const OTHER = "0x2222222222222222222222222222222222222222"; + +function status(ramp: Partial | null, rampError?: { code: string; message: string }): MoneriumStatusResponse { + return { + customerType: "individual", + profileId: "profile-1", + ...(ramp ? { ramp: { chain: "polygon", iban: "missing", linkedAddress: null, source: "oauth", ...ramp } } : {}), + ...(rampError ? { rampError } : {}), + status: "APPROVED", + statusExternal: "approved" + }; +} + +function input(overrides: Partial = {}): MoneriumWalletInput { + return { address: ADDRESS, customerType: "individual", isEvmWallet: true, signMessage: async () => "0xsig", ...overrides }; +} + +function api(statuses: MoneriumStatusResponse[]) { + const calls = { link: 0, move: 0 }; + return { + api: { + getStatus: vi.fn(async () => statuses.length > 1 ? (statuses.shift() as MoneriumStatusResponse) : statuses[0]), + linkWallet: vi.fn(async () => { + calls.link += 1; + return { address: ADDRESS, chain: "polygon", iban: "pending" as const }; + }), + moveIban: vi.fn(async () => { + calls.move += 1; + return { address: ADDRESS, chain: "polygon", iban: "provisioned" as const }; + }) + }, + calls + }; +} + +describe("moneriumWalletMachine", () => { + it("finishes immediately when the IBAN already points to the connected wallet", async () => { + const { api: client, calls } = api([status({ iban: "provisioned", linkedAddress: ADDRESS })]); + const actor = createActor(createMoneriumWalletMachine(client), { input: input() }).start(); + await waitFor(actor, snapshot => snapshot.status === "done"); + expect(actor.getSnapshot().output).toEqual({ error: undefined, ready: true }); + expect(calls.link).toBe(0); + }); + + it("links the wallet, waits for provisioning, and finishes once the IBAN lands", async () => { + vi.useFakeTimers(); + try { + const { api: client, calls } = api([ + status({ iban: "missing" }), + status({ iban: "missing", linkedAddress: ADDRESS }), + status({ iban: "provisioned", linkedAddress: ADDRESS }) + ]); + const actor = createActor(createMoneriumWalletMachine(client), { input: input() }).start(); + await waitFor(actor, snapshot => snapshot.matches("Waiting")); + expect(calls.link).toBe(1); + expect(client.linkWallet).toHaveBeenCalledWith({ address: ADDRESS, chain: "polygon", signature: "0xsig" }); + await vi.advanceTimersByTimeAsync(5_000); + await waitFor(actor, snapshot => snapshot.status === "done"); + expect(actor.getSnapshot().output?.ready).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("asks before moving an IBAN that sits on another wallet", async () => { + const { api: client, calls } = api([ + status({ iban: "elsewhere", linkedAddress: ADDRESS }), + status({ iban: "provisioned", linkedAddress: ADDRESS }) + ]); + const actor = createActor(createMoneriumWalletMachine(client), { input: input() }).start(); + await waitFor(actor, snapshot => snapshot.matches("NeedsMove")); + expect(calls.move).toBe(0); + actor.send({ type: "CONFIRM_MOVE" }); + await waitFor(actor, snapshot => snapshot.status === "done"); + expect(calls.move).toBe(1); + expect(actor.getSnapshot().output?.ready).toBe(true); + }); + + it("treats a provisioned IBAN on a different wallet as movable after linking this one", async () => { + const { api: client, calls } = api([ + status({ iban: "provisioned", linkedAddress: OTHER }), + status({ iban: "provisioned", linkedAddress: OTHER }), + status({ iban: "provisioned", linkedAddress: ADDRESS }) + ]); + const actor = createActor(createMoneriumWalletMachine(client), { input: input() }).start(); + await waitFor(actor, snapshot => snapshot.matches("NeedsMove")); + expect(calls.link).toBe(1); + actor.send({ type: "CONFIRM_MOVE" }); + await waitFor(actor, snapshot => snapshot.status === "done"); + expect(actor.getSnapshot().output?.ready).toBe(true); + }); + + it("surfaces a lost Monerium session and retries on request", async () => { + const { api: client } = api([status(null, { code: "MONERIUM_REAUTHENTICATION_REQUIRED", message: "Monerium reauthentication is required" })]); + const actor = createActor(createMoneriumWalletMachine(client), { input: input() }).start(); + await waitFor(actor, snapshot => snapshot.matches("Failure")); + expect(actor.getSnapshot().context.error).toContain("reauthentication"); + actor.send({ type: "CANCEL" }); + await waitFor(actor, snapshot => snapshot.status === "done"); + expect(actor.getSnapshot().output?.ready).toBe(false); + }); + + it("refuses a substrate wallet without calling Monerium", async () => { + const { api: client } = api([status({ iban: "missing" })]); + const actor = createActor(createMoneriumWalletMachine(client), { input: input({ isEvmWallet: false }) }).start(); + await waitFor(actor, snapshot => snapshot.status === "done"); + expect(actor.getSnapshot().output).toEqual({ error: "Connect an EVM wallet to receive EUR", ready: false }); + expect(client.getStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/machines/moneriumWallet.machine.ts b/apps/frontend/src/machines/moneriumWallet.machine.ts new file mode 100644 index 000000000..d936650b5 --- /dev/null +++ b/apps/frontend/src/machines/moneriumWallet.machine.ts @@ -0,0 +1,170 @@ +import type { MoneriumCustomerType, MoneriumKycApi, MoneriumRampReadiness, MoneriumWalletApi } from "@vortexfi/kyc"; +import { buildMoneriumWalletLinkMessage } from "@vortexfi/shared"; +import { assign, fromPromise, setup } from "xstate"; +import { moneriumKycApi } from "./moneriumKyc.machine"; + +export interface MoneriumWalletInput { + address: string | undefined; + customerType: MoneriumCustomerType; + /** Substrate wallets cannot sign the ERC-2612 permit the onramp needs. */ + isEvmWallet: boolean; + signMessage: ((message: string) => Promise<`0x${string}`>) | undefined; +} + +export interface MoneriumWalletContext extends MoneriumWalletInput { + error?: string; + /** Set once this wallet was linked in this flow, so readiness reads are interpreted for it. */ + linked?: boolean; + readiness?: MoneriumRampReadiness; +} + +export interface MoneriumWalletOutput { + error?: string; + ready: boolean; +} + +export type MoneriumWalletEvent = { type: "CANCEL" } | { type: "CONFIRM_MOVE" } | { type: "RETRY" }; + +type MoneriumWalletApiClient = Pick & MoneriumWalletApi; + +const POLL_INTERVAL_MS = 5_000; + +function linkedHere(context: MoneriumWalletContext, readiness: MoneriumRampReadiness): boolean { + return ( + context.linked === true || + (!!context.address && !!readiness.linkedAddress && readiness.linkedAddress.toLowerCase() === context.address.toLowerCase()) + ); +} + +function readinessOf(event: unknown): MoneriumRampReadiness { + return (event as { output: MoneriumRampReadiness }).output; +} + +function errorOf(event: unknown, fallback: string): string { + const error = (event as { error?: unknown }).error; + return error instanceof Error ? error.message : fallback; +} + +/** + * Links the connected EOA to the approved Monerium profile and waits until the profile's IBAN + * points to it, so the EUR onramp can mint there and take its permit. An IBAN that already sits + * on another wallet or chain is moved only after the user confirms, because that redirects their + * future SEPA deposits. + */ +export function createMoneriumWalletMachine(api: MoneriumWalletApiClient = moneriumKycApi) { + return setup({ + actions: { + storeReadiness: assign({ error: () => undefined, readiness: ({ event }) => readinessOf(event) }) + }, + actors: { + linkWallet: fromPromise(async ({ input }: { input: MoneriumWalletContext }) => { + if (!input.address || !input.signMessage || !input.readiness) + throw new Error("Connect a wallet to link it to Monerium"); + const signature = await input.signMessage(buildMoneriumWalletLinkMessage()); + return api.linkWallet({ address: input.address, chain: input.readiness.chain, signature }); + }), + moveIban: fromPromise(async ({ input }: { input: MoneriumWalletContext }) => { + if (!input.address || !input.readiness) throw new Error("Connect a wallet to move the IBAN to it"); + return api.moveIban({ address: input.address, chain: input.readiness.chain }); + }), + readReadiness: fromPromise(async ({ input }: { input: MoneriumWalletContext }): Promise => { + const status = await api.getStatus(input.customerType); + if (status.rampError) throw new Error(status.rampError.message); + if (!status.ramp) throw new Error("Monerium has not approved your verification yet"); + return status.ramp; + }), + // Monerium provisions the IBAN asynchronously; re-read on a fixed cadence until it lands. + wait: fromPromise(() => new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))) + }, + guards: { + hasEvmWallet: ({ context }) => context.isEvmWallet && !!context.address, + isPending: ({ context, event }) => readinessOf(event).iban === "missing" && linkedHere(context, readinessOf(event)), + isReady: ({ context, event }) => { + const readiness = readinessOf(event); + return ( + readiness.iban === "provisioned" && + !!context.address && + readiness.linkedAddress?.toLowerCase() === context.address.toLowerCase() + ); + }, + needsMove: ({ context, event }) => readinessOf(event).iban !== "missing" && linkedHere(context, readinessOf(event)) + }, + types: { + context: {} as MoneriumWalletContext, + events: {} as MoneriumWalletEvent, + input: {} as MoneriumWalletInput, + output: {} as MoneriumWalletOutput + } + }).createMachine({ + context: ({ input }) => ({ ...input }), + id: "moneriumWallet", + initial: "Guard", + output: ({ context }) => ({ error: context.error, ready: !context.error && context.readiness?.iban === "provisioned" }), + states: { + Cancelled: { type: "final" }, + Checking: { + invoke: { + input: ({ context }) => context, + onDone: [ + { actions: "storeReadiness", guard: "isReady", target: "Ready" }, + { actions: "storeReadiness", guard: "needsMove", target: "NeedsMove" }, + { actions: "storeReadiness", guard: "isPending", target: "Waiting" }, + { actions: "storeReadiness", target: "Linking" } + ], + onError: { + actions: assign({ error: ({ event }) => errorOf(event, "Could not read your Monerium status") }), + target: "Failure" + }, + src: "readReadiness" + } + }, + Failure: { + on: { + CANCEL: { target: "Cancelled" }, + RETRY: { actions: assign({ error: () => undefined }), target: "Checking" } + } + }, + Guard: { + always: [ + { guard: "hasEvmWallet", target: "Checking" }, + { actions: assign({ error: () => "Connect an EVM wallet to receive EUR" }), target: "Cancelled" } + ] + }, + Linking: { + invoke: { + input: ({ context }) => context, + onDone: { actions: assign({ linked: () => true }), target: "Checking" }, + onError: { + actions: assign({ error: ({ event }) => errorOf(event, "Could not link your wallet to Monerium") }), + target: "Failure" + }, + src: "linkWallet" + } + }, + Moving: { + invoke: { + input: ({ context }) => context, + onDone: { target: "Checking" }, + onError: { + actions: assign({ error: ({ event }) => errorOf(event, "Could not move your IBAN") }), + target: "Failure" + }, + src: "moveIban" + } + }, + NeedsMove: { + on: { + CANCEL: { actions: assign({ error: () => "The IBAN was not moved to this wallet" }), target: "Cancelled" }, + CONFIRM_MOVE: { target: "Moving" } + } + }, + Ready: { type: "final" }, + Waiting: { + invoke: { onDone: { target: "Checking" }, src: "wait" }, + on: { CANCEL: { actions: assign({ error: () => "IBAN provisioning was cancelled" }), target: "Cancelled" } } + } + } + }); +} + +export const moneriumWalletMachine = createMoneriumWalletMachine(); diff --git a/apps/frontend/src/machines/ramp.context.ts b/apps/frontend/src/machines/ramp.context.ts index 0ab847563..690b8bc04 100644 --- a/apps/frontend/src/machines/ramp.context.ts +++ b/apps/frontend/src/machines/ramp.context.ts @@ -16,6 +16,7 @@ export const initialRampContext: RampContext = { isQuoteExpired: false, isQuoteRedo: false, kybLink: undefined, + moneriumCallback: undefined, partnerId: undefined, paymentData: undefined, postAuthTarget: undefined, diff --git a/apps/frontend/src/machines/ramp.machine.test.ts b/apps/frontend/src/machines/ramp.machine.test.ts index 47412d8bb..56e4ce9f6 100644 --- a/apps/frontend/src/machines/ramp.machine.test.ts +++ b/apps/frontend/src/machines/ramp.machine.test.ts @@ -21,7 +21,8 @@ import { RampLimitExceededError } from "./actors/validateKyc.actor"; import { AlfredpayKycMachineError, AlfredpayKycMachineErrorType } from "@vortexfi/kyc"; import { alfredpayKycMachine } from "./alfredpayKyc.machine"; import { aveniaKycMachine } from "./brlaKyc.machine"; -import { MykoboKycMachineError, MykoboKycMachineErrorType, mykoboKycMachine } from "./mykoboKyc.machine"; +import type { moneriumKycMachine } from "./moneriumKyc.machine"; +import type { moneriumWalletMachine } from "./moneriumWallet.machine"; import { RampContext, RampMachineEvents, RampState } from "./types"; import { rampMachine } from "./ramp.machine"; @@ -106,15 +107,21 @@ function createRampActor(actors?: ProvideArg["actors"], actions?: ProvideArg["ac return createActor(rampMachine.provide(buildImplementations(actors, actions))); } -/** Minimal final child machine standing in for the Mykobo KYC machine. It finishes on FINISH. */ -function stubMykoboMachine(output: { profileApproved?: boolean; error?: MykoboKycMachineError }) { +/** Minimal child machine standing in for a Monerium step. It finishes with the given output on FINISH. */ +function stubFinalMachine(output: unknown) { return setup({}).createMachine({ initial: "Waiting", output: () => output, states: { Done: { type: "final" }, Waiting: { on: { FINISH: "Done" } } } - }) as unknown as typeof mykoboKycMachine; + }) as unknown as T; } +const approvedMonerium = { customerType: "individual", status: "APPROVED" }; +const stubMoneriumKyc = (output: unknown = approvedMonerium) => stubFinalMachine(output); +const stubMoneriumWallet = (output: unknown = { ready: true }) => stubFinalMachine(output); +const finishChild = (actor: ReturnType, id: string) => + (actor.getSnapshot().children[id] as AnyActorRef).send({ type: "FINISH" }); + /** Minimal child machine standing in for an approved Avenia KYC machine. */ const stubAveniaMachine = setup({}).createMachine({ initial: "Done", @@ -463,53 +470,87 @@ describe("rampMachine", () => { }); describe("KYC routing", () => { - it("routes EURC ramps with kycNeeded to the Mykobo child and advances to KycComplete on approval", async () => { + it("routes EURC ramps through Monerium verification and wallet linking before KycComplete", async () => { const actor = createRampActor({ - mykoboKyc: stubMykoboMachine({ profileApproved: true }), + moneriumKyc: stubMoneriumKyc(), + moneriumWallet: stubMoneriumWallet(), validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) }); actor.start(); await goToQuoteReady(actor); await confirmRamp(actor); - await waitFor(actor, s => s.matches({ KYC: "Mykobo" })); - - (actor.getSnapshot().children.mykoboKyc as AnyActorRef).send({ type: "FINISH" }); + await waitFor(actor, s => s.matches({ KYC: "Monerium" })); + finishChild(actor, "moneriumKyc"); + await waitFor(actor, s => s.matches({ KYC: "MoneriumWallet" })); + finishChild(actor, "moneriumWallet"); await waitFor(actor, s => s.matches("KycComplete")); }); - it("returns to QuoteReady when the user cancels Mykobo KYC", async () => { + it("restarts the Monerium step with the OAuth callback that brought the user back", async () => { const actor = createRampActor({ - mykoboKyc: stubMykoboMachine({ - error: new MykoboKycMachineError("Cancelled by the user", MykoboKycMachineErrorType.UserRejected) - }), + moneriumKyc: stubMoneriumKyc(), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + await waitFor(actor, s => s.matches({ KYC: "Monerium" })); + const before = actor.getSnapshot().children.moneriumKyc; + + actor.send({ callback: { code: "code-1", state: "state-1" }, type: "MONERIUM_CALLBACK" }); + + expect(actor.getSnapshot().matches({ KYC: "Monerium" })).toBe(true); + expect(actor.getSnapshot().children.moneriumKyc).not.toBe(before); + expect(actor.getSnapshot().context.moneriumCallback).toEqual({ code: "code-1", state: "state-1" }); + }); + + it("returns to QuoteReady without a message when the user closes Monerium before approval", async () => { + const actor = createRampActor({ + moneriumKyc: stubMoneriumKyc({ customerType: "individual" }), + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor); + await waitFor(actor, s => s.matches({ KYC: "Monerium" })); + + finishChild(actor, "moneriumKyc"); + await waitFor(actor, s => s.matches("QuoteReady")); + expect(actor.getSnapshot().context.initializeFailedMessage).toBeUndefined(); + }); + + it("a Monerium rejection returns to QuoteReady and keeps the failure message", async () => { + const actor = createRampActor({ + moneriumKyc: stubMoneriumKyc({ customerType: "individual", status: "REJECTED" }), validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) }); actor.start(); await goToQuoteReady(actor); await confirmRamp(actor); - await waitFor(actor, s => s.matches({ KYC: "Mykobo" })); + await waitFor(actor, s => s.matches({ KYC: "Monerium" })); - (actor.getSnapshot().children.mykoboKyc as AnyActorRef).send({ type: "FINISH" }); + finishChild(actor, "moneriumKyc"); await waitFor(actor, s => s.matches("QuoteReady")); + expect(actor.getSnapshot().context.initializeFailedMessage).toBe("Monerium has not approved your verification yet."); }); - it("a KYC rejection resets the ramp but keeps the failure message", async () => { + it("an unlinked wallet returns to QuoteReady with the wallet step's message", async () => { const actor = createRampActor({ - mykoboKyc: stubMykoboMachine({ - error: new MykoboKycMachineError("KYC was rejected", MykoboKycMachineErrorType.KycRejected) - }), + moneriumKyc: stubMoneriumKyc(), + moneriumWallet: stubMoneriumWallet({ error: "Connect an EVM wallet to receive EUR", ready: false }), validateKyc: fromPromise(async (): Promise => ({ kycNeeded: true })) }); actor.start(); await goToQuoteReady(actor); await confirmRamp(actor); - await waitFor(actor, s => s.matches({ KYC: "Mykobo" })); + await waitFor(actor, s => s.matches({ KYC: "Monerium" })); + finishChild(actor, "moneriumKyc"); + await waitFor(actor, s => s.matches({ KYC: "MoneriumWallet" })); - (actor.getSnapshot().children.mykoboKyc as AnyActorRef).send({ type: "FINISH" }); - // KycFailure immediately resets; the reset preserves initializeFailedMessage for the UI. - await waitFor(actor, s => s.matches("Idle")); - expect(actor.getSnapshot().context.initializeFailedMessage).toBe("KYC was rejected"); + finishChild(actor, "moneriumWallet"); + await waitFor(actor, s => s.matches("QuoteReady")); + expect(actor.getSnapshot().context.initializeFailedMessage).toBe("Connect an EVM wallet to receive EUR"); }); it("BRL ramps with a valid KYC go straight to KycComplete", async () => { diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index 36047f0b4..248c176ba 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -12,6 +12,8 @@ import { RampLimitExceededError, validateKycActor } from "./actors/validateKyc.a import { alfredpayKycMachine } from "./alfredpayKyc.machine"; import { aveniaKycMachine } from "./brlaKyc.machine"; import { kycStateNode } from "./kyc.states"; +import { moneriumKycMachine } from "./moneriumKyc.machine"; +import { moneriumWalletMachine } from "./moneriumWallet.machine"; import { mykoboKycMachine } from "./mykoboKyc.machine"; import { acceptRecipientInviteActor, @@ -106,6 +108,8 @@ export const rampMachine = setup({ checkAndRefreshToken: fromPromise(checkAndRefreshTokenActor), checkEmail: fromPromise(checkEmailActor), loadQuote: fromPromise(loadQuoteActor), + moneriumKyc: moneriumKycMachine, + moneriumWallet: moneriumWalletMachine, mykoboKyc: mykoboKycMachine, quoteRefresher: fromCallback(({ sendBack, input }) => { return createQuoteRefresher(input.context, sendBack); diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index 7e33f5a05..e64bd7231 100644 --- a/apps/frontend/src/machines/types.ts +++ b/apps/frontend/src/machines/types.ts @@ -1,4 +1,5 @@ import { WalletAccount } from "@talismn/connect-wallets"; +import type { MoneriumOAuthCallback } from "@vortexfi/kyc"; import { AlfredpayKycContext, AveniaKycContext } from "@vortexfi/kyc"; import { FiatToken, PaymentData, QuoteResponse, RampDirection } from "@vortexfi/shared"; import { ActorRef, ActorRefFrom, Snapshot, SnapshotFrom } from "xstate"; @@ -8,11 +9,15 @@ import { RampExecutionInput, RampSigningPhase, RampState } from "../types/phases import { alfredpayKycMachine } from "./alfredpayKyc.machine"; import { aveniaKycMachine } from "./brlaKyc.machine"; import { MykoboKycContext } from "./kyc.states"; +import { moneriumKycMachine } from "./moneriumKyc.machine"; +import { moneriumWalletMachine } from "./moneriumWallet.machine"; import { mykoboKycMachine } from "./mykoboKyc.machine"; export type { RampState } from "../types/phases"; export type GetMessageSignatureCallback = (message: string) => Promise<`0x${string}`>; export interface RampContext { + /** Monerium OAuth callback (`?code&state` or `?error`) waiting for the restored KYC child. */ + moneriumCallback?: MoneriumOAuthCallback; connectedWalletAddress: string | undefined; // The address of the connected wallet (EVM or Substrate) authToken?: string; chainId: number | undefined; @@ -94,6 +99,8 @@ export type RampMachineEvents = | { type: "LOGOUT" } | { type: "GO_BACK" } | { type: "START_KYB_LINK"; invite?: string; region?: string; locked?: boolean } + | { type: "MONERIUM_CALLBACK"; callback: MoneriumOAuthCallback } + | { type: "MONERIUM_REFRESH" } | { type: "RETRY_INVITE" } | { type: "SELECT_REGION"; fiatToken: FiatToken }; @@ -106,6 +113,22 @@ export type AveniaKycSnapshot = SnapshotFrom; export type AlfredpayKycActorRef = ActorRefFrom; export type AlfredpayKycSnapshot = SnapshotFrom; +export type MoneriumKycActorRef = ActorRefFrom; +export type MoneriumKycSnapshot = SnapshotFrom; + +export type MoneriumWalletActorRef = ActorRefFrom; +export type MoneriumWalletSnapshot = SnapshotFrom; + +export type SelectedMoneriumData = { + stateValue: MoneriumKycSnapshot["value"]; + context: MoneriumKycSnapshot["context"]; +}; + +export type SelectedMoneriumWalletData = { + stateValue: MoneriumWalletSnapshot["value"]; + context: MoneriumWalletSnapshot["context"]; +}; + export type MykoboKycActorRef = ActorRefFrom; export type MykoboKycSnapshot = SnapshotFrom; diff --git a/apps/frontend/src/pages/widget/index.tsx b/apps/frontend/src/pages/widget/index.tsx index af62ae9f1..60c5ae70f 100644 --- a/apps/frontend/src/pages/widget/index.tsx +++ b/apps/frontend/src/pages/widget/index.tsx @@ -9,6 +9,8 @@ import { AveniaKYBFlow } from "../../components/Avenia/AveniaKYBFlow"; import { AveniaKYBForm } from "../../components/Avenia/AveniaKYBForm"; import { AveniaKYCForm } from "../../components/Avenia/AveniaKYCForm"; import { DoneScreen } from "../../components/DoneScreen"; +import { MoneriumKycFlow } from "../../components/Monerium/MoneriumKycFlow"; +import { MoneriumWalletFlow } from "../../components/Monerium/MoneriumWalletFlow"; import { MykoboKycFlow } from "../../components/Mykobo/MykoboKycFlow"; import { HistoryMenu } from "../../components/menus/HistoryMenu"; import { SettingsMenu } from "../../components/menus/SettingsMenu"; @@ -26,6 +28,8 @@ import { useAlfredpayKycSelector, useAveniaKycActor, useAveniaKycSelector, + useMoneriumKycActor, + useMoneriumWalletActor, useMykoboKycActor, useRampActor } from "../../contexts/rampState"; @@ -73,6 +77,8 @@ const WidgetContent = () => { const aveniaState = useAveniaKycSelector(); const alfredpayKycActor = useAlfredpayKycActor(); const mykoboKycActor = useMykoboKycActor(); + const moneriumKycActor = useMoneriumKycActor(); + const moneriumWalletActor = useMoneriumWalletActor(); const showFiatAccountRegistration = useFiatAccountSelector(s => s.matches("Open")); const fiatRegistrationCountry = useFiatAccountSelector(s => s.context.fiatRegistrationCountry); @@ -167,6 +173,14 @@ const WidgetContent = () => { return ; } + if (moneriumWalletActor) { + return ; + } + + if (moneriumKycActor) { + return ; + } + if (mykoboKycActor) { return ; } diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 52c7f0a81..440cdf9cb 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -587,6 +587,46 @@ }, "button": "Maintenance Mode" }, + "moneriumKycFlow": { + "checkingStatus": "Connecting to Monerium...", + "failure": "Could not continue with Monerium.", + "inReview": { + "description": "Monerium is reviewing your information. You can come back later.", + "later": "Continue later", + "refresh": "Refresh status", + "title": "Verification in review" + }, + "ready": { + "cancel": "Cancel", + "continue": "Continue to Monerium", + "description": "Monerium securely collects the information required for your EUR verification. You will return here when finished.", + "title": "Verify with Monerium" + }, + "redirecting": { + "cancel": "Cancel", + "description": "Complete the verification in the Monerium tab, then come back here.", + "refresh": "I have finished", + "title": "Finish with Monerium" + }, + "rejected": "Your verification was not approved.", + "retry": "Try again", + "startOver": "Start over" + }, + "moneriumWalletFlow": { + "cancel": "Cancel", + "checking": "Checking your Monerium wallet...", + "failure": "Could not link your wallet to Monerium.", + "linking": "Confirm the signature in your wallet to link it to Monerium. This costs no gas.", + "moving": "Moving your IBAN to this wallet...", + "needsMove": { + "cancel": "Cancel", + "confirm": "Move IBAN", + "description": "Your Monerium IBAN currently points to another wallet or chain. EUR pay-ins are minted to the wallet the IBAN points to, so it must be moved to {{address}} to continue.", + "title": "Move your IBAN to this wallet" + }, + "retry": "Try again", + "waiting": "Monerium is provisioning your IBAN. This usually takes a moment..." + }, "mxnDocumentUpload": { "backLabel": "Back of Document", "fileHint": "Accepted formats: JPG, PNG, PDF — max 5 MB each", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 94628c322..8d68cb4ee 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -590,6 +590,46 @@ }, "button": "Modo de Manutenção" }, + "moneriumKycFlow": { + "checkingStatus": "Conectando à Monerium...", + "failure": "Não foi possível continuar com a Monerium.", + "inReview": { + "description": "A Monerium está analisando suas informações. Você pode voltar mais tarde.", + "later": "Continuar depois", + "refresh": "Atualizar status", + "title": "Verificação em análise" + }, + "ready": { + "cancel": "Cancelar", + "continue": "Continuar para a Monerium", + "description": "A Monerium coleta com segurança as informações necessárias para sua verificação em EUR. Você voltará para cá ao terminar.", + "title": "Verificar com a Monerium" + }, + "redirecting": { + "cancel": "Cancelar", + "description": "Conclua a verificação na aba da Monerium e depois volte para cá.", + "refresh": "Já terminei", + "title": "Concluir com a Monerium" + }, + "rejected": "Sua verificação não foi aprovada.", + "retry": "Tentar novamente", + "startOver": "Recomeçar" + }, + "moneriumWalletFlow": { + "cancel": "Cancelar", + "checking": "Verificando sua carteira Monerium...", + "failure": "Não foi possível vincular sua carteira à Monerium.", + "linking": "Confirme a assinatura na sua carteira para vinculá-la à Monerium. Isso não custa gás.", + "moving": "Movendo seu IBAN para esta carteira...", + "needsMove": { + "cancel": "Cancelar", + "confirm": "Mover IBAN", + "description": "Seu IBAN Monerium aponta para outra carteira ou rede no momento. Os depósitos em EUR são emitidos na carteira para a qual o IBAN aponta, então ele precisa ser movido para {{address}} para continuar.", + "title": "Mover seu IBAN para esta carteira" + }, + "retry": "Tentar novamente", + "waiting": "A Monerium está provisionando seu IBAN. Isso normalmente leva um momento..." + }, "mxnDocumentUpload": { "backLabel": "Verso do Documento", "fileHint": "Formatos aceitos: JPG, PNG, PDF — máx. 5 MB cada", diff --git a/apps/frontend/src/types/searchParams.ts b/apps/frontend/src/types/searchParams.ts index c3e6fae5c..9ca8ccb3d 100644 --- a/apps/frontend/src/types/searchParams.ts +++ b/apps/frontend/src/types/searchParams.ts @@ -16,6 +16,9 @@ export const rampSearchSchema = z.object({ code: z.string().optional(), countryCode: z.string().optional(), cryptoLocked: z.string().optional(), + // Monerium OAuth callback (`/widget?code&state`, or `?error&error_description` when the user cancels). + error: z.string().optional(), + error_description: z.string().optional(), externalSessionId: z.string().optional(), fiat: z.string().optional(), inputAmount: stringOrNumberParam, @@ -30,6 +33,7 @@ export const rampSearchSchema = z.object({ paymentMethod: z.string().optional(), quoteId: z.string().optional(), rampType: z.string().optional(), + state: z.string().optional(), walletAddressLocked: z.string().optional() }); diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 0e16c3621..1a2f255d1 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -330,16 +330,12 @@ provider-shaped rather than UI-shaped. dashboard signs in a second time. Fine for this iteration. - **Order is fixed:** authenticate → accept → KYC. The recipient needs a `customer_entity` before any provider record can attach to it. - - **EU recipient onboarding is unavailable.** The widget's legacy EURC KYC child does not create - the approved Monerium binding, Polygon EOA, and IBAN required by the active backend onramp. - Migration from the sibling OAuth application into the white-label application is still TBD. EU - is therefore excluded from the widget's KYB - region list: an EU link's - `?kybLocked=EU` is not recognized, and the corridor locks only from the acceptance response. - The dashboard intentionally does not prevent creating EU invites — once any corridor is - approved, all live corridors are selectable in the recipient dialog — so an EU invite can be - issued but cannot produce a payable recipient until recipient EU onboarding, corridor binding, - and Monerium import are wired. Known gap, tracked with the EUR corridor reconciliation. + - **EU onboarding runs through Monerium OAuth.** After login the widget starts the Monerium + authorization (top-level in a standalone widget, a new tab when embedded) and returns to + `/widget` with the callback, which the persisted ramp hands to the restored verification step. + Once approved, the connected EVM wallet signs Monerium's ownership message, Vortex links it and + provisions or moves the IBAN, and the EUR pay-in continues with that wallet's permit. The legacy + Mykobo form stays in the codebase but no longer routes new EUR flows. - **The recipient's payout instrument** is created provider-side and stored as a masked pointer, never as raw bank PII. Where it is captured follows from the above — the widget. `#review` diff --git a/docs/proposal-monerium-dual-app.md b/docs/proposal-monerium-dual-app.md index 15a3a4f6a..1886d7013 100644 --- a/docs/proposal-monerium-dual-app.md +++ b/docs/proposal-monerium-dual-app.md @@ -188,17 +188,19 @@ Managed children, quote simulation, execution, and the B2B onramp are unchanged. ## Phase 4: widget -- SEPA/EUR onboarding routes to a Monerium flow built on the shared `@vortexfi/kyc` - Monerium machine behind the existing Supabase OTP login. The Mykobo form stays dormant. -- Authorization opens as top-level navigation when the widget is the top document and in a - new tab when embedded; a `/monerium/callback` route completes the exchange. The - persisted ramp snapshot in `localStorage` already survives the redirect; it no longer - carries any token. -- The connected wallet is linked after OAuth completion through `POST /v1/monerium/wallet` - (the widget is wallet-first, so the address and `signMessage` are available). Permit - signing reuses `userSigning.ts`. The registered `http://localhost:5473/widget` callback - matches the legacy widget pattern of using its own route as the redirect target. -- `kybRegions.ts` and the phase messages are updated accordingly. +- SEPA/EUR verification routes to a Monerium step built on the shared `@vortexfi/kyc` machine + behind the existing Supabase OTP login. The Mykobo child stays in the codebase for persisted + legacy flows only. +- Authorization opens as top-level navigation when the widget is the top document and in a new + tab when embedded (with an "I have finished" re-check). Monerium returns to the registered + `/widget` callback; `useSetRampUrlParams` hands `code`/`state` (or `error`) to the persisted + ramp, which restarts the restored Monerium step with the callback, then drops the params. +- A second widget machine links the connected EVM wallet after approval through + `POST /v1/monerium/wallet` using the existing message-signature callback, polls readiness until + the IBAN is provisioned, and asks before moving an IBAN that sits elsewhere. Substrate wallets + are refused because the onramp needs the EOA permit. +- EUR BUY registration sends the connected wallet as `walletAddress`; the existing user-signing + actor already signs the owner permit and the summary step already renders `ibanPaymentData`. ## Phase 5: SDK and direct API From aeb393476d02cf4a8b98b69288cc49fce1618610 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 09:23:29 +0200 Subject: [PATCH 10/42] feat(sdk): register the Monerium EUR onramp and return its owner permit EurHandler (formerly MykoboHandler) registers EUR/SEPA BUY with the destination and the user's Monerium-linked wallet, signs only the ephemeral-owned transactions, and VortexSdk.registerRamp returns the wallet's ERC-2612 permit in unsignedTransactions for submitUserTransactions. MONERIUM_ONBOARDING_REQUIRED and MONERIUM_REAUTHENTICATION_REQUIRED map to dedicated errors. The SDK never supported the Monerium onramp before, so replacing the Mykobo onramp's email/ipAddress fields with walletAddress breaks no live integration; the legacy Mykobo SELL adapter stays for persisted flows. --- docs/api/wire-contract.snapshot.md | 66 +++++++-- packages/sdk/ARCHITECTURE.md | 13 +- packages/sdk/README.md | 13 +- packages/sdk/src/VortexSdk.ts | 23 ++-- packages/sdk/src/errors.ts | 42 ++++++ .../{MykoboHandler.ts => EurHandler.ts} | 25 ++-- packages/sdk/src/types.ts | 13 +- packages/sdk/test/errors.test.ts | 22 +++ packages/sdk/test/eurHandler.test.ts | 125 ++++++++++++++++++ packages/sdk/test/vortexSdk.eurOnramp.test.ts | 60 +++++++++ 10 files changed, 357 insertions(+), 45 deletions(-) rename packages/sdk/src/handlers/{MykoboHandler.ts => EurHandler.ts} (79%) create mode 100644 packages/sdk/test/eurHandler.test.ts create mode 100644 packages/sdk/test/vortexSdk.eurOnramp.test.ts diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index c9e64abf7..91b7b312c 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -3000,8 +3000,9 @@ AnyAdditionalData: { walletAddress: string; } | { destinationAddress: string; - email: string; - ipAddress: string; + email?: string; + ipAddress?: string; + walletAddress: string; } | { destinationAddress: string; fiatAccountId?: string; @@ -3646,8 +3647,18 @@ EurOfframpUpdateAdditionalData: { EurOnrampAdditionalData: { destinationAddress: string; - email: string; - ipAddress: string; + email?: string; + ipAddress?: string; + walletAddress: string; +} + +EurOnrampError: class EurOnrampError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; } EurOnrampQuote: { @@ -4290,6 +4301,15 @@ MissingDomesticOnrampParametersError: class MissingDomesticOnrampParametersError readonly status: number; } +MissingEurOnrampParametersError: class MissingEurOnrampParametersError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + MissingMykoboOfframpParametersError: class MissingMykoboOfframpParametersError { constructor(); readonly code?: string; @@ -4317,6 +4337,24 @@ MissingRequiredFieldsError: class MissingRequiredFieldsError { readonly status: number; } +MoneriumOnboardingRequiredError: class MoneriumOnboardingRequiredError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MoneriumReauthenticationRequiredError: class MoneriumReauthenticationRequiredError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + MoonbeamEphemeralNotFoundError: class MoonbeamEphemeralNotFoundError { constructor(); readonly code?: string; @@ -4715,8 +4753,9 @@ RegisterRampAdditionalData: (quote: Q, additionalData: RegisterRampAdditionalData): Promise<{ rampProcess: RampProcess; unsignedTransactions: UnsignedTx[] }>` Registers a new ramp process. Creates fresh Substrate and EVM ephemeral accounts, submits the quote and ephemeral addresses to the API, then signs and submits the returned ephemeral-owned transactions. Returns the ramp process and the user-owned `unsignedTransactions` that the caller must sign or broadcast. -The active EUR/SEPA BUY backend flow is not supported by this SDK release. It requires a -profile-linked owner permit, but the current EUR handler does not return that permit to the caller. -Use the direct API contract until a provider-aware EUR SDK handler is released. EUR SELL is -unavailable for new quotes. +For EUR/SEPA BUY, pass `walletAddress`: the wallet linked to the user's Monerium profile (see the +Fiat Corridors guide). The backend mints EURe to that wallet and returns its ERC-2612 permit as a +user-owned typed-data transaction in `unsignedTransactions`; sign and submit it with +`submitUserTransactions` (or `getTypedDataToSign` + `submitUserSignature`) before the SEPA +instructions (`ibanPaymentData`) are released. The user must already be onboarded with Monerium +and have that wallet linked; otherwise registration fails with `MoneriumOnboardingRequiredError` +or `MoneriumReauthenticationRequiredError`. EUR SELL is unavailable for new quotes. ##### `updateRamp(quote: Q, rampId: string, additionalUpdateData: UpdateRampAdditionalData): Promise` -Submits route-specific transaction hashes after off-chain steps complete. Used for supported sell flows. Supported SDK buy flows do not require a separate update call; direct-API EUR BUY does. +Submits route-specific transaction hashes after off-chain steps complete. Used for supported sell flows. Buy flows do not use it; the EUR BUY owner permit goes through `submitUserTransactions` / `submitUserSignature`. ##### `startRamp(rampId: string): Promise` Starts a registered ramp process. diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index b77254c45..6b882fa7e 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -27,7 +27,7 @@ import { attachSignatures, typedDataToSign, type UserTransactionType, userTransa import { TransactionSigningError } from "./errors.js"; import { BrlHandler } from "./handlers/BrlHandler.js"; import { DomesticHandler } from "./handlers/DomesticHandler.js"; -import { MykoboHandler } from "./handlers/MykoboHandler.js"; +import { EurHandler } from "./handlers/EurHandler.js"; import { assertSufficientOfframpBalance } from "./preflight.js"; import { ApiService } from "./services/ApiService.js"; import { NetworkManager } from "./services/NetworkManager.js"; @@ -58,7 +58,7 @@ export class VortexSdk { private networkManager: NetworkManager; private brlHandler: BrlHandler; private domesticHandler: DomesticHandler; - private mykoboHandler: MykoboHandler; + private eurHandler: EurHandler; private storeEphemeralKeys: boolean; private storeEphemeralKeysCallback: VortexSdkConfig["storeEphemeralKeysCallback"]; private offrampFundingMode: NonNullable; @@ -91,7 +91,7 @@ export class VortexSdk { this.signTransactions.bind(this) ); - this.mykoboHandler = new MykoboHandler( + this.eurHandler = new EurHandler( this.apiService, this, this.generateEphemerals.bind(this), @@ -128,7 +128,8 @@ export class VortexSdk { return []; } - return rampProcess.unsignedTxs.filter(tx => tx.signer === userAddress); + const wanted = userAddress.toLowerCase(); + return rampProcess.unsignedTxs.filter(tx => tx.signer.toLowerCase() === wanted); } async registerRamp( @@ -156,8 +157,10 @@ export class VortexSdk { rampProcess = await this.brlHandler.registerBrlOnramp(quote.id, additionalData as BrlOnrampAdditionalData); unsignedTransactions = []; } else if (quote.from === "sepa") { - rampProcess = await this.mykoboHandler.registerMykoboOnramp(quote.id, additionalData as EurOnrampAdditionalData); - unsignedTransactions = []; + const eurData = additionalData as EurOnrampAdditionalData; + rampProcess = await this.eurHandler.registerEurOnramp(quote.id, eurData); + // The Monerium owner permit is signed by the linked wallet, not by an ephemeral. + unsignedTransactions = await this.getUserTransactions(rampProcess, eurData.walletAddress); } else { throw new Error(`Unsupported onramp from: ${quote.from}`); } @@ -178,7 +181,7 @@ export class VortexSdk { const userAddress = (additionalData as BrlOfframpAdditionalData).walletAddress; unsignedTransactions = await this.getUserTransactions(rampProcess, userAddress); } else if (quote.to === "sepa") { - rampProcess = await this.mykoboHandler.registerMykoboOfframp(quote.id, additionalData as EurOfframpAdditionalData); + rampProcess = await this.eurHandler.registerEurOfframp(quote.id, additionalData as EurOfframpAdditionalData); const userAddress = (additionalData as EurOfframpAdditionalData).walletAddress; unsignedTransactions = await this.getUserTransactions(rampProcess, userAddress); } else { @@ -202,7 +205,9 @@ export class VortexSdk { } else if (quote.from === "pix") { throw new Error("Brl onramp does not require any further data"); } else if (quote.from === "sepa") { - throw new Error("Euro onramp does not require any further data"); + throw new Error( + "The EUR onramp's owner permit is submitted through submitUserTransactions or submitUserSignature, not updateRamp" + ); } } else if (quote.rampType === RampDirection.SELL) { if (isDomesticToken(quote.outputCurrency)) { @@ -210,7 +215,7 @@ export class VortexSdk { } else if (quote.to === "pix") { return this.brlHandler.updateBrlOfframp(rampId, additionalUpdateData as BrlOfframpUpdateAdditionalData); } else if (quote.to === "sepa") { - return this.mykoboHandler.updateMykoboOfframp(rampId, additionalUpdateData as EurOfframpUpdateAdditionalData); + return this.eurHandler.updateEurOfframp(rampId, additionalUpdateData as EurOfframpUpdateAdditionalData); } } diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index befbbc99d..3cd62dfde 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -239,6 +239,7 @@ export class MykoboError extends RegisterRampError { } } +/** @deprecated The Monerium onramp replaced the Mykobo onramp; see {@link MissingEurOnrampParametersError}. */ export class MissingMykoboOnrampParametersError extends MykoboError { constructor() { super("Parameters destinationAddress, email and ipAddress are required for Mykobo EUR onramp", 400); @@ -246,6 +247,37 @@ export class MissingMykoboOnrampParametersError extends MykoboError { } } +// EUR (Monerium) onramp specific errors +export class EurOnrampError extends RegisterRampError { + constructor(message: string, status = 400) { + super(message, status); + this.name = "EurOnrampError"; + } +} + +export class MissingEurOnrampParametersError extends EurOnrampError { + constructor() { + super("Parameters destinationAddress and walletAddress are required for the EUR onramp", 400); + this.name = "MissingEurOnrampParametersError"; + } +} + +/** The authenticated user has no Monerium profile bound yet; complete Monerium onboarding first. */ +export class MoneriumOnboardingRequiredError extends EurOnrampError { + constructor(message: string, status = 403) { + super(message, status); + this.name = "MoneriumOnboardingRequiredError"; + } +} + +/** The backend's Monerium session for this user is gone; the user must reconnect Monerium. */ +export class MoneriumReauthenticationRequiredError extends EurOnrampError { + constructor(message: string, status = 404) { + super(message, status); + this.name = "MoneriumReauthenticationRequiredError"; + } +} + export class MissingMykoboOfframpParametersError extends MykoboError { constructor() { super("Parameters walletAddress, email, ipAddress and destinationAddress are required for Mykobo EUR offramp", 400); @@ -452,6 +484,16 @@ export function parseAPIError(response: unknown, fallbackStatus?: number): Vorte } } + if (errorCode === "MONERIUM_ONBOARDING_REQUIRED") { + return new MoneriumOnboardingRequiredError(errorMessage ?? "Monerium onboarding is required", normalizedStatus); + } + if (errorCode === "MONERIUM_REAUTHENTICATION_REQUIRED") { + return new MoneriumReauthenticationRequiredError( + errorMessage ?? "Monerium reauthentication is required", + normalizedStatus + ); + } + if (errorMessage) { if (errorMessage?.includes("Missing required fields")) { return new MissingRequiredFieldsError([]); diff --git a/packages/sdk/src/handlers/MykoboHandler.ts b/packages/sdk/src/handlers/EurHandler.ts similarity index 79% rename from packages/sdk/src/handlers/MykoboHandler.ts rename to packages/sdk/src/handlers/EurHandler.ts index aa365d4d2..675a71c21 100644 --- a/packages/sdk/src/handlers/MykoboHandler.ts +++ b/packages/sdk/src/handlers/EurHandler.ts @@ -8,7 +8,7 @@ import { UnsignedTx, UpdateRampRequest } from "@vortexfi/shared"; -import { MissingMykoboOfframpParametersError, MissingMykoboOnrampParametersError } from "../errors.js"; +import { MissingEurOnrampParametersError, MissingMykoboOfframpParametersError } from "../errors.js"; import type { ApiService } from "../services/ApiService.js"; import type { EurOfframpAdditionalData, @@ -18,7 +18,13 @@ import type { VortexSdkContext } from "../types.js"; -export class MykoboHandler implements RampHandler { +/** + * EUR/SEPA corridor adapter. BUY runs the Monerium onramp: the backend mints EURe to the wallet + * linked to the user's Monerium profile and returns that wallet's ERC-2612 permit as a user-owned + * transaction, which the integrator signs through `submitUserTransactions`. SELL keeps the legacy + * Mykobo adapter for persisted flows; new EUR SELL quotes are rejected by the backend. + */ +export class EurHandler implements RampHandler { private apiService: ApiService; private context: VortexSdkContext; private generateEphemerals: () => Promise<{ @@ -67,18 +73,19 @@ export class MykoboHandler implements RampHandler { return unsignedTxs.filter(tx => ephemeralSigners.has(tx.signer.toLowerCase())); } - async registerMykoboOnramp(quoteId: string, additionalData: EurOnrampAdditionalData): Promise { - if (!additionalData.destinationAddress || !additionalData.email || !additionalData.ipAddress) { - throw new MissingMykoboOnrampParametersError(); + async registerEurOnramp(quoteId: string, additionalData: EurOnrampAdditionalData): Promise { + if (!additionalData.destinationAddress || !additionalData.walletAddress) { + throw new MissingEurOnrampParametersError(); } const { ephemerals, accountMetas } = await this.generateEphemerals(); + // Identity (profile, linked address, IBAN) is derived server-side; walletAddress names the + // Monerium-linked owner whose permit comes back as a user-owned transaction. const registerRequest: RegisterRampRequest = { additionalData: { destinationAddress: additionalData.destinationAddress, - email: additionalData.email, - ipAddress: additionalData.ipAddress + walletAddress: additionalData.walletAddress }, quoteId, signingAccounts: accountMetas @@ -103,7 +110,7 @@ export class MykoboHandler implements RampHandler { return this.apiService.updateRamp(updateRequest); } - async registerMykoboOfframp(quoteId: string, additionalData: EurOfframpAdditionalData): Promise { + async registerEurOfframp(quoteId: string, additionalData: EurOfframpAdditionalData): Promise { if ( !additionalData.walletAddress || !additionalData.email || @@ -145,7 +152,7 @@ export class MykoboHandler implements RampHandler { return this.apiService.updateRamp(updateRequest); } - async updateMykoboOfframp(rampId: string, additionalData: EurOfframpUpdateAdditionalData): Promise { + async updateEurOfframp(rampId: string, additionalData: EurOfframpUpdateAdditionalData): Promise { const rampProcess = await this.apiService.getRampStatus(rampId); if (rampProcess.currentPhase !== "initial") { throw new Error( diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 418c9d488..94cd19b64 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -150,8 +150,15 @@ export interface BrlOnrampAdditionalData { export interface EurOnrampAdditionalData { destinationAddress: string; - email: string; - ipAddress: string; + /** + * The wallet linked to the user's Monerium profile. The backend mints EURe there and returns + * its ERC-2612 permit as a user-owned transaction that this wallet must sign. + */ + walletAddress: string; + /** @deprecated Not used by the Monerium onramp; identity is derived from the authenticated user. */ + email?: string; + /** @deprecated Not used by the Monerium onramp. */ + ipAddress?: string; } export interface DomesticOnrampAdditionalData { @@ -197,7 +204,7 @@ export type UpdateRampAdditionalData = Q extends Domest : Q extends BrlOnrampQuote ? never // No additional data required from the user for this type of ramp. : Q extends EurOnrampQuote - ? never // No additional data required from the user for EUR onramp. + ? never // The owner permit goes through submitUserTransactions / submitUserSignature, not updateRamp. : Q extends DomesticOfframpQuote ? DomesticOfframpUpdateAdditionalData : Q extends BrlOfframpQuote diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts index f4b5d4402..12b4b6c9c 100644 --- a/packages/sdk/test/errors.test.ts +++ b/packages/sdk/test/errors.test.ts @@ -1,6 +1,8 @@ import {describe, expect, test} from "bun:test"; import { DomesticOnrampKycRequiredError, + MoneriumOnboardingRequiredError, + MoneriumReauthenticationRequiredError, BrlKycStatusError, MissingDomesticOfframpParametersError, MissingBrlOfframpParametersError, @@ -35,6 +37,26 @@ describe("parseAPIError", () => { expect(error.status).toBe(403); }); + test("maps the Monerium onboarding and reauthentication types to dedicated errors", () => { + const onboarding = parseAPIError({ + code: 403, + message: "Monerium onboarding is required before an EUR ramp can be registered", + statusCode: 403, + type: "MONERIUM_ONBOARDING_REQUIRED" + }); + expect(onboarding).toBeInstanceOf(MoneriumOnboardingRequiredError); + expect(onboarding.status).toBe(403); + + const reauth = parseAPIError({ + code: 404, + message: "Monerium reauthentication is required", + statusCode: 404, + type: "MONERIUM_REAUTHENTICATION_REQUIRED" + }); + expect(reauth).toBeInstanceOf(MoneriumReauthenticationRequiredError); + expect(reauth.status).toBe(404); + }); + test("preserves provider limit error types as stable codes", () => { const error = parseAPIError({ code: 400, diff --git a/packages/sdk/test/eurHandler.test.ts b/packages/sdk/test/eurHandler.test.ts new file mode 100644 index 000000000..5a8983911 --- /dev/null +++ b/packages/sdk/test/eurHandler.test.ts @@ -0,0 +1,125 @@ +// EUR (Monerium) onramp coverage: the handler registers with the linked wallet, signs only the +// ephemeral-owned transactions, and leaves the owner permit for the integrator's wallet. +// Run: cd packages/sdk && bun test + +import {describe, expect, test} from "bun:test"; +import { + EPaymentMethod, + EphemeralAccountType, + FiatToken, + Networks, + PresignedTx, + RampDirection, + RampProcess, + RegisterRampRequest, + UnsignedTx, + UpdateRampRequest +} from "@vortexfi/shared"; +import {MissingEurOnrampParametersError} from "../src/errors"; +import {EurHandler} from "../src/handlers/EurHandler"; +import {ApiService} from "../src/services/ApiService"; +import type {VortexSdkContext} from "../src/types"; + +const OWNER = "0xAbCd000000000000000000000000000000000001"; +const DESTINATION = "0x0000000000000000000000000000000000000002"; + +type Call = { method: string; payload: unknown }; + +const permitTx: UnsignedTx = { + meta: {}, + network: Networks.Polygon, + nonce: 0, + phase: "moneriumOnrampSelfTransfer", + signer: OWNER.toLowerCase(), + txData: { + domain: { chainId: 137, name: "Monerium EURe", verifyingContract: "0x18ec0A6E18E5bc3784fDd3a3634b31245ab704F6", version: "1" }, + message: { deadline: "1800000000", nonce: "0", owner: OWNER, spender: "0xEVM", value: "1" }, + primaryType: "Permit", + types: { Permit: [] } + } +}; + +const ephemeralTx: UnsignedTx = { + meta: {}, + network: Networks.Polygon, + nonce: 1, + phase: "moneriumOnrampSelfTransfer", + signer: "0xEVM", + txData: { data: "0x", gas: "21000", to: "0x0000000000000000000000000000000000000003", value: "0" } +}; + +const rampProcess = (unsignedTxs: UnsignedTx[]): RampProcess => ({ + createdAt: "2026-01-01T00:00:00.000Z", + currentPhase: "initial", + from: EPaymentMethod.SEPA, + id: "ramp_eur", + inputAmount: "100", + inputCurrency: FiatToken.EURC, + outputAmount: "107", + outputCurrency: "USDC", + paymentMethod: EPaymentMethod.SEPA, + quoteId: "quote_eur", + to: Networks.Arbitrum, + type: RampDirection.BUY, + unsignedTxs, + updatedAt: "2026-01-01T00:00:00.000Z" +}); + +function setup() { + const calls: Call[] = []; + const apiService = new ApiService("http://localhost:3000"); + apiService.registerRamp = async (req: RegisterRampRequest) => { + calls.push({ method: "registerRamp", payload: req }); + return rampProcess([permitTx, ephemeralTx]); + }; + apiService.updateRamp = async (req: UpdateRampRequest) => { + calls.push({ method: "updateRamp", payload: req }); + return { ...rampProcess([permitTx, ephemeralTx]), ibanPaymentData: undefined }; + }; + const context: VortexSdkContext = { + storeEphemerals: async (...args) => { + calls.push({ method: "storeEphemerals", payload: args }); + } + }; + const generateEphemerals = async () => ({ + accountMetas: [ + { address: "5SUBSTRATE", type: EphemeralAccountType.Substrate }, + { address: "0xEVM", type: EphemeralAccountType.EVM } + ], + ephemerals: { + EVM: { address: "0xEVM", secret: "s" }, + Substrate: { address: "5SUBSTRATE", secret: "s" } + } + }); + const signTransactions = async (txs: UnsignedTx[]): Promise => { + calls.push({ method: "signTransactions", payload: txs }); + return txs.map((t, i) => ({ ...t, txData: `presigned_${i}` })); + }; + return { calls, handler: new EurHandler(apiService, context, generateEphemerals, signTransactions) }; +} + +describe("EurHandler onramp", () => { + test("registers with the linked wallet, signs only ephemeral transactions, then updates", async () => { + const { calls, handler } = setup(); + + const result = await handler.registerEurOnramp("quote_eur", { destinationAddress: DESTINATION, walletAddress: OWNER }); + + expect(result.id).toBe("ramp_eur"); + expect(calls.map(c => c.method)).toEqual(["registerRamp", "storeEphemerals", "signTransactions", "updateRamp"]); + const reg = calls[0].payload as RegisterRampRequest; + expect(reg.additionalData).toEqual({ destinationAddress: DESTINATION, walletAddress: OWNER }); + const signed = calls[2].payload as UnsignedTx[]; + expect(signed.map(tx => tx.signer)).toEqual(["0xEVM"]); + const upd = calls[3].payload as UpdateRampRequest; + expect(upd.presignedTxs).toHaveLength(1); + expect(upd.additionalData).toEqual({}); + }); + + test("rejects registration without the linked wallet before calling the API", async () => { + const { calls, handler } = setup(); + await expect(handler.registerEurOnramp("quote_eur", { destinationAddress: DESTINATION, walletAddress: "" })).rejects.toBeInstanceOf( + MissingEurOnrampParametersError + ); + expect(calls).toHaveLength(0); + }); +}); diff --git a/packages/sdk/test/vortexSdk.eurOnramp.test.ts b/packages/sdk/test/vortexSdk.eurOnramp.test.ts new file mode 100644 index 000000000..60071305c --- /dev/null +++ b/packages/sdk/test/vortexSdk.eurOnramp.test.ts @@ -0,0 +1,60 @@ +// VortexSdk routing for EUR/SEPA BUY: the owner permit comes back as a user-owned transaction. +// Run: cd packages/sdk && bun test + +import {describe, expect, test} from "bun:test"; +import {EPaymentMethod, FiatToken, Networks, RampDirection, type RampProcess, type UnsignedTx} from "@vortexfi/shared"; +import type {EurOnrampQuote} from "../src/types"; +import {VortexSdk} from "../src/VortexSdk"; + +const OWNER = "0xAbCd000000000000000000000000000000000001"; + +const permitTx = { + meta: {}, + network: Networks.Polygon, + nonce: 0, + phase: "moneriumOnrampSelfTransfer", + signer: OWNER.toLowerCase(), + txData: { + domain: { chainId: 137, name: "Monerium EURe", verifyingContract: "0x18ec0A6E18E5bc3784fDd3a3634b31245ab704F6", version: "1" }, + message: { deadline: "1800000000", nonce: "0", owner: OWNER, spender: "0xEVM", value: "1" }, + primaryType: "Permit", + types: { Permit: [] } + } +} as UnsignedTx; +const ephemeralTx = { ...permitTx, nonce: 1, signer: "0xEVM", txData: { data: "0x", gas: "1", to: "0x0", value: "0" } } as UnsignedTx; + +const quote = { + from: EPaymentMethod.SEPA, + id: "quote_eur", + inputCurrency: FiatToken.EURC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum +} as EurOnrampQuote; + +describe("VortexSdk.registerRamp for EUR/SEPA BUY", () => { + test("returns the Monerium owner permit as the user-owned transaction", async () => { + const sdk = new VortexSdk({ apiBaseUrl: "http://127.0.0.1:1", secretKey: "sk_test_eur", storeEphemeralKeys: false }); + const registered: unknown[] = []; + (sdk as unknown as { eurHandler: { registerEurOnramp: unknown } }).eurHandler = { + registerEurOnramp: async (quoteId: string, data: unknown): Promise => { + registered.push([quoteId, data]); + return { id: "ramp_eur", unsignedTxs: [permitTx, ephemeralTx] } as RampProcess; + } + }; + + const { rampProcess, unsignedTransactions } = await sdk.registerRamp(quote, { + destinationAddress: "0x0000000000000000000000000000000000000002", + walletAddress: OWNER + }); + + expect(rampProcess.id).toBe("ramp_eur"); + expect(registered).toEqual([["quote_eur", { destinationAddress: "0x0000000000000000000000000000000000000002", walletAddress: OWNER }]]); + expect(unsignedTransactions).toEqual([permitTx]); + expect(sdk.getUserTransactionType(unsignedTransactions[0])).toBe("evm-typed-data"); + }); + + test("updateRamp points EUR BUY integrators at the permit submission helpers", async () => { + const sdk = new VortexSdk({ apiBaseUrl: "http://127.0.0.1:1", secretKey: "sk_test_eur", storeEphemeralKeys: false }); + await expect(sdk.updateRamp(quote, "ramp_eur", undefined as never)).rejects.toThrow("submitUserTransactions"); + }); +}); From e1ec97ac57a5d44529cce50fa4e9486ebd467454 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 09:23:29 +0200 Subject: [PATCH 11/42] docs(api): document EUR onramps through the SDK, dashboard, and widget The API pages and the integration skill described EUR BUY as direct-API only; it now runs through the SDK once the user has onboarded with Monerium and linked the paying wallet in the dashboard or widget. --- .agents/skills/vortex-integration/SKILL.md | 2 +- docs/api/pages/01-overview.md | 2 +- docs/api/pages/02-quick-start-with-the-sdk.md | 2 +- docs/api/pages/04-ramp-lifecycle.md | 6 +++--- docs/api/pages/09-fiat-corridors.md | 4 ++-- docs/proposal-monerium-dual-app.md | 14 +++++++++----- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index 747a6cd08..df68da004 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -24,7 +24,7 @@ A machine-loadable capability catalog for AI coding agents integrating Vortex in - **Decimals**: all amounts are strings. Never parse them through JS `Number` — use `BigInt`, `decimal.js`, or equivalent. - **Quote TTL**: quotes expire (see `expiresAt`). Re-quote, never reuse stale quotes. - **Presigned counts**: this is **per ephemeral-signed transaction, not per ramp**. Each transaction an ephemeral key signs must be submitted as 5 presigned variants — 1 primary plus exactly 4 backups with consecutive nonces in `meta.additionalTxs` (`NUMBER_OF_PRESIGNED_TXS = 5`); the API rejects any other backup count. A ramp can contain several ephemeral-signed transactions across its phases. (The SDK builds these for you; only raw-API integrations need to construct them.) -- **Currently implemented SDK corridors**: BRL via PIX, USD via ACH, MXN via SPEI, COP via ACH, and ARS via CBU; these support BUY and SELL. The backend additionally supports direct-API EUR/SEPA BUY for pre-provisioned approved users. The SDK does not support that owner-permit journey, and EUR SELL is unavailable. These corridors deliver to EVM networks only (no AssetHub). +- **Currently implemented SDK corridors**: BRL via PIX, USD via ACH, MXN via SPEI, COP via ACH, and ARS via CBU support BUY and SELL; EUR via SEPA (Monerium) supports BUY only. EUR BUY needs `walletAddress` (the user's Monerium-linked wallet, linked in the Dashboard or Widget) and the returned owner permit signed through `submitUserTransactions`; `MONERIUM_ONBOARDING_REQUIRED` / `MONERIUM_REAUTHENTICATION_REQUIRED` mean the user must (re)connect Monerium first. These corridors deliver to EVM networks only (no AssetHub). - **EUR currency value**: TypeScript uses the member `FiatToken.EURC`, which serializes to the wire value `"EUR"`. Raw JSON clients must send `"EUR"`, with `"sepa"` as the rail identifier. - **taxId is deprecated for BRL**: the user's tax ID is derived server-side from the authenticated profile. Sending a `taxId` that mismatches the derived one is rejected; stop sending it in new integrations. - **Deferred offramp funding**: the SDK checks the source wallet balance at `registerRamp` by default. Server integrations that register before funding a temporary wallet may configure `offrampFundingMode: "deferred"`. This skips only the SDK pre-flight; fund the exact `walletAddress` before signing/submitting user transactions, then update and start before the registration window expires. Backend execution-time balance checks remain authoritative. diff --git a/docs/api/pages/01-overview.md b/docs/api/pages/01-overview.md index 4deef2958..48c29f39e 100644 --- a/docs/api/pages/01-overview.md +++ b/docs/api/pages/01-overview.md @@ -21,7 +21,7 @@ Every Vortex ramp follows the same shape: 5. **Start** — your application calls start once signatures and fiat payment are in place. 6. **Track** — Vortex drives the on-chain phase machine. Your application listens via webhooks or polls the ramp status endpoint. -The SDK wraps steps 2, 3, and parts of 5 for supported flows. Direct API integrations must implement them explicitly. In particular, EUR BUY currently requires the direct API path. +The SDK wraps steps 2, 3, and parts of 5 for supported flows. Direct API integrations must implement them explicitly. EUR BUY additionally needs the Monerium-linked wallet's typed-data permit, which the SDK returns as a user-owned transaction. ## Recommended Integration Paths diff --git a/docs/api/pages/02-quick-start-with-the-sdk.md b/docs/api/pages/02-quick-start-with-the-sdk.md index da824cd8f..9d15884d0 100644 --- a/docs/api/pages/02-quick-start-with-the-sdk.md +++ b/docs/api/pages/02-quick-start-with-the-sdk.md @@ -172,7 +172,7 @@ Quotes can be requested without any key (anonymous rate discovery). Registering The SDK cannot mint credentials or run KYC. Onboard the real user through the Vortex app or Widget, or use Vortex's managed-profile workflow, then use a credential bound to that profile. The secret is shown only once at creation; see [Authentication And API Credentials](https://api-docs.vortexfinance.co/authentication-and-partner-keys). This applies to buys and sells in all four bank-transfer corridors. -EUR/SEPA BUY is currently a direct-API integration rather than an SDK flow because it requires a typed-data permit from the already-linked Monerium owner wallet. See [Fiat Corridors](https://api-docs.vortexfinance.co/fiat-corridors). EUR SELL is unavailable. +EUR/SEPA BUY works through the SDK once the user is onboarded with Monerium and has linked the paying wallet (dashboard or widget): pass that wallet as `walletAddress`, then sign the returned owner permit with `submitUserTransactions` before the SEPA instructions are released. See [Fiat Corridors](https://api-docs.vortexfinance.co/fiat-corridors). EUR SELL is unavailable. ### Offramp (Sell) diff --git a/docs/api/pages/04-ramp-lifecycle.md b/docs/api/pages/04-ramp-lifecycle.md index d34cf7d30..993ee689b 100644 --- a/docs/api/pages/04-ramp-lifecycle.md +++ b/docs/api/pages/04-ramp-lifecycle.md @@ -24,13 +24,13 @@ Only public addresses are sent to Vortex. The matching ephemeral secret keys mus Use `POST /v1/ramp/update` to submit signed transactions and route-specific transaction hashes. -The SDK performs this automatically for the buy flows it supports. Direct API integrations must ensure that each signature or transaction hash matches the transaction returned by Vortex for the same ramp and phase. EUR BUY is direct-API only and requires both the profile-linked owner's typed-data permit and the ephemeral-owned signatures returned at registration. +The SDK performs this automatically for the buy flows it supports. Direct API integrations must ensure that each signature or transaction hash matches the transaction returned by Vortex for the same ramp and phase. EUR BUY requires both the profile-linked owner's typed-data permit and the ephemeral-owned signatures returned at registration; the SDK signs the ephemeral set and returns the permit as a user-owned transaction. -On buys, the fiat payment instructions (`depositQrCode` for BRL, `ibanPaymentData` for EUR) are withheld until the presigned transactions pass validation: they are released on the update response and on `GET /v1/ramp/{id}`, not on the register response. SDK integrations receive supported-corridor instructions directly from `registerRamp`, which performs the update internally. For EUR, the direct API client must submit the owner permit and ephemeral signatures itself before `ibanPaymentData` is released. +On buys, the fiat payment instructions (`depositQrCode` for BRL, `ibanPaymentData` for EUR) are withheld until the presigned transactions pass validation: they are released on the update response and on `GET /v1/ramp/{id}`, not on the register response. SDK integrations receive supported-corridor instructions directly from `registerRamp`, which performs the update internally. For EUR, the owner permit must also be submitted (`submitUserTransactions` in the SDK, or the update endpoint directly) before `ibanPaymentData` is released. ## 4. Start The Ramp -Use `POST /v1/ramp/start` after required signatures, transaction hashes, and fiat payment steps are complete. For BRL buys, call start after the user completes the PIX payment. For direct-API EUR buys, submit all signatures, display the released IBAN instructions, and call start after the user initiates the SEPA transfer. For USD, MXN, COP, and ARS buys the order is inverted: call start first — the start response's `achPaymentData` contains the bank transfer instructions the user must pay. +Use `POST /v1/ramp/start` after required signatures, transaction hashes, and fiat payment steps are complete. For BRL buys, call start after the user completes the PIX payment. For EUR buys, submit all signatures, display the released IBAN instructions, and call start after the user initiates the SEPA transfer. For USD, MXN, COP, and ARS buys the order is inverted: call start first — the start response's `achPaymentData` contains the bank transfer instructions the user must pay. If a BRL PIX payment is confirmed by the payment partner but the client cannot call start (for example because the managed profile was deleted or its corridor policy changed after registration), Vortex automatically starts the already-signed persisted ramp. This recovery is tied to the exact provider ticket issued at registration; it does not authorize new ramps or bypass payment verification. diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index c73337467..d3b5260f6 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -183,12 +183,12 @@ Authenticated clients can request account limits with `POST /v1/limits`, passing EUR uses the `"sepa"` rail identifier. New EUR BUY quotes use a Polygon source route and deliver to supported non-Polygon EVM destinations. Polygon and AssetHub are not available as destinations for this flow. New EUR SELL quotes are rejected. -EUR BUY is currently supported through the direct API, not the SDK, Widget, or Dashboard. Registration requires only the normal quote ID, a fresh EVM signing account, and `additionalData.destinationAddress`; profile, address, and IBAN identity are derived server-side and caller-supplied identity fields are rejected. +EUR BUY is supported through the SDK, the direct API, the Dashboard, and the Widget. Registration requires the normal quote ID, a fresh EVM signing account, `additionalData.destinationAddress`, and (for the SDK) `walletAddress`, the wallet linked to the user's Monerium profile; profile, address, and IBAN identity are derived server-side and caller-supplied identity fields are rejected. A user without a Monerium binding gets `MONERIUM_ONBOARDING_REQUIRED`; a user whose backend Monerium session expired gets `MONERIUM_REAUTHENTICATION_REQUIRED` and must reconnect Monerium in the Dashboard or Widget. Registration succeeds only for a pre-provisioned individual or business legal entity with an approved local EUR provider binding, a live approved provider profile, and exactly one existing Polygon EOA/IBAN destination. The user must control that linked EOA. The register response includes unsigned ephemeral transactions and an EIP-712 permit whose signer is the linked owner EOA. Route transactions by `signer`: sign ephemeral-owned entries with the fresh ephemeral key and send the permit to the owner's wallet. Submit the complete signed set to `POST /v1/ramp/update`. Only then does `ibanPaymentData` expose the IBAN, receiver name, BIC, and payment reference. Display those values verbatim, have the user initiate the SEPA transfer, and call `POST /v1/ramp/start` before the ramp's start deadline. -The current integration does not create or import provider profiles, connect wallets, provision or move IBANs, or manage KYC/KYB lifecycle state. Those setup operations must already be complete before registration. The owner permit expires 24 hours after preparation; late settlement can require manual resolution. +Onboarding, wallet linking, and IBAN provisioning happen in the Dashboard or Widget (Monerium OAuth, then linking the paying wallet); the API does not import external profiles or manage KYC/KYB lifecycle state. Those setup steps must be complete before registration. The owner permit expires 24 hours after preparation; late settlement can require manual resolution. --- diff --git a/docs/proposal-monerium-dual-app.md b/docs/proposal-monerium-dual-app.md index 1886d7013..baf825335 100644 --- a/docs/proposal-monerium-dual-app.md +++ b/docs/proposal-monerium-dual-app.md @@ -204,11 +204,15 @@ Managed children, quote simulation, execution, and the B2B onramp are unchanged. ## Phase 5: SDK and direct API -- `VortexSdk.registerRamp` returns user-owned `unsignedTransactions` for SEPA BUY instead - of forcing an empty list; the EUR handler keeps owner-signed transactions and - `updateRamp` accepts the permit signature through `submitUserSignature`. -- README and `ARCHITECTURE.md` drop the "direct API only" caveat and document the - onboarding prerequisite (dashboard or widget) plus the two new error types. +- `EurHandler` (renamed from `MykoboHandler`) registers the Monerium onramp with + `{ destinationAddress, walletAddress }`, signs only the ephemeral-owned transactions, and + `VortexSdk.registerRamp` returns the owner permit in `unsignedTransactions` for the linked + wallet to sign through `submitUserTransactions`; `updateRamp` explains that for SEPA BUY. + The legacy Mykobo SELL adapter stays for persisted flows. +- `MONERIUM_ONBOARDING_REQUIRED` and `MONERIUM_REAUTHENTICATION_REQUIRED` map to + `MoneriumOnboardingRequiredError` and `MoneriumReauthenticationRequiredError`. +- README, `ARCHITECTURE.md`, the API pages, and the integration skill drop the + "direct API only" caveat and document the onboarding prerequisite (dashboard or widget). ## Phase 6: documentation and security spec From 989e3428745204724f680edfbd24db7cebc29a52 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 09:30:30 +0200 Subject: [PATCH 12/42] docs(repo): record the dual Monerium app decision and boundary Convert the accepted proposal into adr-0006, describe both Monerium applications as active profile sources in the operations interface and security spec, flip the audit checklist items the first-party clients now satisfy, and record the backend-memory OAuth session dependency as RISK-025. --- docs/README.md | 4 +- docs/adr-0006-monerium-dual-app.md | 66 +++++ docs/architecture-identity-model.md | 7 +- docs/operations-monerium-interface.md | 100 +++++--- docs/operations-testing.md | 10 +- docs/proposal-monerium-dual-app.md | 239 ------------------ .../03-ramp-engine/ramp-phase-flows.md | 2 +- .../security-spec/05-integrations/monerium.md | 52 ++-- docs/security-spec/README.md | 4 +- docs/security-spec/RISK-REGISTER.md | 1 + 10 files changed, 170 insertions(+), 315 deletions(-) create mode 100644 docs/adr-0006-monerium-dual-app.md delete mode 100644 docs/proposal-monerium-dual-app.md diff --git a/docs/README.md b/docs/README.md index 429cc0057..c9fdfc620 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`adr-0003-managed-headless-profiles.md`](adr-0003-managed-headless-profiles.md) | Accepted identity, ownership, authorization, and lifecycle decisions for managed headless profiles | | [`adr-0004-sandbox-demo-environment.md`](adr-0004-sandbox-demo-environment.md) | Accepted decision on the seeded sales-demo account in the sandbox environment | | [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md) | Accepted decisions, final parameter registry, and accepted risks for the Monerium B2B onramp | +| [`adr-0006-monerium-dual-app.md`](adr-0006-monerium-dual-app.md) | Accepted decision to serve the EUR onramp through both the Monerium OAuth and white-label applications | | [`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 | @@ -28,13 +29,12 @@ The smaller set of general project documents stays directly in `docs/`: | [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | | [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md) | Launch gates, deploy checklist, and terms inputs for the B2B onramp pilot | | [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md) | Operator procedures for the B2B onramp: onboarding, incidents, alert triage, dormancy, migration | -| [`operations-monerium-interface.md`](operations-monerium-interface.md) | Active direct-API Monerium onramp boundary plus white-label profile, address, IBAN, and payment interface reference | +| [`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 | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | | [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md) | Phase-2 proposal for the consumer (Safe + passkey) Monerium onramp; the B2B variant shipped | | [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | -| [`proposal-monerium-dual-app.md`](proposal-monerium-dual-app.md) | Implementation plan for running the Monerium OAuth and white-label apps in parallel on the EUR corridor, stacked on PR #1359 | | [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | The root [`README.md`](../README.md) is human onboarding, [`MAP.md`](../MAP.md) is diff --git a/docs/adr-0006-monerium-dual-app.md b/docs/adr-0006-monerium-dual-app.md new file mode 100644 index 000000000..a7adb33c2 --- /dev/null +++ b/docs/adr-0006-monerium-dual-app.md @@ -0,0 +1,66 @@ +# ADR 0006: Run the Monerium OAuth and White-Label Apps in Parallel + +**Status:** Accepted (decided 2026-09-14, implemented 2026-09-14/15 on top of the Monerium +reintegration, PR #1359). How the system works lives in +[`operations-monerium-interface.md`](operations-monerium-interface.md) and +[`product-dashboard.md`](product-dashboard.md); invariants and the threat model in +[`security-spec/05-integrations/monerium.md`](security-spec/05-integrations/monerium.md); +accepted risk in [`security-spec/RISK-REGISTER.md`](security-spec/RISK-REGISTER.md) +(RISK-025). The B2B onramp ([`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md)) +is unaffected. + +## Context + +The Monerium reintegration rebuilt the EUR onramp on the white-label application: mint EURe to +the wallet linked to the user's Monerium profile, take that wallet's ERC-2612 permit, swap on +Polygon, settle through Squid. It assumed users are provisioned into the white-label app. That +onboarding is blocked until KYC sharing exists, so the only way to offer the EUR rail again is +the Monerium OAuth application, where users complete KYC/KYB in Monerium's hosted flow. + +Sandbox probes on 2026-09-14 settled the facts the design depends on: + +- The white-label application cannot see profiles that only authorized the OAuth application + (`403` on profile, address, and IBAN reads; absent from its profile list). The only + cross-app read is `GET /addresses/{address}`, which reveals the owning profile ID but nothing + about it. +- With the user's OAuth token, `POST /addresses` links a wallet (`201`) and `POST /ibans` + requests an IBAN (`202`); a profile holds one IBAN, and a second chain or address requires + moving it (`PATCH /ibans/{iban}`). +- Monerium ignores the legacy link-at-login authorize parameters. +- The active onramp is pinned to Polygon mainnet while Monerium's sandbox mints on testnets, so + the pay-in itself can only be verified end to end in production. + +## Decision + +1. **Both apps stay in service; only the credential differs.** At EUR ramp registration the + backend resolves the user's `monerium`/`eur` binding through the white-label app first and, + when that app answers `403` or `404`, through the user's backend-held OAuth token. Any other + white-label failure does not switch apps. The on-chain flow is identical for both paths. +2. **No stored source.** Which app served a profile is decided per call and logged, never + persisted: after registration the ramp facts make the source irrelevant. +3. **OAuth tokens stay in backend memory only**, as before. A missing or rejected session fails + closed with `MONERIUM_REAUTHENTICATION_REQUIRED`; a missing binding with + `MONERIUM_ONBOARDING_REQUIRED`. Clients prompt a reconnect. +4. **Vortex provisions the wallet and IBAN for OAuth users.** The client collects the EOA's + signature over Monerium's fixed ownership message; the backend verifies it and the EOA + requirement, links through the app that can read the profile, and requests the profile's + single IBAN when none exists. An IBAN that sits elsewhere is moved only on an explicit owner + request, because a move redirects future SEPA deposits. Status reads never mutate provider + state. Wallet-address discovery of unbound users is not offered. +5. **Callback allowlist.** The OAuth callback is chosen by a `client` selector from the + configured dashboard and widget URIs and bound into the transaction; a mismatch renders + Monerium's authorization page empty, never a caller-supplied redirect. +6. **All first-party clients.** The dashboard and the widget run the OAuth onboarding, the + wallet-link step, and the permit signing; the SDK returns the permit as a user-owned + transaction. The Mykobo onboarding paths stay only for persisted legacy flows. + +## Consequences + +- OAuth-onboarded users depend on a backend-memory token for readiness and registration; a + backend restart forces a reconnect before their next EUR ramp (RISK-025). An encrypted + refresh-token store is the alternative if reconnect prompts prove too frequent. +- OAuth-to-white-label migration, profile creation through the white-label API, and external + profile import remain undefined and are still refused. +- EUR SELL remains unavailable for new quotes. +- Sandbox verification stops at onboarding, wallet linking, and IBAN provisioning; the Polygon + pay-in is verified in production only until the flow gains a testnet variant. diff --git a/docs/architecture-identity-model.md b/docs/architecture-identity-model.md index b42bc0ae0..0d7c3c1aa 100644 --- a/docs/architecture-identity-model.md +++ b/docs/architecture-identity-model.md @@ -174,9 +174,10 @@ Alfredpay customer creation uses the child's immutable provider contact email, n manager's login email. Email-bound Mykobo operations and Monerium OAuth KYC/KYB onboarding remain unsupported for delegated profiles; these legacy routes ignore a managed selector and remain scoped to the authenticated manager, so managed clients must not send that header to them. Future Monerium -import handling is TBD. This does not prevent a non-technical managed child whose Monerium binding -and Polygon EOA/IBAN were provisioned out of band from using the active direct-API EUR BUY flow when -manager corridor policy allows it. +import handling is TBD. One `provider_customers` binding serves both Monerium applications; +registration decides at runtime which app can read the profile. This does not prevent a +non-technical managed child whose Monerium binding and Polygon EOA/IBAN were provisioned out of +band from using the active EUR BUY flow when manager corridor policy allows it. Child-owned credentials authenticate directly as the child. Public and secret validation derive the unique active manager relationship on every request; corridor-bound route diff --git a/docs/operations-monerium-interface.md b/docs/operations-monerium-interface.md index 0510f6a49..9637e5da4 100644 --- a/docs/operations-monerium-interface.md +++ b/docs/operations-monerium-interface.md @@ -26,35 +26,45 @@ corridor cannot serve quotes while every registration is guaranteed to fail auth ## Current Vortex Release Boundary -The backend API accepts new EUR BUY quotes and registrations. A user is corridor-ready only when -operations has already provisioned all of the following: - -- an approved Vortex `provider_customers` binding for provider `monerium`, rail `eur`, and the - authenticated legal entity; -- the same profile still reports `approved` through the white-label API; -- exactly one existing Polygon IBAN whose mint destination is an EOA linked to that profile; and -- access to that EOA so the API client can collect the exact ERC-2612 permit returned at registration. - -Both individual and business legal entities may use the backend flow when they meet those -preconditions. The first-party SDK, dashboard, and widget do not yet complete the linked-owner permit -journey, so this release does not claim EUR availability through those clients. A direct API client -must sign the user-owned permit, sign the ephemeral-owned transactions, submit all signatures through -`POST /v1/ramp/update`, make the SEPA transfer using the released instructions, and then call -`POST /v1/ramp/start`. - -This release intentionally does not create or import a Monerium profile, connect a wallet, provision -or move an IBAN, reconcile KYC/KYB lifecycle state, create user-to-corridor bindings, or support EUR -SELL. Those capabilities remain deferred even where the shared white-label client maps the underlying -provider endpoint. - -## Deferred Profile Sources - -Profiles may eventually be created directly through the white-label API or imported. One expected -source is a sibling Monerium authorization-code/PKCE application that Vortex operates for KYC/KYB -onboarding; other trusted external sources are also possible. The migration/import process, -persistence model, and status reconciliation are not yet defined. - -## Deferred KYC/KYB Profile Lifecycle +The backend API accepts new EUR BUY quotes and registrations. A user is corridor-ready when all of +the following hold ([adr-0006](adr-0006-monerium-dual-app.md)): + +- a Vortex `provider_customers` binding for provider `monerium`, rail `eur`, and the authenticated + legal entity carries the Monerium profile UUID; +- that profile reports `approved`, read through the white-label API when it can see the profile + and otherwise through the user's backend-held OAuth token; +- exactly one Polygon IBAN whose mint destination is an EOA linked to that profile; and +- the ramping client controls that EOA so it can sign the exact ERC-2612 permit returned at + registration. + +Users reach that state in one of two ways: operations provisions them into the white-label +application out of band, or they complete Monerium OAuth onboarding in the dashboard or widget and +then link the wallet they will pay in with (`POST /v1/monerium/wallet`), which lets Vortex link the +EOA and request or move the profile's single IBAN. Both individual and business legal entities are +eligible. The SDK, dashboard, and widget sign the owner permit and the ephemeral-owned transactions, +submit them through `POST /v1/ramp/update`, show the released SEPA instructions, and call +`POST /v1/ramp/start` after the transfer; a direct API client does the same itself. Readiness is +reported on `GET /v1/monerium/status` (`ramp`) and on the Monerium account of +`GET /v1/onboarding/status`. + +This release does not create profiles through the white-label API, import external profiles, +migrate OAuth profiles into the white-label application, orchestrate KYC/KYB lifecycle state +through the white-label API, or support EUR SELL. Those capabilities remain deferred even where the +shared client maps the underlying provider endpoint. + +## Profile Sources + +- **Monerium OAuth application.** Users verify in Monerium's hosted flow; Vortex keeps their + access and refresh tokens in backend memory only and mirrors the profile into + `provider_customers` and `kyc_cases`. The white-label application cannot see these profiles, so + every read for them uses the user's token; a lost session surfaces as + `MONERIUM_REAUTHENTICATION_REQUIRED` until the user reconnects. +- **White-label application.** Profiles it can see (provisioned out of band today) are read with + client credentials; no user session is needed. +- **Other imports** and the migration of OAuth profiles into the white-label application are not + defined. + +## KYC/KYB Profile Lifecycle Monerium does not expose a separate KYC/KYB case or attempt ID. The profile UUID created by `POST /profiles` is the durable workflow identity; its `kind` is immutable, and details, form data, @@ -73,16 +83,20 @@ the TBD migration design. | `rejected` | Final compliance rejection. Do not retry or create a replacement profile unless Monerium explicitly authorizes a new onboarding. | The current shared client implements profile reads but not `POST /profiles` or the onboarding -`POST`/`PATCH` operations above. The active ramp only verifies an already-bound profile. Externally -imported profiles enter Vortex directly as `approved` and do not execute these submission steps -locally. Lifecycle orchestration and imported-profile handling remain deferred. - -## Deferred Address And IBAN Management - -The operations below describe mapped provider capabilities. Active ramp registration uses only the -list/read operations and fails closed unless the required Polygon destination already exists. It does -not call `POST /addresses`, `POST /ibans`, or `PATCH /ibans/{iban}`. The B2B onramp links its -forwarder addresses and requests their IBANs through these operations under its own orchestration +`POST`/`PATCH` operations above; OAuth-onboarded users complete them in Monerium's hosted flow. +The active ramp only verifies an already-bound profile. Externally imported profiles would enter +Vortex directly as `approved`; lifecycle orchestration through the white-label API and +imported-profile handling remain deferred. + +## Address And IBAN Management + +Vortex uses the write operations below on the user's behalf through `POST /v1/monerium/wallet` +(link the connected EOA after verifying its signature over the fixed message, then request the +profile's single IBAN when none exists) and `POST /v1/monerium/iban/move` (move the IBAN to an +already-linked address on the owner's explicit request), each through the app that can read the +profile. Ramp registration and status reads use only the list/read operations and fail closed +unless the required Polygon destination already exists. The B2B onramp links its forwarder +addresses and requests their IBANs under its own orchestration ([architecture-monerium-b2b-onramp.md](architecture-monerium-b2b-onramp.md)). | Operation | Endpoint / sequence | Commentary | Source | @@ -116,10 +130,12 @@ client preserves both documented response semantics. ## Active On-Ramp: SEPA To EURe 1. Quote simulation selects the fixed Polygon EURe route without reading Monerium identity. -2. Registration derives the profile UUID from the authenticated legal entity's approved local - binding; caller-supplied profile, address, or IBAN identity is rejected. +2. Registration derives the profile UUID from the authenticated legal entity's local binding and + reads it through the white-label app or, when that app cannot see it, the user's OAuth token; + caller-supplied profile, address, or IBAN identity is rejected. 3. Vortex requires the live profile to be `approved` and resolves exactly one existing Polygon EOA - and IBAN with the same mint destination. No provider resource is created or moved. + and IBAN with the same mint destination. Registration creates or moves no provider resource; + the wallet-link step did that earlier. 4. Vortex snapshots the owner's EURe balance and prepares the owner permit, the ephemeral `transferFrom`, and all downstream route transactions. 5. `POST /v1/ramp/update` validates the complete signature set before releasing diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 0dfa4e7e8..6b462e8ec 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -90,7 +90,8 @@ cross-chain offramp scenarios. ³ Active Monerium coverage is split across focused block tests. The retained `corridors/eur-*.scenario.test.ts` files now seed identity-bearing Mykobo metadata directly and verify only persisted legacy recovery. Add a full Monerium fake-world quote→register→execute -scenario before claiming end-to-end corridor coverage. +scenario before claiming end-to-end corridor coverage. Monerium's sandbox mints on testnets while +the flow is pinned to Polygon mainnet, so the pay-in is not sandbox-verifiable either. **Gaps at a glance** (everything not ✅ above): the Alfredpay permit/TokenRelayer cross-chain SELL variant is untested (no-permit fallback is); the active EUR onramp lacks SDK, E2E, and full @@ -243,9 +244,10 @@ Notes: ### EUR coverage SEPA/EUR BUY is cataloged through Monerium; SELL returns public `400`. Focused tests cover -profile-derived registration, Polygon EURe baseline persistence, balance-delta execution, -exact self-transfer, pinned Uniswap conversion, flow topology, quote selection, and SELL -rejection. The old Mykobo corridor scenarios persist legacy metadata directly to exercise +white-label/OAuth identity resolution, wallet-link and IBAN-move rules, profile-derived +registration, Polygon EURe baseline persistence, balance-delta execution, exact self-transfer, +pinned Uniswap conversion, flow topology, quote selection, and SELL rejection; the SDK suite +covers EUR onramp registration and the returned owner permit. The old Mykobo corridor scenarios persist legacy metadata directly to exercise recovery without reconnecting Mykobo to quote creation. A complete Monerium fake-world corridor, SDK contract, and E2E journey remain open coverage gaps. diff --git a/docs/proposal-monerium-dual-app.md b/docs/proposal-monerium-dual-app.md deleted file mode 100644 index baf825335..000000000 --- a/docs/proposal-monerium-dual-app.md +++ /dev/null @@ -1,239 +0,0 @@ -# Proposal: Dual Monerium App Support (OAuth + White-Label) - -Status: proposed implementation plan for a stacked PR on top of -[PR #1359](https://github.com/pendulum-chain/vortex/pull/1359) (`monerium-reintegration`). -Decision sought: run the Monerium OAuth application and the Monerium white-label -application in parallel for the EUR corridor, resolving the user's profile through the -white-label app first and the OAuth app second. Last updated: 2026-09-14. - -Related material: - -- [`Monerium Interface`](operations-monerium-interface.md) -- [`Monerium Integration (security spec)`](security-spec/05-integrations/monerium.md) -- [`Identity, Customer, and Partner Model`](architecture-identity-model.md) -- [`ADR-0005 Monerium B2B onramp`](adr-0005-monerium-b2b-onramp.md) (unaffected, uses the - white-label client through its own attestor orchestration) - -## Objective - -Give users the EUR/SEPA rail back without white-label onboarding, which is blocked until -KYC sharing exists. At EUR ramp time Vortex resolves the authenticated user's Monerium -profile in this order: - -1. the profile is visible to the **white-label** app (client credentials); -2. otherwise the profile is reachable through the **OAuth** app (the user's backend-held - token); -3. otherwise the user is not onboarded and the client offers OAuth onboarding. - -Both paths feed the same on-chain flow shipped by PR #1359 (mint to the user's linked EOA, -owner permit, self-transfer, Uniswap, Squid). Only the credential used to read profile, -linked address, and IBAN differs. - -## Decisions already taken - -| Topic | Decision | -|---|---| -| Difference between paths | Credential for the registration-time reads only. Execution never calls Monerium. | -| Wallet-address discovery | Not in scope. Detection uses the local binding's profile ID only. Address lookup would adopt a Monerium identity from a bare wallet address, which the spec forbids without an ownership proof. Can be added later behind a signed link-message proof. | -| Wallet link and IBAN for OAuth users | Vortex does it with the user's OAuth token: `POST /addresses` with a client-collected link signature, then `POST /ibans` (or a user-confirmed move). Link-at-login is no longer supported by Monerium (P2). The user never uses Monerium's own app. | -| Client scope | Dashboard, widget, SDK/direct API. | -| OAuth token storage | Backend memory only, as today. Legacy widget kept the token in the persisted ramp machine snapshot in `localStorage`; that does not return. | -| Recording the source | Runtime resolution on every registration, no schema change. After registration the persisted facts (profile, address, IBAN, baseline) make the source irrelevant. | - -## Facts the plan relies on - -- `createRegisterMoneriumIssue` (`apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts`) - takes `resolveProfileId` and a client exposing `getProfile`, `listAddresses`, `listIbans` - by dependency injection. Everything after profile resolution is credential-agnostic. -- The OAuth service (`apps/api/src/api/services/monerium/monerium.service.ts`) already - caches access and rotating refresh tokens per legal entity, mirrors the profile into - `provider_customers` (provider `monerium`, rail `eur`), and surfaces - `MONERIUM_REAUTHENTICATION_REQUIRED` when the token is gone. The dashboard renders that - code as a reconnect prompt. -- Both apps share one `provider_customers` row per entity. The white-label API has no - email lookup, so "known to the white-label app" can only be tested with a profile ID. -- The legacy widget (removed in `32dc0a87c`) linked the wallet during OAuth login using - `/auth?address=&chain=&signature=` and passed the token to the backend at registration. - The on-chain design was the same permit-based self-transfer. -- Dashboard and widget both call `/v1/ramp/*` directly, hold a Supabase session for - logged-in users, and already sign EIP-712 typed data with wagmi. The dashboard has the - Monerium OAuth UI (`MoneriumKycFlow`, `/monerium/callback`) built on the shared - `@vortexfi/kyc` Monerium machine; the widget has none (SEPA onboarding there is the - legacy Mykobo form). The SDK EUR handler drops owner-signed transactions. - -## Phase 0: sandbox probes (before code) - -| Probe | Question | Effect on the plan | -|---|---|---| -| P1 | Does the white-label client see a profile onboarded through the OAuth app (`GET /profiles/{id}` with client credentials)? | If yes, step 1 of the resolution already covers OAuth users and the OAuth read adapter is only a fallback. If no, the fallback is the main path for every OAuth user. | -| P2 | With a user token from the OAuth app: are `GET /ibans?profile=`, `POST /ibans`, and `POST /addresses` permitted? Does `/auth?address&chain&signature` still link at login? | Decides whether Vortex can provision the IBAN itself and whether re-linking needs another authorize round trip. | -| P3 | Sandbox chain: legacy minted on `amoy` in sandbox. Confirm the #1359 flow's sandbox network and the chain used for IBAN and address filters agree. | Configuration only. | - -Record the results in this document and in `operations-monerium-interface.md`. - -Results so far (2026-09-14): - -- P3: the #1359 flow is pinned to Polygon mainnet (`MoneriumIssue(Networks.Polygon)`, mainnet - EURe, mainnet Uniswap pool) regardless of `SANDBOX_ENABLED`. Monerium sandbox profiles - hold `amoy`/`sepolia` addresses, so a sandbox IBAN can never match at registration. - Sandbox end-to-end ramps need either an Amoy variant of the flow or production-only - verification; the API-level probes are unaffected. -- The sandbox "white-label" credentials and the older sandbox credentials from the B2B work - resolve to the same Monerium application. It sees one partner-owned personal profile - (pending) with the B2B forwarder addresses and IBAN. -- Monerium docs (API reference and white-label guide) do not state which token types may - call `POST /ibans` and `POST /addresses`, and the legacy `/auth` link parameters - (`address`, `chain`, `signature`) are no longer documented. Both need the live - user-token probe. -- P2 (run 2026-09-14 with the partner account against the sandbox OAuth app "Vortex"): - - The authorization-code exchange returns a 1-hour access token plus a refresh token. - - `POST /addresses` with the **user** token links a new EOA (`201`, state `linked`). - - The legacy link-at-login parameters (`address`, `chain`, `signature` on `/auth`) are - ignored: the address was not linked. Vortex must link through `POST /addresses`. - - `POST /ibans` with the user token reaches the business rule, not an auth error, and - answers `400 IBAN already requested or provisioned for this profile`: **one IBAN per - profile**. A second chain/address requires moving the IBAN (`PATCH /ibans/{iban}`), - which the OAuth app is permitted to do ("Update IBANs"). - - The OAuth app's enabled permissions are Create wallet address, Read/Create/Update - IBANs, and Create payments. It has no KYC permissions; KYC happens in Monerium's - hosted flow. - - Registered redirect URIs on the OAuth app: `http://localhost:5174/dashboard/monerium/callback`, - `http://localhost:5473/widget`, `http://localhost:5473`, `http://localhost:5474/dashboard`, - `http://localhost:5474`. A mismatch renders the authorization page empty with no error. -- P1 (run 2026-09-14 with a second sandbox user who signed up inside the OAuth flow via - `auth_mode=signup`): **the white-label app cannot see OAuth-onboarded profiles.** - `GET /profiles/{id}`, `GET /addresses?profile=`, and `GET /ibans?profile=` with client - credentials answer `403 ... does not have access to profile ... with required scopes`, - and the profile is absent from the white-label `GET /profiles` list and `/auth/context`. - Only `GET /addresses/{address}` answers `200` for the user-linked address, so an address - lookup can reveal which profile owns an address but cannot read that profile. - Consequences: for OAuth users the user token is the only read path, at onboarding and at - every registration; a lost backend token means reauthentication before ramping; the - resolver order (white-label first, OAuth second) stands. On the fresh profile - `POST /ibans` with the user token answered `202 Accepted`, confirming provisioning. - -## Phase 1: API identity resolution - -New module `apps/api/src/api/services/monerium/identity.ts`: - -``` -resolveMoneriumIdentity(userId, network, transaction) - -> { profileId, source: "whitelabel" | "oauth", client: MoneriumReadClient } -``` - -1. Load the entity's `provider_customers` row (provider `monerium`, rail `eur`). No row or - no `providerCustomerId` → `MONERIUM_ONBOARDING_REQUIRED`. -2. White-label: `MoneriumApiService.getProfile(profileId)`. Visible and `approved` → - source `whitelabel`, client is the shared service. Not visible (404/403) → continue. - Visible but not approved → reject as today. -3. OAuth: cached credentials for the entity → read `/profiles/{id}`, `/addresses`, `/ibans` - with the user bearer token through a small adapter that validates responses with the - shared zod schemas from `packages/shared/src/services/monerium/schemas.ts`. Approved → - source `oauth`. No cached credentials → `MONERIUM_REAUTHENTICATION_REQUIRED`. - -`createRegisterMoneriumIssue` replaces its `resolveProfileId` + `getClient` dependencies -with `resolveIdentity`; the destination matching, EOA check, baseline read, and facts stay -unchanged. The source is logged, not persisted. - -Error contract on `POST /v1/ramp/register`: `MONERIUM_ONBOARDING_REQUIRED` and -`MONERIUM_REAUTHENTICATION_REQUIRED` become documented public error types (OpenAPI, -wire-contract snapshot, SDK error mapping). - -Tests: resolver order with fakes for both clients; registration tests for each source; -hermetic contract coverage for the user-token read schemas. - -## Phase 2: API onboarding and readiness - -1. `POST /v1/monerium/oauth/start` accepts a `client` selector (`dashboard`, the default, or - `widget`). The redirect URI comes from an allowlist (`MONERIUM_REDIRECT_URI`, - `MONERIUM_WIDGET_REDIRECT_URI`; the widget flow is refused with `503` when the latter is - unset) and is bound into the OAuth transaction exactly as today. Both URIs are registered - with Monerium. Link-at-login is dead (P2), so the start request carries no wallet - parameters. -2. Wallet link, `POST /v1/monerium/wallet` (bearer session, no impersonation): body - `{ address, chain, signature }` where `signature` is the user's EOA signature over the - fixed link message. The backend verifies it with viem `verifyMessage`, rejects addresses - with deployed code (the permit needs an EOA), resolves the profile through the identity - resolver, links through whichever app can read it (`POST /addresses`, skipped when already - linked), and then handles the profile's single IBAN (P2): none → `POST /ibans` and - `iban: "pending"` (Monerium's "already requested" `400` also maps to `pending`); present - on that address and chain → `provisioned`; present elsewhere → `elsewhere`. -3. IBAN move, `POST /v1/monerium/iban/move` with `{ address, chain }`: moves the single - IBAN (`PATCH /ibans/{iban}`) to an address already linked on that chain. It exists only as - an explicit owner action because it redirects the user's future SEPA deposits; nothing - else moves an IBAN. -4. Readiness: `GET /v1/monerium/status` adds, for approved profiles, - `ramp: { source, linkedAddress, chain, iban: "provisioned" | "elsewhere" | "missing" }` - measured against the chain the active onramp mints on (`MONERIUM_RAMP_CHAIN`, Polygon), - or `rampError: { code: "MONERIUM_REAUTHENTICATION_REQUIRED", message }` when the live - read needs an OAuth session that is gone (a persisted approval stays readable). The - Monerium account entry of `GET /v1/onboarding/status` carries the same `ramp` object - (`null` elsewhere; a lost session surfaces through the existing `error` field). Status - reads never mutate provider state. Nothing is persisted; Monerium stays authoritative. - -Managed children, quote simulation, execution, and the B2B onramp are unchanged. - -## Phase 3: dashboard - -- EU corridor card reads `ramp` readiness. Approved without a linked wallet or IBAN shows a - "Link wallet" step: connect wallet, sign the link message, call `POST /v1/monerium/wallet`, - then poll status until the IBAN is `provisioned` (or confirm a move through - `POST /v1/monerium/iban/move` when it is `elsewhere`). -- Transfer machine, EUR BUY: the connected wallet must equal `ramp.linkedAddress` before - registration; the owner permit is signed with the existing `signMultipleTypedData`; - `updateRamp` carries ephemeral presigns plus the permit; `ibanPaymentData` from the - response renders the SEPA instructions; then `startRamp`. -- `MONERIUM_REAUTHENTICATION_REQUIRED` from registration reopens the reconnect prompt and - retries registration afterwards. - -## Phase 4: widget - -- SEPA/EUR verification routes to a Monerium step built on the shared `@vortexfi/kyc` machine - behind the existing Supabase OTP login. The Mykobo child stays in the codebase for persisted - legacy flows only. -- Authorization opens as top-level navigation when the widget is the top document and in a new - tab when embedded (with an "I have finished" re-check). Monerium returns to the registered - `/widget` callback; `useSetRampUrlParams` hands `code`/`state` (or `error`) to the persisted - ramp, which restarts the restored Monerium step with the callback, then drops the params. -- A second widget machine links the connected EVM wallet after approval through - `POST /v1/monerium/wallet` using the existing message-signature callback, polls readiness until - the IBAN is provisioned, and asks before moving an IBAN that sits elsewhere. Substrate wallets - are refused because the onramp needs the EOA permit. -- EUR BUY registration sends the connected wallet as `walletAddress`; the existing user-signing - actor already signs the owner permit and the summary step already renders `ibanPaymentData`. - -## Phase 5: SDK and direct API - -- `EurHandler` (renamed from `MykoboHandler`) registers the Monerium onramp with - `{ destinationAddress, walletAddress }`, signs only the ephemeral-owned transactions, and - `VortexSdk.registerRamp` returns the owner permit in `unsignedTransactions` for the linked - wallet to sign through `submitUserTransactions`; `updateRamp` explains that for SEPA BUY. - The legacy Mykobo SELL adapter stays for persisted flows. -- `MONERIUM_ONBOARDING_REQUIRED` and `MONERIUM_REAUTHENTICATION_REQUIRED` map to - `MoneriumOnboardingRequiredError` and `MoneriumReauthenticationRequiredError`. -- README, `ARCHITECTURE.md`, the API pages, and the integration skill drop the - "direct API only" caveat and document the onboarding prerequisite (dashboard or widget). - -## Phase 6: documentation and security spec - -- `security-spec/05-integrations/monerium.md`: invariants for the resolution order, the - server-side link-signature verification, the EOA requirement at link time, the redirect - allowlist, the unchanged memory-only token rule, and the still-forbidden caller-supplied - profile identity. The "Deferred OAuth" sections become the active description. -- `RISK-REGISTER.md`: OAuth-app profiles that the white-label app cannot see remain - dependent on backend token presence; migration between apps is still undefined. -- `operations-monerium-interface.md`, API pages, OpenAPI, and wire-contract snapshot. - -## Commit slices - -One stacked PR, one logical commit per phase: probe results (docs), API resolver and -adapter, API onboarding and readiness, dashboard, widget, SDK, docs and security spec. - -## Open items - -- Token persistence: with P1 answered, every OAuth-user registration depends on a cached - backend token. Memory-only is the current decision; an encrypted refresh-token store is - the alternative if reauthentication prompts prove too frequent. -- Embedded-widget authorization strategy (new tab versus popup) once the embed contract is - checked. -- Whether the dormant Mykobo widget form is removed in this PR or later. 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 49df05e22..81643d08e 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -19,7 +19,7 @@ The phase processor in `state-machine.md` orchestrates execution. The authoritat **EUR On-ramp (Monerium SEPA on Polygon):** SEPA payment → EURe increase on the profile-linked Polygon owner → exact owner-to-ephemeral transfer → pinned Uniswap V3 EURe→USDC → fees/subsidy → Squid → user destination - Runtime phases: `initial` → `moneriumOnrampMint` → `fundEphemeral` → `moneriumOnrampSelfTransfer` → `uniswapApprove` → `uniswapSwap` → `distributeFees` → `subsidizePostSwap` → `squidRouterSwap` → `squidRouterPay` → `finalSettlementSubsidy` → `destinationTransfer` → `complete`. -- Registration derives one approved profile-linked Polygon EOA/IBAN pair and persists the owner's EURe baseline. The SEPA artifact uses the full quote input amount; issue execution waits for `baseline + quoted post-fee EURe` and treats balance-check timeout/read failure as recoverable. +- Registration resolves the profile through the white-label app or the user's backend-held OAuth token (`05-integrations/monerium.md`), derives one approved profile-linked Polygon EOA/IBAN pair, and persists the owner's EURe baseline. The SEPA artifact uses the full quote input amount; issue execution waits for `baseline + quoted post-fee EURe` and treats balance-check timeout/read failure as recoverable. - Self-transfer moves exactly the quoted post-fee EURe from the owner to the ephemeral using the owner permit and ephemeral `transferFrom`; excess or duplicate EURe remains with the owner. - The fixed Polygon Uniswap block converts EURe to native USDC through the pinned 500-fee pool. Fees and post-swap subsidy run on Polygon USDC before Squid destination settlement. - Polygon destinations are not selected because this topology contains the cross-chain Squid pay phase. Supported non-Polygon EVM destination tokens must exist in `evmTokenConfig`. diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 8343e6025..497f7b8a4 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -45,24 +45,28 @@ native USDC `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359`, the 500-fee pool ### Release Boundary -The active product boundary is the backend EUR BUY flow for a pre-provisioned corridor-ready legal -entity. Both individual and business entities are eligible when they have an approved local -Monerium/`eur` provider-customer binding, the bound provider profile is still approved, exactly one -existing Polygon EOA/IBAN destination resolves for that profile, and the API client can collect a -permit from that EOA. The API neither selects identity from caller input nor creates the missing -provider resources. - -The following are explicitly deferred and MUST NOT be inferred from the shared client's endpoint -coverage: EUR SELL, profile creation/import, OAuth-to-white-label migration, KYC/KYB lifecycle -orchestration, user-to-corridor binding, wallet linking, IBAN provisioning/movement, and first-party -SDK/dashboard/widget execution of the owner-wallet signing journey. A direct API client can complete -the active onramp; the first-party clients cannot yet do so. - -### Deferred OAuth And Imported Profiles - -- Vortex operates a sibling authorization-code/PKCE Monerium application for KYC/KYB onboarding. - Profiles onboarded there may later be migrated into the white-label application through a process - that is still to be defined. +The active product boundary is the EUR BUY flow for a legal entity whose Monerium profile is +readable through either Monerium application ([adr-0006](../../adr-0006-monerium-dual-app.md)). +Both individual and business entities are eligible when they have a `monerium`/`eur` +provider-customer binding, the bound profile is approved, exactly one Polygon EOA/IBAN destination +resolves for it, and the ramping client can collect a permit from that EOA. Profiles reach that +state either by out-of-band provisioning into the white-label app or by Monerium OAuth onboarding +in the dashboard or widget followed by the Vortex wallet-link step (`POST /v1/monerium/wallet`), +which links the connected EOA and requests or moves the profile's IBAN. The API never selects +identity from caller input. The SDK, dashboard, and widget all complete the owner-permit journey; +direct API clients submit the permit themselves. + +The following remain deferred and MUST NOT be inferred from the shared client's endpoint coverage: +EUR SELL, profile creation through the white-label API, OAuth-to-white-label migration, external +profile import, KYC/KYB lifecycle orchestration through the white-label API, and automatic permit +recovery. + +### OAuth Profiles And Imported Profiles + +- Vortex operates a sibling authorization-code/PKCE Monerium application for KYC/KYB onboarding + (specified below). Its profiles are invisible to the white-label application and are read only + through the user's backend-held token; the identity resolver covers both apps at registration. + Migration of such profiles into the white-label application is still undefined. - Profiles may also be imported from other trusted external sources. Every source MUST associate the correct Monerium profile UUID with the correct Vortex legal entity; no caller-controlled profile adoption may be exposed while the import contract is undefined. @@ -116,6 +120,9 @@ the active onramp; the first-party clients cannot yet do so. | Balance-delta misattribution | An unrelated or duplicate EURe credit increases the linked owner's balance enough to satisfy a ramp | Accepted under RISK-023: the executor advances on the persisted balance delta, then transfers only the quoted amount. No claim of deterministic SEPA-order correlation is made; excess remains with the owner. | | Permit becomes unusable before settlement | SEPA settlement arrives after the 24-hour permit deadline or after its nonce is consumed | Accepted under RISK-024: execution proves the permit unusable and stops rather than broadening authorization; manual resolution is required when no sufficient allowance remains. | | Polygon swap route substitution | A stale or malicious endpoint points the conversion at a different pool, token, fee tier, router, or recipient | Deployment checks pin the pool/factory/router/quoter relationships; quote metadata and signed calldata are validated against constants, exact amounts, the ephemeral recipient, and bounded fee fields before broadcast | +| Forged wallet link | A caller links an address it does not control to a profile | The backend verifies the EOA signature over the fixed ownership message and rejects contract code before any provider call; Monerium verifies the signature again | +| IBAN redirection | A link, status, or registration call moves the profile's IBAN to another wallet | Only `POST /v1/monerium/iban/move`, an explicit owner request naming an already-linked address, calls `PATCH /ibans`; reads never mutate provider state | +| OAuth session loss | The backend restarts or Monerium revokes the refresh token, so an OAuth-onboarded profile cannot be read | Readiness and registration fail closed with `MONERIUM_REAUTHENTICATION_REQUIRED`; clients prompt a reconnect; tokens are never persisted (RISK-025) | ### Audit Checklist @@ -135,8 +142,9 @@ the active onramp; the first-party clients cannot yet do so. - [x] Strict transaction completeness requires the user-signed typed-data permit as well as every ephemeral-signed transaction before payment instructions are released. - [x] Wallet linking verifies the owner signature and the EOA requirement server-side, links through the resolving app, and requests at most one IBAN; IBAN moves need an explicit owner request to an already-linked address; status reads never mutate provider state (`wallet.test.ts`). - [x] The OAuth callback is selected from the configured dashboard/widget allowlist and bound into the transaction. -- [ ] OAuth-to-white-label migration, KYC/KYB lifecycle orchestration, user-to-corridor binding, wallet linking, and other import mechanisms are deferred; their trust boundary, persistence model, and status reconciliation remain TBD. -- [ ] The first-party SDK, dashboard, and widget do not complete the profile-linked owner-wallet signing journey. The active release is direct-API only. +- [ ] OAuth-to-white-label migration, KYC/KYB lifecycle orchestration through the white-label API, and external profile import are deferred; their trust boundary, persistence model, and status reconciliation remain TBD. +- [x] The SDK returns the owner permit as a user-owned transaction; the dashboard and widget sign it with the connected Monerium-linked wallet, and SEPA instructions are released only after that update. +- [ ] OAuth-onboarded users depend on a backend-memory Monerium session for readiness and registration; a restart forces a reconnect (RISK-025). - [ ] A permit collected before SEPA settlement can expire or become stale. If no sufficient allowance remains, the ramp stops for manual resolution; no automatic reauthorization path is implemented (RISK-024). - [x] The post-issue conversion route is fixed-pool Polygon EURe-to-USDC followed by the regular EVM fee, subsidy, Squid settlement, and destination-transfer blocks. - [ ] Provider-order correlation is not implemented. The active flow intentionally uses the accepted owner-balance-delta attribution model under RISK-023 instead. @@ -147,9 +155,9 @@ the active onramp; the first-party clients cannot yet do so. The backend provides authenticated Monerium OAuth authorization-code endpoints for individual KYC and business KYB. It generates OAuth state and PKCE material server-side, exchanges codes directly with Monerium, keeps access and rotating refresh tokens only in backend memory, reads the authenticated Monerium context and API-v2 profile, and mirrors only normalized verification metadata into `provider_customers` and `kyc_cases`. -The endpoints are `POST /v1/monerium/oauth/start`, `POST /v1/monerium/oauth/complete`, and `GET /v1/monerium/status`. They use the Supabase-authenticated user identity. `MONERIUM_REDIRECT_URI` is the exact dashboard callback URI registered with Monerium and is never derived from request input. After a successful callback exchange, the callback route restores any refreshed dashboard session and replace-navigates to the overview with the EU onboarding modal open; callback failures remain on the callback route so their error is preserved. +The endpoints are `POST /v1/monerium/oauth/start`, `POST /v1/monerium/oauth/complete`, `GET /v1/monerium/status`, and the wallet-readiness routes `POST /v1/monerium/wallet` and `POST /v1/monerium/iban/move` specified above. They use the Supabase-authenticated user identity. `MONERIUM_REDIRECT_URI` (dashboard) and `MONERIUM_WIDGET_REDIRECT_URI` (widget) are the exact callback URIs registered with Monerium; the start request's `client` selector picks one and it is never derived from request input. After a successful callback exchange, the dashboard callback route restores any refreshed session and replace-navigates to the overview with the EU onboarding modal open, and the widget's persisted ramp hands the callback to its restored verification step; callback failures preserve their error. -Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR recipient-eligibility provider. This change does not restore the historical Monerium EURe payment rail. EUR ramp registration remains disabled, and the dormant Mykobo settlement path must not be re-enabled until its separate Mykobo-profile gate is reconciled with Monerium identity. +Monerium replaces Mykobo as the EU onboarding provider in the dashboard and widget and as the EUR recipient-eligibility provider. Profiles onboarded here are readable only through the user's backend-held token; the EUR onramp resolves them through the identity resolver (invariant 16 above) and the wallet-link step provisions their IBAN. The dormant Mykobo settlement path stays legacy-recovery-only. ### Security Invariants diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index d725e09d5..32b8ee09a 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -60,7 +60,7 @@ documents win. | Integration Template | `05-integrations/_template.md` | Template for new provider specs | | BRLA | `05-integrations/brla.md` | BRLA anchor for BRL on/off-ramp | | Mykobo | `05-integrations/mykobo.md` | Legacy persisted EUR on/off-ramp recovery and standalone profile API | -| Monerium | `05-integrations/monerium.md` | Server-to-server white-label API client, the active direct-API Polygon EUR onramp, and the legacy OAuth KYC/KYB onboarding | +| Monerium | `05-integrations/monerium.md` | White-label API client, the Polygon EUR onramp for profiles reachable through either Monerium app, wallet/IBAN readiness, and OAuth KYC/KYB onboarding | | Monerium B2B | `05-integrations/monerium-b2b.md` | Whitelabel onramp: attestor address linking, HMAC webhook + durable inbox, forward-only deposits | | Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | | Binance | `05-integrations/binance.md` | Binance USDT spot price used as the primary USD<>BRL rate source | @@ -109,7 +109,7 @@ Most module specifications use these sections: | **XCM** | Cross-Consensus Messaging — the cross-chain transfer protocol between Polkadot parachains | | **BRLA** | Brazilian Real stablecoin anchor (BRL on/off-ramp) | | **Mykobo** | Legacy persisted EUR flow recovery and standalone profile/KYC endpoints; excluded from new quotes. | -| **Monerium** | European e-money provider integrated through the white-label API. Active direct-API SEPA/EUR BUY provider using Polygon EURe for already provisioned approved users, and the B2B onramp; onboarding/linking/import and EUR SELL are deferred. | +| **Monerium** | European e-money provider. SEPA/EUR BUY provider using Polygon EURe for users onboarded through its OAuth app (dashboard/widget) or provisioned into its white-label app, plus the B2B onramp; profile import and EUR SELL are deferred. | | **Alfredpay** | Fiat payment provider supporting multiple currencies | | **Binance** | Crypto exchange whose USDT/fiat spot ticker is the primary USD-to-fiat rate source for currencies with a liquid market (currently BRL via `USDTBRL`) | | **FastForex** | Fiat exchange-rate provider used as the USD-to-fiat rate source for currencies without a Binance market, and the fallback after Binance for those that have one | diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 77fefe75c..964f1e302 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -45,6 +45,7 @@ register and the owning module specification. | RISK-022 | Accepted | Medium | Product + Compliance + Operations | Avenia API and hosted KYB submission do not persist a durable pre-send claim. An ambiguous provider success or concurrent retry can therefore leave an unbound or superseded provider attempt. The low current KYB volume does not justify the additional submission-state machinery. | Provider active-attempt preflight, API conflict reconciliation, exact bound-attempt polling, and fail-closed handling of multiple active attempts. | Add a durable submission claim before increasing KYB volume, relying on unattended recovery, or observing duplicate or orphaned attempts operationally. | | 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; 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 24 hours after preparation 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. | ## Review cadence From d235a647089a238b0a8dfc07ddda7a6e3b66da6e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:44:09 +0200 Subject: [PATCH 13/42] fix(api): reject unsupported EUR onramp destinations with a public quote error The Monerium flow mints on Polygon and bridges onward, so a Polygon (or non-EVM) destination has no flow. Clients used to receive an opaque "No block flow mapped" 400; expose the rule as a shared helper and a public QuoteError so pickers and error mapping can share it. --- .../services/phases/blocks/flows/catalog.ts | 6 ++-- .../services/quote/eur-onramp-network.test.ts | 29 +++++++++++++++++++ apps/api/src/api/services/quote/index.ts | 18 ++++++++++++ docs/api/wire-contract.snapshot.md | 2 +- .../security-spec/05-integrations/monerium.md | 2 +- .../shared/src/endpoints/quote.endpoints.ts | 1 + packages/shared/src/helpers/networks.ts | 8 +++++ 7 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/api/services/quote/eur-onramp-network.test.ts diff --git a/apps/api/src/api/services/phases/blocks/flows/catalog.ts b/apps/api/src/api/services/phases/blocks/flows/catalog.ts index 827158afd..7ca0d495f 100644 --- a/apps/api/src/api/services/phases/blocks/flows/catalog.ts +++ b/apps/api/src/api/services/phases/blocks/flows/catalog.ts @@ -1,5 +1,6 @@ import { AssetHubToken, + doesNetworkSupportEurOnramp, EPaymentMethod, EvmToken, evmTokenConfig, @@ -121,7 +122,7 @@ const flowDefinitions: FlowDefinition[] = [ create(request) { const network = getNetworkFromDestination(request.to); const issueFeeEur = config.monerium.issueFeeEur; - if (!network || network === Networks.Polygon || !isNetworkEVM(network) || !isEvmToken(request.outputCurrency)) { + if (!network || !doesNetworkSupportEurOnramp(network) || !isEvmToken(request.outputCurrency)) { throw new APIError({ message: "Unsupported Monerium destination", status: httpStatus.BAD_REQUEST }); } if (issueFeeEur === undefined) { @@ -142,8 +143,7 @@ const flowDefinitions: FlowDefinition[] = [ request.from === EPaymentMethod.SEPA && request.inputCurrency === FiatToken.EURC && network !== undefined && - network !== Networks.Polygon && - isNetworkEVM(network) && + doesNetworkSupportEurOnramp(network) && isEvmToken(request.outputCurrency) && evmTokenConfig[network][request.outputCurrency] !== undefined ); diff --git a/apps/api/src/api/services/quote/eur-onramp-network.test.ts b/apps/api/src/api/services/quote/eur-onramp-network.test.ts new file mode 100644 index 000000000..6e42e8545 --- /dev/null +++ b/apps/api/src/api/services/quote/eur-onramp-network.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, QuoteError, RampDirection } from "@vortexfi/shared"; +import { QuoteService } from "."; + +const request = { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Polygon, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Polygon +}; + +describe("EUR onramp destination rejection", () => { + it("returns a public bad request for a Polygon destination", async () => { + await expect(new QuoteService().createQuote(request)).rejects.toMatchObject({ + isPublic: true, + message: QuoteError.EurOnrampNetworkUnsupported, + status: 400 + }); + }); + + it("returns the same bad request for an AssetHub destination", async () => { + await expect( + new QuoteService().createQuote({ ...request, network: Networks.AssetHub, to: Networks.AssetHub }) + ).rejects.toMatchObject({ isPublic: true, message: QuoteError.EurOnrampNetworkUnsupported, status: 400 }); + }); +}); diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 46d348d3d..b841674e0 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -3,6 +3,7 @@ import { CreateBestQuoteRequest, CreateQuoteRequest, DestinationType, + doesNetworkSupportEurOnramp, EvmToken, FiatToken, getNetworkFromDestination, @@ -55,6 +56,7 @@ export class QuoteService extends BaseRampService { } ): Promise { assertEurOfframpSupported(request); + assertEurOnrampNetworkSupported(request); return this.executeQuoteCalculation(request); } @@ -300,6 +302,22 @@ function assertEurOfframpSupported(request: Pick): void { + const network = getNetworkFromDestination(request.to); + if ( + request.rampType === RampDirection.BUY && + request.inputCurrency === FiatToken.EURC && + network !== undefined && + !doesNetworkSupportEurOnramp(network) + ) { + throw new APIError({ isPublic: true, message: QuoteError.EurOnrampNetworkUnsupported, status: httpStatus.BAD_REQUEST }); + } +} + function mapAlfredpayLimitErrorToApiError(error: AlfredpayTradeLimitError, isOnramp: boolean): APIError { const prefix = selectAlfredpayLimitPrefix(error.kind === "above", isOnramp); return new APIError({ diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 91b7b312c..f5c7115be 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -1342,7 +1342,7 @@ PriceResponseBase: { totalFee: number; } -QuoteError: enum QuoteError { AboveUpperLimitBuy = "Input amount exceeds maximum BUY limit of", AboveUpperLimitSell = "Output amount exceeds maximum SELL limit of", AnchorTemporarilyUnavailable = "This payment provider is temporarily unavailable. Please try again in a few minutes.", AssetHubNotSupportedForAlfredPay = "AssetHub is not supported for this currency. Please select a different network.", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", BelowLowerLimitSell = "Output amount below minimum SELL limit of", FailedToCalculateFeeComponents = "Failed to calculate fee components", FailedToCalculatePreNablaDeductibleFees = "Failed to calculate pre-Nabla deductible fees", FailedToCalculateQuote = "Failed to calculate the quote. Please try a lower amount.", InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", InputAmountTooLowToCoverFees = "Input amount too low to cover fees", InvalidNetworks = "Invalid 'networks' value: must be an array of valid network identifiers", InvalidRampType = "Invalid ramp type, must be \"BUY\" or \"SELL\"", LowLiquidity = "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", MissingFromField = "BUY rampType requires 'from' parameter", MissingRequiredFields = "Missing required fields", MissingToField = "SELL rampType requires 'to' parameter", NetworkFeesTooHigh = "Destination network fees are temporarily too high. Please try again later.", QuoteNotFound = "Quote not found", UnableToGetPendulumTokenDetails = "Unable to get Pendulum token details", UnsupportedCurrency = "Currency not supported" } +QuoteError: enum QuoteError { AboveUpperLimitBuy = "Input amount exceeds maximum BUY limit of", AboveUpperLimitSell = "Output amount exceeds maximum SELL limit of", AnchorTemporarilyUnavailable = "This payment provider is temporarily unavailable. Please try again in a few minutes.", AssetHubNotSupportedForAlfredPay = "AssetHub is not supported for this currency. Please select a different network.", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", BelowLowerLimitSell = "Output amount below minimum SELL limit of", EurOnrampNetworkUnsupported = "EUR pay-ins are not available on this network yet. Please select a different network.", FailedToCalculateFeeComponents = "Failed to calculate fee components", FailedToCalculatePreNablaDeductibleFees = "Failed to calculate pre-Nabla deductible fees", FailedToCalculateQuote = "Failed to calculate the quote. Please try a lower amount.", InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", InputAmountTooLowToCoverFees = "Input amount too low to cover fees", InvalidNetworks = "Invalid 'networks' value: must be an array of valid network identifiers", InvalidRampType = "Invalid ramp type, must be \"BUY\" or \"SELL\"", LowLiquidity = "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", MissingFromField = "BUY rampType requires 'from' parameter", MissingRequiredFields = "Missing required fields", MissingToField = "SELL rampType requires 'to' parameter", NetworkFeesTooHigh = "Destination network fees are temporarily too high. Please try again later.", QuoteNotFound = "Quote not found", UnableToGetPendulumTokenDetails = "Unable to get Pendulum token details", UnsupportedCurrency = "Currency not supported" } QuoteFeeStructure: { anchor: string; diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 497f7b8a4..07322a357 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -94,7 +94,7 @@ recovery. 11. Redemption orders of EUR 15,000 or more MUST include `supportingDocumentId`. Uploads MUST remain PDF/JPEG, at most 5 MB, with filenames no longer than 100 characters. 12. Webhook subscription secrets MUST contain 24-64 random bytes encoded as documented, callback URLs MUST use HTTPS, and event types MUST stay within the consumed Monerium enum. 13. Live contract mutations MUST target exactly `https://api.monerium.dev` and remain independently opt-in. An order contract test MUST NOT run from credentials alone because it can move sandbox EURe. -14. New SEPA/EUR BUY quotes MUST resolve only to the Polygon Monerium flow. New EUR SELL quotes MUST return a public `400` and MUST NOT fall back to a Mykobo flow. +14. New SEPA/EUR BUY quotes MUST resolve only to the Polygon Monerium flow. A direct EUR BUY quote for a destination that flow cannot serve (Polygon itself or a non-EVM network, `doesNetworkSupportEurOnramp`) MUST return the public `400` `QuoteError.EurOnrampNetworkUnsupported`; the dashboard and widget pickers MUST NOT offer those destinations for EUR. New EUR SELL quotes MUST return a public `400` and MUST NOT fall back to a Mykobo flow. 15. Production startup MUST fail without a Monerium auth-code client ID, exact callback URI, `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET`, and explicit non-negative `MONERIUM_ISSUE_FEE_EUR`. The issue fee MUST NOT silently default to zero. Credentials MUST NOT be accepted from client requests. 16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's canonical legal entity and its `monerium`/`eur` provider-customer binding, and MUST read that profile through the white-label app first and, only when the white-label API answers `403` or `404` for it, through the user's backend-held OAuth token (`resolveMoneriumIdentity`). A missing binding MUST fail with `MONERIUM_ONBOARDING_REQUIRED`; a missing or rejected OAuth session MUST fail with `MONERIUM_REAUTHENTICATION_REQUIRED`; any other white-label failure MUST NOT switch apps. The live profile MUST be `approved`. Which app served the profile is logged, never persisted. Registration MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. 17. Self-transfer registration MUST copy only owner, token, chain, and amount from trusted `monerium-issue` facts and MUST reject an owner that is also the EVM ephemeral. Its EURe permit and exact `transferFrom` MUST be independently validated and reconciled; strict presign completeness MUST require both the user-signed permit and ephemeral-signed transfer. A still-current permit MUST be consumed even when allowance already exists, while an advanced nonce or expired deadline may prove it non-replayable. Permit and transfer hashes MUST remain in namespaced block state, and successful execution MUST verify receipts and the exact allowance reduction. diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 34269b4b7..f505c72ba 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -120,6 +120,7 @@ export enum QuoteError { // Compatibility errors AssetHubNotSupportedForAlfredPay = "AssetHub is not supported for this currency. Please select a different network.", + EurOnrampNetworkUnsupported = "EUR pay-ins are not available on this network yet. Please select a different network.", // Token/calculation errors UnableToGetPendulumTokenDetails = "Unable to get Pendulum token details", diff --git a/packages/shared/src/helpers/networks.ts b/packages/shared/src/helpers/networks.ts index 55f70230a..ced2bbe3c 100644 --- a/packages/shared/src/helpers/networks.ts +++ b/packages/shared/src/helpers/networks.ts @@ -155,6 +155,14 @@ export function isNetworkEVM(network: Networks): network is EvmNetworks { return getNetworkMetadata(network)?.isEVM ?? false; } +/** + * The EUR onramp mints EURe on Polygon and bridges to the destination, so it delivers to every + * EVM network except Polygon itself (no same-chain Polygon flow exists yet). + */ +export function doesNetworkSupportEurOnramp(network: Networks): network is EvmNetworks { + return isNetworkEVM(network) && network !== Networks.Polygon; +} + export function isNetworkAssetHub(network: Networks): network is Networks.AssetHub { return getNetworkMetadata(network)?.id === ASSETHUB_CHAIN_ID; } From 6132997509e1384874823bc810d3bf02f0a87f92 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:44:10 +0200 Subject: [PATCH 14/42] fix(dashboard): hide destinations the EUR onramp cannot serve The onramp form and quote explorer offered (and defaulted to) Polygon for EUR pay-ins, which the API rejects. --- .../src/components/quote/QuoteExplorer.tsx | 2 +- .../src/components/transfer/OnrampForm.tsx | 11 +++++++++-- apps/dashboard/src/domain/onramp.test.ts | 10 ++++++++++ apps/dashboard/src/domain/onramp.ts | 13 ++++++++++--- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/components/quote/QuoteExplorer.tsx b/apps/dashboard/src/components/quote/QuoteExplorer.tsx index 4567847a0..49c606e76 100644 --- a/apps/dashboard/src/components/quote/QuoteExplorer.tsx +++ b/apps/dashboard/src/components/quote/QuoteExplorer.tsx @@ -71,7 +71,7 @@ export function QuoteExplorer() { // token. The corridor needs no reconciliation — every corridor quotes in both directions. const corridor = CORRIDORS[corridorId]; - const networkOptions = getNetworkOptions(tokenOptions); + const networkOptions = getNetworkOptions(tokenOptions, isBuy ? corridorId : undefined); // Before the token list loads there are no options, and the requested network still labels the chip. const activeNetwork = networkOptions.find(option => option.id === requestedNetwork) ?? networkOptions[0] ?? { id: requestedNetwork, label: requestedNetwork }; diff --git a/apps/dashboard/src/components/transfer/OnrampForm.tsx b/apps/dashboard/src/components/transfer/OnrampForm.tsx index b732ca965..0e495895c 100644 --- a/apps/dashboard/src/components/transfer/OnrampForm.tsx +++ b/apps/dashboard/src/components/transfer/OnrampForm.tsx @@ -54,7 +54,6 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot, () => false); const tokenOptions = getRampTokenOptions(RampDirection.BUY); const corridors = ONRAMP_CORRIDORS.filter(corridorId => approved.has(corridorId)); - const networkOptions = getNetworkOptions(tokenOptions); // Prefilled values are trusted only while the token list and onboarding status are still // loading, when the option lists they'd be validated against are empty; the reconciliation // effects below snap them to a valid option once those lists resolve. @@ -63,7 +62,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi amount: prefill?.amount ?? "", corridorId: prefill?.corridorId ?? corridors[0] ?? "", destinationAddress: address ?? "", - network: prefill?.network ?? networkOptions[0]?.id ?? "polygon", + network: prefill?.network ?? getNetworkOptions(tokenOptions, prefill?.corridorId)[0]?.id ?? "polygon", // Left empty on purpose — the reconciliation effect below is the single place that resolves it. outputCurrency: "" }, @@ -74,6 +73,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi const corridorId = form.watch("corridorId") as CorridorId; const amount = form.watch("amount"); const outputCurrency = form.watch("outputCurrency"); + const networkOptions = getNetworkOptions(tokenOptions, corridorId); const networkTokens = tokenOptions.filter(option => option.network === network); useEffect(() => { @@ -91,6 +91,13 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi } }, [corridors, form, isLoadingApprovals]); + useEffect(() => { + const fallback = networkOptions[0]; + if (fallback && !networkOptions.some(option => option.id === network)) { + form.setValue("network", fallback.id); + } + }, [form, network, networkOptions]); + useEffect(() => { if (!networkTokens.some(option => option.currency === outputCurrency)) { const preferred = networkTokens.find(option => option.currency === prefill?.token); diff --git a/apps/dashboard/src/domain/onramp.test.ts b/apps/dashboard/src/domain/onramp.test.ts index ac377051f..9abe58d71 100644 --- a/apps/dashboard/src/domain/onramp.test.ts +++ b/apps/dashboard/src/domain/onramp.test.ts @@ -87,6 +87,16 @@ describe("getNetworkOptions", () => { it("returns nothing while the token list is still empty", () => { assert.deepEqual(getNetworkOptions([]), []); }); + + it("drops Polygon for the EU corridor, whose onramp mints there and bridges onward", () => { + const tokens = [option("USDC", "Polygon", true), option("USDC", "Base", true, Networks.Base)]; + + assert.deepEqual(getNetworkOptions(tokens, "EU"), [{ id: Networks.Base, label: "Base" }]); + assert.deepEqual(getNetworkOptions(tokens, "BR"), [ + { id: Networks.Base, label: "Base" }, + { id: Networks.Polygon, label: "Polygon" } + ]); + }); }); describe("eurOnrampBlocker", () => { diff --git a/apps/dashboard/src/domain/onramp.ts b/apps/dashboard/src/domain/onramp.ts index fb34d54f7..a5728e467 100644 --- a/apps/dashboard/src/domain/onramp.ts +++ b/apps/dashboard/src/domain/onramp.ts @@ -1,5 +1,6 @@ import type { MoneriumRampReadiness } from "@vortexfi/kyc"; import { + doesNetworkSupportEurOnramp, doesNetworkSupportRamp, type EvmNetworks, EvmToken, @@ -45,10 +46,16 @@ export interface NetworkOption { label: string; } -/** The distinct networks the given tokens live on, alphabetical by display name. */ -export function getNetworkOptions(options: RampTokenOption[]): NetworkOption[] { +/** + * The distinct networks the given tokens live on, alphabetical by display name. A pay-in corridor + * narrows them to the destinations its flow can serve (EUR mints on Polygon and bridges onward). + */ +export function getNetworkOptions(options: RampTokenOption[], corridorId?: CorridorId): NetworkOption[] { const labelByNetwork = new Map(options.map(option => [option.network, option.networkLabel])); - return [...labelByNetwork].map(([id, label]) => ({ id, label })).sort((a, b) => a.label.localeCompare(b.label)); + return [...labelByNetwork] + .filter(([id]) => corridorId !== "EU" || doesNetworkSupportEurOnramp(id)) + .map(([id, label]) => ({ id, label })) + .sort((a, b) => a.label.localeCompare(b.label)); } export function sortRampTokenOptions(options: RampTokenOption[]): RampTokenOption[] { From 6b8ab694bc51f8a1106e82c9ecddd2793bbd36a0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:44:11 +0200 Subject: [PATCH 15/42] fix(frontend): hide Polygon for EUR pay-ins and translate the quote rejection --- .../TokenSelectionList/helpers.tsx | 21 ++++++++++++++----- .../src/stores/quote/useQuoteStore.ts | 1 + apps/frontend/src/translations/en.json | 1 + apps/frontend/src/translations/pt.json | 1 + 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index f2928d113..c06dfc347 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -1,5 +1,6 @@ import { assetHubTokenConfig, + doesNetworkSupportEurOnramp, doesNetworkSupportRamp, EvmNetworks, FiatToken, @@ -19,6 +20,7 @@ import { import { useMemo } from "react"; import { isFrontendNetworkEnabled } from "../../../config/networkAvailability"; import { getEvmTokenConfig } from "../../../services/tokens"; +import { useFiatToken } from "../../../stores/quote/useQuoteFormStore"; import { useRampDirection } from "../../../stores/rampDirectionStore"; import { useTokenSelectionState } from "../../../stores/tokenSelectionStore"; import { ExtendedTokenDefinition } from "./hooks/useTokenSelection"; @@ -26,10 +28,11 @@ import { ExtendedTokenDefinition } from "./hooks/useTokenSelection"; export function useTokenDefinitions(filter: string, selectedNetworkFilter: Networks | "all") { const { tokenSelectModalType } = useTokenSelectionState(); const rampDirection = useRampDirection(); + const fiatToken = useFiatToken(); const allDefinitions = useMemo( - () => getAllSupportedTokenDefinitions(tokenSelectModalType, rampDirection), - [tokenSelectModalType, rampDirection] + () => getAllSupportedTokenDefinitions(tokenSelectModalType, rampDirection, fiatToken), + [tokenSelectModalType, rampDirection, fiatToken] ); const availableNetworks = useMemo(() => { @@ -171,10 +174,18 @@ function isFiatDirection(type: "from" | "to", direction: RampDirection) { return (isBuy && type === "from") || (!isBuy && type === "to"); } -function getAllSupportedTokenDefinitions(type: "from" | "to", direction: RampDirection): ExtendedTokenDefinition[] { +function getAllSupportedTokenDefinitions( + type: "from" | "to", + direction: RampDirection, + fiatToken: FiatToken +): ExtendedTokenDefinition[] { if (isFiatDirection(type, direction)) { return getFiatTokens(); - } else { - return getAllOnChainTokens(); } + const onChainTokens = getAllOnChainTokens(); + // The EUR onramp mints on Polygon and bridges onward, so Polygon is not a destination it can serve. + if (direction === RampDirection.BUY && fiatToken === FiatToken.EURC) { + return onChainTokens.filter(token => doesNetworkSupportEurOnramp(token.network)); + } + return onChainTokens; } diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 30f2f27cb..f21eaddbe 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -57,6 +57,7 @@ const friendlyErrorMessages: Record = { [QuoteError.InvalidNetworks]: "pages.swap.error.invalidNetworks", [QuoteError.QuoteNotFound]: "pages.swap.error.quoteNotFound", [QuoteError.AssetHubNotSupportedForAlfredPay]: "pages.swap.error.assetHubNotSupportedForAlfredPay", + [QuoteError.EurOnrampNetworkUnsupported]: "pages.swap.error.eurOnrampNetworkUnsupported", // Amount too low - suggest larger amount [QuoteError.InputAmountTooLowToCoverFees]: "pages.swap.error.tryLargerAmount", diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 440cdf9cb..c5b7fc59f 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1597,6 +1597,7 @@ "BRL_tokenUnavailable": "Improving your BRL exit - back shortly! ", "COP_tokenUnavailable": "Building your COP rail - available soon!", "EURC_tokenUnavailable": "Improving your EUR exit - back shortly! ", + "eurOnrampNetworkUnsupported": "EUR pay-ins are not available on this network yet. Please select a different network.", "feeComponents": "Failed to calculate the fees. Please try again with a different amount.", "fetchingQuote": "Failed to get quote", "gasWarning": "Please choose a smaller amount to ensure you can pay the gas cost of the transaction.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 8d68cb4ee..32198ea40 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1601,6 +1601,7 @@ "BRL_tokenUnavailable": "Ajustando sua saída BRL - em breve!", "COP_tokenUnavailable": "Construa seu trilho COP - disponível em breve!", "EURC_tokenUnavailable": "Ajustando sua saída EUR - em breve!", + "eurOnrampNetworkUnsupported": "Pagamentos em EUR ainda não estão disponíveis nesta rede. Selecione outra rede.", "feeComponents": "Falha ao calcular as taxas. Por favor, tente novamente com um valor diferente.", "fetchingQuote": "Falha ao obter cotação", "gasWarning": "Escolha um valor menor para garantir que você consiga pagar o custo do gás da transação.", From 6e0721168bf5fe8173e1ca054cb8ca1febf448c9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:44:11 +0200 Subject: [PATCH 16/42] fix(dashboard): let an approved Monerium row win over a legacy Mykobo row Both providers map to the EU corridor and merged by status rank alone, so a pre-Monerium user's approved Mykobo row could shadow the Monerium row and its wallet readiness, asking for a wallet link forever. deriveOnboardings moves into a pure mappers module so it can be unit-tested without the transfer actor's localStorage bootstrap. --- apps/dashboard/src/hooks/useActiveAccount.ts | 51 +----------------- .../services/api/onboarding.mappers.test.ts | 48 +++++++++++++++++ .../src/services/api/onboarding.mappers.ts | 53 +++++++++++++++++++ 3 files changed, 103 insertions(+), 49 deletions(-) create mode 100644 apps/dashboard/src/services/api/onboarding.mappers.test.ts create mode 100644 apps/dashboard/src/services/api/onboarding.mappers.ts diff --git a/apps/dashboard/src/hooks/useActiveAccount.ts b/apps/dashboard/src/hooks/useActiveAccount.ts index f2f98c406..265452362 100644 --- a/apps/dashboard/src/hooks/useActiveAccount.ts +++ b/apps/dashboard/src/hooks/useActiveAccount.ts @@ -1,57 +1,10 @@ import { useMemo } from "react"; -import type { AccountType, CorridorId, Onboarding, OnboardingStatus, SenderAccount } from "@/domain/types"; -import type { OnboardingEntityDto, OnboardingState } from "@/services/api/onboarding.service"; -import { corridorFromProviderAccount } from "@/services/api/recipient.mappers"; +import type { AccountType, CorridorId, SenderAccount } from "@/domain/types"; +import { deriveOnboardings } from "@/services/api/onboarding.mappers"; import { useAuthStore } from "@/stores/auth.store"; import { useManagedProfileSelection } from "@/stores/managed-profile.store"; import { useOnboardingStatusQuery } from "./useApprovedCorridors"; -const STATE_TO_STATUS: Record = { - approved: "approved", - in_review: "in_review", - pending: "pending", - rejected: "rejected", - started: "started" -}; - -const MONERIUM_REAUTHENTICATION_REQUIRED = "MONERIUM_REAUTHENTICATION_REQUIRED"; - -// When a corridor has several provider accounts, surface the furthest-along one. -const STATUS_RANK: Record = { - approved: 5, - in_review: 4, - not_started: 0, - pending: 3, - rejected: 1, - started: 2 -}; - -function deriveOnboardings(entity: OnboardingEntityDto, type: AccountType): Partial> { - const kind = type === "company" ? "kyb" : "kyc"; - const onboardings: Partial> = {}; - for (const account of entity.accounts) { - const corridorId = corridorFromProviderAccount(account); - if (!corridorId) { - continue; - } - const status = STATE_TO_STATUS[account.state]; - const existing = onboardings[corridorId]; - if (!existing || STATUS_RANK[status] > STATUS_RANK[existing.status]) { - onboardings[corridorId] = { - companyName: account.companyName, - corridorId, - kind, - ramp: account.ramp ?? null, - reauthenticationRequired: account.error?.code === MONERIUM_REAUTHENTICATION_REQUIRED, - status, - taxReference: account.taxReference, - updatedAt: new Date().toISOString() - }; - } - } - return onboardings; -} - /** * The authenticated sender account, derived from the Supabase session (identity) and * GET /v1/onboarding/status (type + per-corridor status). No seed data — undefined until diff --git a/apps/dashboard/src/services/api/onboarding.mappers.test.ts b/apps/dashboard/src/services/api/onboarding.mappers.test.ts new file mode 100644 index 000000000..fcfd2e31f --- /dev/null +++ b/apps/dashboard/src/services/api/onboarding.mappers.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { OnboardingAccountDto, OnboardingEntityDto } from "./onboarding.service"; +import { deriveOnboardings } from "./onboarding.mappers"; + +const ramp = { chain: "polygon", iban: "provisioned" as const, linkedAddress: "0xabc", source: "oauth" as const }; + +function account(provider: "monerium" | "mykobo", extra: Partial = {}): OnboardingAccountDto { + return { + companyName: null, + country: null, + customerType: "individual", + error: null, + id: `account-${provider}`, + kycCase: null, + provider, + rail: "eur", + ramp: null, + state: "approved", + status: "approved", + statusExternal: "approved", + taxReference: null, + ...extra + }; +} + +function entity(accounts: OnboardingAccountDto[]): OnboardingEntityDto { + return { accounts, id: "entity-1", status: "active", type: "individual" }; +} + +describe("deriveOnboardings", () => { + it("lets an approved Monerium row win over a legacy approved Mykobo row regardless of order", () => { + for (const accounts of [ + [account("mykobo"), account("monerium", { ramp })], + [account("monerium", { ramp }), account("mykobo")] + ]) { + const eu = deriveOnboardings(entity(accounts), "individual").EU; + assert.equal(eu?.status, "approved"); + assert.deepEqual(eu?.ramp, ramp); + } + }); + + it("still surfaces the furthest-along row when the providers differ in status", () => { + const eu = deriveOnboardings(entity([account("mykobo"), account("monerium", { state: "pending", status: "pending" })]), "individual") + .EU; + assert.equal(eu?.status, "approved"); + }); +}); diff --git a/apps/dashboard/src/services/api/onboarding.mappers.ts b/apps/dashboard/src/services/api/onboarding.mappers.ts new file mode 100644 index 000000000..83148d00b --- /dev/null +++ b/apps/dashboard/src/services/api/onboarding.mappers.ts @@ -0,0 +1,53 @@ +import type { AccountType, CorridorId, Onboarding, OnboardingStatus } from "@/domain/types"; +import type { OnboardingEntityDto, OnboardingState } from "./onboarding.service"; +import { corridorFromProviderAccount } from "./recipient.mappers"; + +const STATE_TO_STATUS: Record = { + approved: "approved", + in_review: "in_review", + pending: "pending", + rejected: "rejected", + started: "started" +}; + +const MONERIUM_REAUTHENTICATION_REQUIRED = "MONERIUM_REAUTHENTICATION_REQUIRED"; + +// When a corridor has several provider accounts, surface the furthest-along one. +const STATUS_RANK: Record = { + approved: 5, + in_review: 4, + not_started: 0, + pending: 3, + rejected: 1, + started: 2 +}; + +export function deriveOnboardings(entity: OnboardingEntityDto, type: AccountType): Partial> { + const kind = type === "company" ? "kyb" : "kyc"; + const onboardings: Partial> = {}; + for (const account of entity.accounts) { + const corridorId = corridorFromProviderAccount(account); + if (!corridorId) { + continue; + } + const status = STATE_TO_STATUS[account.state]; + const existing = onboardings[corridorId]; + const rank = STATUS_RANK[status]; + const existingRank = existing ? STATUS_RANK[existing.status] : -1; + // On a tie the live EU provider wins: a legacy approved Mykobo row carries no wallet + // readiness, so letting it shadow the Monerium row would ask for a wallet link forever. + if (rank > existingRank || (rank === existingRank && account.provider === "monerium")) { + onboardings[corridorId] = { + companyName: account.companyName, + corridorId, + kind, + ramp: account.ramp ?? null, + reauthenticationRequired: account.error?.code === MONERIUM_REAUTHENTICATION_REQUIRED, + status, + taxReference: account.taxReference, + updatedAt: new Date().toISOString() + }; + } + } + return onboardings; +} From 959ec28f72b301e1431b24611782780176a4a0d5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:46:38 +0200 Subject: [PATCH 17/42] fix(kyc): route a lost Monerium session back to authorization and allow closing while redirecting GET /v1/monerium/status answers 200 with rampError for a persisted approval whose backend OAuth session is gone, so the approved branch swallowed the reconnect need and the widget looped between wallet linking and the quote. Redirecting also ignored CLOSE, which the widget's cancel button sends. --- packages/kyc/src/index.ts | 1 + packages/kyc/src/monerium/machine.test.ts | 38 ++++++++++++++++++++++- packages/kyc/src/monerium/machine.ts | 12 +++++-- packages/kyc/src/monerium/types.ts | 3 ++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/kyc/src/index.ts b/packages/kyc/src/index.ts index ec3b82f76..5e24a71d5 100644 --- a/packages/kyc/src/index.ts +++ b/packages/kyc/src/index.ts @@ -59,6 +59,7 @@ export type { MoneriumWalletLinkResult } from "./monerium/types"; export { + MONERIUM_REAUTHENTICATION_REQUIRED, MoneriumAuthorizationRequiredError, type MoneriumCustomerType, type MoneriumKycContext, diff --git a/packages/kyc/src/monerium/machine.test.ts b/packages/kyc/src/monerium/machine.test.ts index de3f59094..0f2760af6 100644 --- a/packages/kyc/src/monerium/machine.test.ts +++ b/packages/kyc/src/monerium/machine.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { createActor, waitFor } from "xstate"; import type { MoneriumKycApi } from "./api"; import { createMoneriumKycMachine } from "./machine"; -import { MoneriumAuthorizationRequiredError, type MoneriumStatusResponse } from "./types"; +import { MONERIUM_REAUTHENTICATION_REQUIRED, MoneriumAuthorizationRequiredError, type MoneriumStatusResponse } from "./types"; const approved: MoneriumStatusResponse = { customerType: "individual", @@ -132,4 +132,40 @@ describe("moneriumKycMachine", () => { await waitFor(actor, snapshot => snapshot.matches("Ready")); expect(actor.getSnapshot().context.authorizationUrl).toBe("https://example.com/auth"); }); + + it("routes a persisted approval whose OAuth session is gone back to authorization", async () => { + const machine = machineWith({ + completeOAuth: async () => approved, + getStatus: async () => ({ + ...approved, + rampError: { code: MONERIUM_REAUTHENTICATION_REQUIRED, message: "Monerium reauthentication is required" } + }), + startOAuth: async () => ({ authorizationUrl: "https://example.com/auth" }) + }); + const actor = createActor(machine, { input: { customerType: "individual" } }).start(); + + await waitFor(actor, snapshot => snapshot.matches("Ready")); + expect(actor.getSnapshot().context.rampError?.code).toBe(MONERIUM_REAUTHENTICATION_REQUIRED); + }); + + it("lets the user close the flow while the authorization tab is open", async () => { + const machine = machineWith( + { + completeOAuth: async () => approved, + getStatus: async () => { + throw new MoneriumAuthorizationRequiredError(); + }, + startOAuth: async () => ({ authorizationUrl: "https://example.com/auth" }) + }, + () => undefined + ); + const actor = createActor(machine, { input: { customerType: "individual" } }).start(); + await waitFor(actor, snapshot => snapshot.matches("Ready")); + actor.send({ type: "START_OAUTH" }); + await waitFor(actor, snapshot => snapshot.matches("Redirecting")); + + actor.send({ type: "CLOSE" }); + + await waitFor(actor, snapshot => snapshot.matches("Done")); + }); }); diff --git a/packages/kyc/src/monerium/machine.ts b/packages/kyc/src/monerium/machine.ts index 769be9bd1..ba256dbff 100644 --- a/packages/kyc/src/monerium/machine.ts +++ b/packages/kyc/src/monerium/machine.ts @@ -1,7 +1,7 @@ import { assign, type DoneActorEvent, fromPromise, setup } from "xstate"; import type { MoneriumKycDeps } from "./api"; import type { MoneriumKycContext, MoneriumKycInput, MoneriumKycOutput, MoneriumStatusResponse } from "./types"; -import { MoneriumAuthorizationRequiredError } from "./types"; +import { MONERIUM_REAUTHENTICATION_REQUIRED, MoneriumAuthorizationRequiredError } from "./types"; function errorFrom(value: unknown): Error { return value instanceof Error ? value : new Error("Monerium onboarding failed"); @@ -39,6 +39,9 @@ export function createMoneriumKycMachine({ api, client, openAuthorizationUrl }: callbackHasError: ({ context }) => !!context.callback && "error" in context.callback, isApproved: ({ event }) => statusOutput(event).status === "APPROVED", isRejected: ({ event }) => statusOutput(event).status === "REJECTED", + // A persisted approval stays readable after the backend's OAuth session is gone; the ramp + // readiness read then needs a fresh authorization, so route back to the OAuth start. + needsReauthentication: ({ event }) => statusOutput(event).rampError?.code === MONERIUM_REAUTHENTICATION_REQUIRED, needsUserAction: ({ event }) => ["created", "incomplete"].includes(statusOutput(event).statusExternal.toLowerCase()) }, types: { @@ -60,6 +63,11 @@ export function createMoneriumKycMachine({ api, client, openAuthorizationUrl }: invoke: { input: ({ context }) => ({ customerType: context.customerType }), onDone: [ + { + actions: "storeStatus", + guard: "needsReauthentication", + target: "Ready" + }, { actions: "storeStatus", guard: "isApproved", @@ -125,7 +133,7 @@ export function createMoneriumKycMachine({ api, client, openAuthorizationUrl }: Redirecting: { entry: "openAuthorization", // A client that could only open the authorization in another tab re-checks on request. - on: { REFRESH: { target: "CheckingStatus" } } + on: { CLOSE: { target: "Done" }, REFRESH: { target: "CheckingStatus" } } }, Rejected: { on: { CLOSE: { target: "Done" }, RETRY: { target: "Ready" } } diff --git a/packages/kyc/src/monerium/types.ts b/packages/kyc/src/monerium/types.ts index d5dd5d8bf..840b2957e 100644 --- a/packages/kyc/src/monerium/types.ts +++ b/packages/kyc/src/monerium/types.ts @@ -2,6 +2,9 @@ export type MoneriumCustomerType = "business" | "individual"; export type MoneriumKycStatus = "APPROVED" | "PENDING" | "REJECTED"; export type MoneriumOAuthClient = "dashboard" | "widget"; + +/** `rampError.code` reported by GET /v1/monerium/status when the backend's OAuth session is gone. */ +export const MONERIUM_REAUTHENTICATION_REQUIRED = "MONERIUM_REAUTHENTICATION_REQUIRED"; export type MoneriumIbanReadiness = "provisioned" | "elsewhere" | "missing"; /** EUR onramp readiness of an approved profile, measured against the chain the onramp mints on. */ From 771e4ac7d985ec15e96727c076f145d4789c198c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:46:39 +0200 Subject: [PATCH 18/42] fix(frontend): bound the Monerium IBAN wait and keep the OAuth panel escapable The wallet flow polled provisioning forever behind a spinner with no cancel; the redirect panel had no way to open Monerium when a popup was blocked, and a reconnect after session loss showed first-verification copy. --- .../components/Monerium/MoneriumKycFlow.tsx | 20 ++++++++++++++++--- .../Monerium/MoneriumWalletFlow.tsx | 10 +++++++++- .../machines/moneriumWallet.machine.test.ts | 19 ++++++++++++++++++ .../src/machines/moneriumWallet.machine.ts | 17 ++++++++++++++++ apps/frontend/src/translations/en.json | 5 +++++ apps/frontend/src/translations/pt.json | 5 +++++ 6 files changed, 72 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx b/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx index fb35c1530..febb09a15 100644 --- a/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx +++ b/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx @@ -14,16 +14,23 @@ const LoadingPanel = ({ message }: { message: string }) => ( interface ActionPanelProps { title: string; description: string; + /** Plain link rendered above the buttons; a user gesture on it is never popup-blocked. */ + link?: { href: string; label: string }; primaryLabel: string; onPrimary: () => void; secondaryLabel: string; onSecondary: () => void; } -const ActionPanel = ({ title, description, primaryLabel, onPrimary, secondaryLabel, onSecondary }: ActionPanelProps) => ( +const ActionPanel = ({ title, description, link, primaryLabel, onPrimary, secondaryLabel, onSecondary }: ActionPanelProps) => (

{title}

{description}

+ {link && ( + + {link.label} + + )} @@ -60,14 +67,16 @@ export const MoneriumKycFlow = () => { } if (stateValue === "Ready") { + // An approved profile whose backend OAuth session is gone lands here too; it needs a reconnect, not a first verification. + const copy = context.rampError ? "reconnect" : "ready"; return ( ); } @@ -76,6 +85,11 @@ export const MoneriumKycFlow = () => { return ( { return ; } if (stateValue === "Waiting") { - return ; + return ( +
+

{t("components.moneriumWalletFlow.waiting")}

+ + +
+ ); } if (stateValue === "Moving") { return ; diff --git a/apps/frontend/src/machines/moneriumWallet.machine.test.ts b/apps/frontend/src/machines/moneriumWallet.machine.test.ts index b1be45d7c..cf99c9ab2 100644 --- a/apps/frontend/src/machines/moneriumWallet.machine.test.ts +++ b/apps/frontend/src/machines/moneriumWallet.machine.test.ts @@ -68,6 +68,25 @@ describe("moneriumWalletMachine", () => { } }); + it("gives up on provisioning after the bounded number of polls", async () => { + vi.useFakeTimers(); + try { + const { api: client } = api([status({ iban: "missing", linkedAddress: ADDRESS })]); + const actor = createActor(createMoneriumWalletMachine(client), { input: input() }).start(); + await waitFor(actor, snapshot => snapshot.matches("Waiting")); + for (let poll = 0; poll < 36; poll += 1) { + await vi.advanceTimersByTimeAsync(5_000); + } + await waitFor(actor, snapshot => snapshot.matches("Failure")); + expect(actor.getSnapshot().context.error).toContain("not provisioned"); + actor.send({ type: "CANCEL" }); + await waitFor(actor, snapshot => snapshot.status === "done"); + expect(actor.getSnapshot().output?.ready).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it("asks before moving an IBAN that sits on another wallet", async () => { const { api: client, calls } = api([ status({ iban: "elsewhere", linkedAddress: ADDRESS }), diff --git a/apps/frontend/src/machines/moneriumWallet.machine.ts b/apps/frontend/src/machines/moneriumWallet.machine.ts index d936650b5..ccf9f3859 100644 --- a/apps/frontend/src/machines/moneriumWallet.machine.ts +++ b/apps/frontend/src/machines/moneriumWallet.machine.ts @@ -15,6 +15,8 @@ export interface MoneriumWalletContext extends MoneriumWalletInput { error?: string; /** Set once this wallet was linked in this flow, so readiness reads are interpreted for it. */ linked?: boolean; + /** Provisioning polls so far; the wait is bounded rather than spinning forever. */ + polls?: number; readiness?: MoneriumRampReadiness; } @@ -28,6 +30,8 @@ export type MoneriumWalletEvent = { type: "CANCEL" } | { type: "CONFIRM_MOVE" } type MoneriumWalletApiClient = Pick & MoneriumWalletApi; const POLL_INTERVAL_MS = 5_000; +/** Monerium usually provisions within seconds; after this many polls the user is told to come back later. */ +const MAX_PROVISIONING_POLLS = 36; function linkedHere(context: MoneriumWalletContext, readiness: MoneriumRampReadiness): boolean { return ( @@ -79,6 +83,10 @@ export function createMoneriumWalletMachine(api: MoneriumWalletApiClient = moner guards: { hasEvmWallet: ({ context }) => context.isEvmWallet && !!context.address, isPending: ({ context, event }) => readinessOf(event).iban === "missing" && linkedHere(context, readinessOf(event)), + isPendingTooLong: ({ context, event }) => + readinessOf(event).iban === "missing" && + linkedHere(context, readinessOf(event)) && + (context.polls ?? 0) >= MAX_PROVISIONING_POLLS, isReady: ({ context, event }) => { const readiness = readinessOf(event); return ( @@ -108,6 +116,14 @@ export function createMoneriumWalletMachine(api: MoneriumWalletApiClient = moner onDone: [ { actions: "storeReadiness", guard: "isReady", target: "Ready" }, { actions: "storeReadiness", guard: "needsMove", target: "NeedsMove" }, + { + actions: [ + "storeReadiness", + assign({ error: () => "Monerium has not provisioned your IBAN yet. Please try again later." }) + ], + guard: "isPendingTooLong", + target: "Failure" + }, { actions: "storeReadiness", guard: "isPending", target: "Waiting" }, { actions: "storeReadiness", target: "Linking" } ], @@ -160,6 +176,7 @@ export function createMoneriumWalletMachine(api: MoneriumWalletApiClient = moner }, Ready: { type: "final" }, Waiting: { + entry: assign({ polls: ({ context }) => (context.polls ?? 0) + 1 }), invoke: { onDone: { target: "Checking" }, src: "wait" }, on: { CANCEL: { actions: assign({ error: () => "IBAN provisioning was cancelled" }), target: "Cancelled" } } } diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index c5b7fc59f..04ae40e15 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -602,9 +602,14 @@ "description": "Monerium securely collects the information required for your EUR verification. You will return here when finished.", "title": "Verify with Monerium" }, + "reconnect": { + "description": "Your Monerium session has expired. Reconnect to continue with your EUR pay-in.", + "title": "Reconnect Monerium" + }, "redirecting": { "cancel": "Cancel", "description": "Complete the verification in the Monerium tab, then come back here.", + "open": "Open Monerium in a new tab if it did not open automatically", "refresh": "I have finished", "title": "Finish with Monerium" }, diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 32198ea40..16f3fbb62 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -605,9 +605,14 @@ "description": "A Monerium coleta com segurança as informações necessárias para sua verificação em EUR. Você voltará para cá ao terminar.", "title": "Verificar com a Monerium" }, + "reconnect": { + "description": "Sua sessão da Monerium expirou. Reconecte para continuar com seu pagamento em EUR.", + "title": "Reconectar à Monerium" + }, "redirecting": { "cancel": "Cancelar", "description": "Conclua a verificação na aba da Monerium e depois volte para cá.", + "open": "Abra a Monerium em uma nova aba caso ela não tenha aberto automaticamente", "refresh": "Já terminei", "title": "Concluir com a Monerium" }, From 59a10bd0012a14e48bdc28c784da356af16850e3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:47:39 +0200 Subject: [PATCH 19/42] fix(frontend): track the Monerium onramp phases on the progress ring EUR pay-ins were mapped to the retired Mykobo sequence, so five of the Monerium phases had no index and the ring sat at 0% or jumped. --- apps/frontend/src/pages/progress/index.tsx | 33 +--------- .../src/pages/progress/phaseFlows.test.ts | 60 +++++++++++++++++++ .../frontend/src/pages/progress/phaseFlows.ts | 52 +++++++++++++++- 3 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 apps/frontend/src/pages/progress/phaseFlows.test.ts diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index e3724506a..26f0a23b4 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -1,5 +1,5 @@ import { CheckIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid"; -import { FiatToken, isDomesticToken, isNetworkEVM, RampDirection, RampPhase } from "@vortexfi/shared"; +import { isNetworkEVM, RampPhase } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { motion } from "motion/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; @@ -12,38 +12,9 @@ import { useRampActor } from "../../contexts/rampState"; import { GotQuestions } from "../../sections/individuals/GotQuestions"; import { RampService } from "../../services/api"; import { RampState } from "../../types/phases"; -import { PHASE_DURATIONS, PHASE_FLOWS } from "./phaseFlows"; +import { getRampFlow, PHASE_DURATIONS, PHASE_FLOWS } from "./phaseFlows"; import { getMessageForPhase } from "./phaseMessages"; -function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS | null { - if (!rampState || !rampState.ramp) { - return null; - } - - const { type } = rampState.ramp; - - if (type === RampDirection.BUY) { - if (rampState.quote?.inputCurrency === FiatToken.BRL) { - return "onramp_brl"; - } - return "onramp_eur_evm"; - } - - if (rampState.quote?.outputCurrency === FiatToken.BRL) { - return "offramp_brl"; - } - - if (rampState.quote?.outputCurrency === FiatToken.EURC) { - return "offramp_eur_evm"; - } - - if (rampState.quote && isDomesticToken(rampState.quote.outputCurrency)) { - return "offramp_alfredpay"; - } - - return null; -} - const useProgressUpdate = ( currentPhase: RampPhase, currentPhaseIndex: number, diff --git a/apps/frontend/src/pages/progress/phaseFlows.test.ts b/apps/frontend/src/pages/progress/phaseFlows.test.ts new file mode 100644 index 000000000..a1ec18fe3 --- /dev/null +++ b/apps/frontend/src/pages/progress/phaseFlows.test.ts @@ -0,0 +1,60 @@ +import { EvmToken, FiatToken, Networks, RampPhase } from "@vortexfi/shared"; +import { describe, expect, it } from "vitest"; +import { buildQuoteResponse, buildRampProcess } from "../../test/fixtures"; +import { RampState } from "../../types/phases"; +import { getRampFlow, PHASE_FLOWS } from "./phaseFlows"; + +// The phases the API's MoneriumOnrampPolygonCrossChain flow emits, in order +// (apps/api/.../flows/monerium-onramp-polygon-cross-chain.ts). +const MONERIUM_ONRAMP_PHASES: RampPhase[] = [ + "initial", + "moneriumOnrampMint", + "fundEphemeral", + "moneriumOnrampSelfTransfer", + "uniswapApprove", + "uniswapSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +function buildRampState(phase: RampPhase, quoteOverrides: Parameters[0]): RampState { + const quote = buildQuoteResponse(quoteOverrides); + return { + quote, + ramp: buildRampProcess(phase, { + from: quote.from, + inputCurrency: quote.inputCurrency, + outputCurrency: quote.outputCurrency, + to: quote.to, + type: quote.rampType + }), + requiredUserActionsCompleted: true, + signedTransactions: [], + userSigningMeta: {} + }; +} + +describe("getRampFlow", () => { + it("routes an EUR pay-in to the Monerium sequence", () => { + const rampState = buildRampState("moneriumOnrampMint", { + inputCurrency: FiatToken.EURC, + outputCurrency: EvmToken.USDC, + to: Networks.Arbitrum + }); + + expect(getRampFlow(rampState)).toBe("onramp_eur_monerium"); + }); + + it("indexes every Monerium phase monotonically so the progress ring never rewinds", () => { + const sequence = PHASE_FLOWS.onramp_eur_monerium; + const indexes = MONERIUM_ONRAMP_PHASES.map(phase => sequence.indexOf(phase)); + + expect(indexes.every(index => index >= 0)).toBe(true); + expect(indexes).toEqual([...indexes].sort((a, b) => a - b)); + }); +}); diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index d8e99f4c7..a9e2485c5 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -1,4 +1,5 @@ -import { RampPhase } from "@vortexfi/shared"; +import { FiatToken, isDomesticToken, RampDirection, RampPhase } from "@vortexfi/shared"; +import { RampState } from "../../types/phases"; export const PHASE_DURATIONS: Record = { alfredOnrampMintFallback: 0, @@ -114,5 +115,54 @@ export const PHASE_FLOWS = { "distributeFees", "destinationTransfer", "complete" + ] as RampPhase[], + + // Mirrors the API's MoneriumOnrampPolygonCrossChain flow (monerium-onramp-polygon-cross-chain.ts). + onramp_eur_monerium: [ + "initial", + "moneriumOnrampMint", + "fundEphemeral", + "moneriumOnrampSelfTransfer", + "uniswapApprove", + "uniswapSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" ] as RampPhase[] }; + +export function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS | null { + if (!rampState || !rampState.ramp) { + return null; + } + + const { type } = rampState.ramp; + + if (type === RampDirection.BUY) { + if (rampState.quote?.inputCurrency === FiatToken.BRL) { + return "onramp_brl"; + } + if (rampState.quote?.inputCurrency === FiatToken.EURC) { + return "onramp_eur_monerium"; + } + return "onramp_eur_evm"; + } + + if (rampState.quote?.outputCurrency === FiatToken.BRL) { + return "offramp_brl"; + } + + if (rampState.quote?.outputCurrency === FiatToken.EURC) { + return "offramp_eur_evm"; + } + + if (rampState.quote && isDomesticToken(rampState.quote.outputCurrency)) { + return "offramp_alfredpay"; + } + + return null; +} From f952bd31a3871d02ec0ce3444e1ea33bee01ad9c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:48:43 +0200 Subject: [PATCH 20/42] fix(api): resolve the Monerium binding across the profile's entities Registration and readiness resolved the binding on the active entity only, while OAuth and status calls are typed. A business-active profile that onboarded EUR through the widget (always individual) failed registration with MONERIUM_ONBOARDING_REQUIRED despite approval, and the onboarding status loop swallowed the 403 silently. --- .../api/controllers/onboarding.controller.ts | 4 + .../api/src/api/services/monerium/identity.ts | 25 ++++-- ...erium-binding-entities.integration.test.ts | 77 +++++++++++++++++++ .../security-spec/05-integrations/monerium.md | 2 +- 4 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/tests/monerium-binding-entities.integration.test.ts diff --git a/apps/api/src/api/controllers/onboarding.controller.ts b/apps/api/src/api/controllers/onboarding.controller.ts index 65bcb70cb..520c70141 100644 --- a/apps/api/src/api/controllers/onboarding.controller.ts +++ b/apps/api/src/api/controllers/onboarding.controller.ts @@ -157,8 +157,12 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise< code: MONERIUM_REAUTHENTICATION_REQUIRED, message: error.message }); + return; } // Status aggregation remains available if Monerium is unavailable or in-memory credentials were lost. + logger.warn( + `Monerium status refresh failed for provider customer ${customer.id}: ${error instanceof Error ? error.message : String(error)}` + ); } }) ); diff --git a/apps/api/src/api/services/monerium/identity.ts b/apps/api/src/api/services/monerium/identity.ts index f7f05583c..612a36fc0 100644 --- a/apps/api/src/api/services/monerium/identity.ts +++ b/apps/api/src/api/services/monerium/identity.ts @@ -3,7 +3,7 @@ import httpStatus from "http-status"; import type { Transaction } from "sequelize"; import ProviderCustomer, { type ProviderCustomerType } from "../../../models/providerCustomer.model"; import { APIError } from "../../errors/api-error"; -import { getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; +import { findCustomerEntityIdsForProfile, getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; import { getMoneriumUserAccessToken, MONERIUM_REAUTHENTICATION_REQUIRED } from "./monerium.service"; export const MONERIUM_ONBOARDING_REQUIRED = "MONERIUM_ONBOARDING_REQUIRED"; @@ -34,13 +34,28 @@ export interface MoneriumIdentityDependencies { loadBinding: (userId: string, transaction?: Transaction) => Promise; } -async function loadMoneriumBinding(userId: string, transaction?: Transaction): Promise { +/** + * Ramp registration carries no customer type, so the binding is looked up across every entity the + * profile owns: the active entity's binding wins, otherwise the one bound entity. A business-active + * profile that onboarded through the widget (always `individual`) is therefore still registerable. + */ +export async function loadMoneriumBinding(userId: string, transaction?: Transaction): Promise { const entity = await getOrCreateCustomerEntityForProfile(userId, undefined, transaction); - const binding = await ProviderCustomer.findOne({ + const bindings = await ProviderCustomer.findAll({ ...(transaction ? { transaction } : {}), - where: { customerEntityId: entity.id, customerType: entity.type, provider: "monerium", rail: "eur" } + where: { + customerEntityId: await findCustomerEntityIdsForProfile(userId, transaction), + provider: "monerium", + rail: "eur" + } }); - return { customerEntityId: entity.id, customerType: entity.type, profileId: binding?.providerCustomerId ?? null }; + const binding = bindings.find(candidate => candidate.customerEntityId === entity.id) ?? bindings[0]; + if (!binding) return { customerEntityId: entity.id, customerType: entity.type, profileId: null }; + return { + customerEntityId: binding.customerEntityId, + customerType: binding.customerType, + profileId: binding.providerCustomerId + }; } async function getUserClient(customerEntityId: string, customerType: ProviderCustomerType): Promise { diff --git a/apps/api/src/tests/monerium-binding-entities.integration.test.ts b/apps/api/src/tests/monerium-binding-entities.integration.test.ts new file mode 100644 index 000000000..34e89f25a --- /dev/null +++ b/apps/api/src/tests/monerium-binding-entities.integration.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { getOrCreateCustomerEntityForProfile, selectActiveCustomerEntity } from "../api/services/customer-entity.service"; +import { loadMoneriumBinding } from "../api/services/monerium/identity"; +import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestUser } from "../test-utils/factories"; + +const PROFILE_ID = "9e6a92a5-5f6d-48aa-a57b-0f8ae8eb745d"; + +beforeAll(async () => { + await setupTestDatabase(); +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +// The widget always onboards EUR as `individual`, while the dashboard switcher and managed +// profiles can make a business entity active. Registration carries no customer type, so the +// binding must be found on whichever entity holds it. +describe("loadMoneriumBinding", () => { + it("finds the Monerium binding on a non-active entity", async () => { + const user = await createTestUser(); + const individual = await getOrCreateCustomerEntityForProfile(user.id, "individual"); + await ProviderCustomer.create({ + customerEntityId: individual.id, + customerType: "individual", + provider: "monerium", + providerCustomerId: PROFILE_ID, + rail: "eur", + status: VerificationStatus.Approved, + statusExternal: "approved" + }); + const business = await selectActiveCustomerEntity(user.id, "business"); + + await expect(loadMoneriumBinding(user.id)).resolves.toEqual({ + customerEntityId: individual.id, + customerType: "individual", + profileId: PROFILE_ID + }); + expect(business.type).toBe("business"); + }); + + it("prefers the active entity's binding when several entities are bound", async () => { + const user = await createTestUser(); + const individual = await getOrCreateCustomerEntityForProfile(user.id, "individual"); + const business = await selectActiveCustomerEntity(user.id, "business"); + for (const entity of [individual, business]) { + await ProviderCustomer.create({ + customerEntityId: entity.id, + customerType: entity.type, + provider: "monerium", + providerCustomerId: `${entity.type}-profile`, + rail: "eur", + status: VerificationStatus.Approved, + statusExternal: "approved" + }); + } + + await expect(loadMoneriumBinding(user.id)).resolves.toEqual({ + customerEntityId: business.id, + customerType: "business", + profileId: "business-profile" + }); + }); + + it("reports the active entity with no profile when nothing is bound", async () => { + const user = await createTestUser(); + const individual = await getOrCreateCustomerEntityForProfile(user.id, "individual"); + + await expect(loadMoneriumBinding(user.id)).resolves.toEqual({ + customerEntityId: individual.id, + customerType: "individual", + profileId: null + }); + }); +}); diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 07322a357..eb05b8052 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -96,7 +96,7 @@ recovery. 13. Live contract mutations MUST target exactly `https://api.monerium.dev` and remain independently opt-in. An order contract test MUST NOT run from credentials alone because it can move sandbox EURe. 14. New SEPA/EUR BUY quotes MUST resolve only to the Polygon Monerium flow. A direct EUR BUY quote for a destination that flow cannot serve (Polygon itself or a non-EVM network, `doesNetworkSupportEurOnramp`) MUST return the public `400` `QuoteError.EurOnrampNetworkUnsupported`; the dashboard and widget pickers MUST NOT offer those destinations for EUR. New EUR SELL quotes MUST return a public `400` and MUST NOT fall back to a Mykobo flow. 15. Production startup MUST fail without a Monerium auth-code client ID, exact callback URI, `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET`, and explicit non-negative `MONERIUM_ISSUE_FEE_EUR`. The issue fee MUST NOT silently default to zero. Credentials MUST NOT be accepted from client requests. -16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's canonical legal entity and its `monerium`/`eur` provider-customer binding, and MUST read that profile through the white-label app first and, only when the white-label API answers `403` or `404` for it, through the user's backend-held OAuth token (`resolveMoneriumIdentity`). A missing binding MUST fail with `MONERIUM_ONBOARDING_REQUIRED`; a missing or rejected OAuth session MUST fail with `MONERIUM_REAUTHENTICATION_REQUIRED`; any other white-label failure MUST NOT switch apps. The live profile MUST be `approved`. Which app served the profile is logged, never persisted. Registration MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. +16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's `monerium`/`eur` provider-customer binding, resolved across every legal entity the profile owns with the active entity's binding preferred (registration carries no customer type; a business-active profile that onboarded EUR as an individual through the widget must still resolve), and MUST read that profile through the white-label app first and, only when the white-label API answers `403` or `404` for it, through the user's backend-held OAuth token (`resolveMoneriumIdentity`). A missing binding MUST fail with `MONERIUM_ONBOARDING_REQUIRED`; a missing or rejected OAuth session MUST fail with `MONERIUM_REAUTHENTICATION_REQUIRED`; any other white-label failure MUST NOT switch apps. The live profile MUST be `approved`. Which app served the profile is logged, never persisted. Registration MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. 17. Self-transfer registration MUST copy only owner, token, chain, and amount from trusted `monerium-issue` facts and MUST reject an owner that is also the EVM ephemeral. Its EURe permit and exact `transferFrom` MUST be independently validated and reconciled; strict presign completeness MUST require both the user-signed permit and ephemeral-signed transfer. A still-current permit MUST be consumed even when allowance already exists, while an advanced nonce or expired deadline may prove it non-replayable. Permit and transfer hashes MUST remain in namespaced block state, and successful execution MUST verify receipts and the exact allowance reduction. 18. The Polygon conversion MUST verify the pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. 19. Issue execution MUST wait for `currentOwnerBalance >= persistedBaseline + quotedPostFeeEureRaw`. Timeouts and exhausted RPC reads are recoverable. Missing or malformed settlement facts are unrecoverable corruption. The executor MUST transfer only the quoted post-fee amount; excess EURe remains in the owner wallet. This non-deterministic attribution exception is accepted only under RISK-023. From 9a6c935a19d3e6c5da6d4c015b7d9ec4e3fca773 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:52:10 +0200 Subject: [PATCH 21/42] fix(api): refuse a second live Monerium ramp or an IBAN move for an owner with one in flight Permits for one owner are prepared against the same ERC-2612 nonce and the mint executor attributes settlement by balance delta, so two live ramps could deliver one SEPA credit to the other ramp's destination and strand the loser on a consumed nonce. Moving the IBAN mid-ramp redirects the mint the executor is polling for. Both are cheap 409s at the boundary. --- .../src/api/services/monerium/active-ramp.ts | 33 ++++++++++++++ .../src/api/services/monerium/wallet.test.ts | 11 +++++ apps/api/src/api/services/monerium/wallet.ts | 11 +++++ .../monerium-issue.registration.test.ts | 29 +++++++++++- .../phases/monerium-issue/registration.ts | 15 +++++++ .../monerium-active-ramp.integration.test.ts | 45 +++++++++++++++++++ .../security-spec/05-integrations/monerium.md | 4 +- docs/security-spec/RISK-REGISTER.md | 2 +- 8 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/api/services/monerium/active-ramp.ts create mode 100644 apps/api/src/tests/monerium-active-ramp.integration.test.ts diff --git a/apps/api/src/api/services/monerium/active-ramp.ts b/apps/api/src/api/services/monerium/active-ramp.ts new file mode 100644 index 000000000..49d7715e1 --- /dev/null +++ b/apps/api/src/api/services/monerium/active-ramp.ts @@ -0,0 +1,33 @@ +import { Op, type Transaction } from "sequelize"; +import sequelize from "../../../config/database"; +import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../../constants/constants"; +import RampState from "../../../models/rampState.model"; + +/** + * The Monerium ramp, if any, that can still mint to and pull from `owner`: every non-terminal + * ramp except an unstarted one whose start window has already closed. Two such ramps would share + * the owner's ERC-2612 nonce and race for the same SEPA credit, and an IBAN move under one would + * redirect the mint it is waiting for. + */ +export async function findActiveMoneriumRampForOwner(owner: string, transaction?: Transaction): Promise { + const ramp = await RampState.findOne({ + attributes: ["id"], + ...(transaction ? { transaction } : {}), + where: { + [Op.and]: [ + sequelize.where( + sequelize.fn("lower", sequelize.literal("state->'blockState'->'moneriumIssue'->>'owner'")), + owner.toLowerCase() + ), + { currentPhase: { [Op.notIn]: ["complete", "failed", "timedOut"] } }, + { + [Op.or]: [ + { currentPhase: { [Op.ne]: "initial" } }, + { createdAt: { [Op.gt]: new Date(Date.now() - RAMP_START_EXPIRATION_TIME_SECONDS * 1000) } } + ] + } + ] + } + }); + return ramp?.id ?? null; +} diff --git a/apps/api/src/api/services/monerium/wallet.test.ts b/apps/api/src/api/services/monerium/wallet.test.ts index 7dc8a8260..56ce1bf6b 100644 --- a/apps/api/src/api/services/monerium/wallet.test.ts +++ b/apps/api/src/api/services/monerium/wallet.test.ts @@ -32,6 +32,7 @@ function client(options: { addresses?: string[]; ibans?: ReturnType function deps(monerium: ReturnType, overrides: Partial[2]> = {}) { const identity = { client: monerium, profile: { state: "approved" }, profileId: PROFILE_ID, source: "oauth" } as unknown as MoneriumIdentity; return { + findActiveRampForOwner: async () => null, isContractAddress: async () => false, resolveIdentity: async () => identity, verifyOwnership: async () => true, @@ -176,6 +177,16 @@ describe("moveMoneriumIban", () => { expect(monerium.updateIbanDestination).not.toHaveBeenCalled(); }); + it("refuses to move the IBAN away from a wallet with a live ramp", async () => { + const monerium = client({ addresses: [OWNER.address], ibans: [iban(OTHER, "ethereum")] }); + const findActiveRampForOwner = mock(async () => "ramp-live"); + await expect( + moveMoneriumIban("user-1", { address: OWNER.address, chain: "polygon" }, deps(monerium, { findActiveRampForOwner })) + ).rejects.toMatchObject({ isPublic: true, status: 409 }); + expect(findActiveRampForOwner).toHaveBeenCalledWith(OTHER); + expect(monerium.updateIbanDestination).not.toHaveBeenCalled(); + }); + it("requires exactly one IBAN", async () => { const monerium = client({ addresses: [OWNER.address] }); await expect(moveMoneriumIban("user-1", { address: OWNER.address, chain: "polygon" }, deps(monerium))).rejects.toMatchObject({ status: 409 }); diff --git a/apps/api/src/api/services/monerium/wallet.ts b/apps/api/src/api/services/monerium/wallet.ts index e07212b2c..d74fc1cf7 100644 --- a/apps/api/src/api/services/monerium/wallet.ts +++ b/apps/api/src/api/services/monerium/wallet.ts @@ -11,6 +11,7 @@ import logger from "../../../config/logger"; import { APIError } from "../../errors/api-error"; import { matchingDestinations } from "../phases/blocks/phases/monerium-issue/registration"; import { MONERIUM_ISSUE_NETWORKS, type MoneriumIssueNetwork } from "../phases/blocks/phases/monerium-issue/simulation"; +import { findActiveMoneriumRampForOwner } from "./active-ramp"; import { type MoneriumIdentity, type MoneriumIdentitySource, resolveMoneriumIdentity } from "./identity"; /** Chain the active EUR onramp mints on; readiness is measured against it. */ @@ -40,6 +41,7 @@ export interface MoneriumWalletLinkResult extends MoneriumWalletDestination { } export interface MoneriumWalletDependencies { + findActiveRampForOwner?: typeof findActiveMoneriumRampForOwner; isContractAddress: (network: MoneriumIssueNetwork, address: `0x${string}`) => Promise; resolveIdentity: (userId: string) => Promise; verifyOwnership: (address: `0x${string}`, signature: `0x${string}`) => Promise; @@ -175,6 +177,15 @@ export async function moveMoneriumIban( } const current = ibans[0]; if (current.chain !== chain || !sameAddress(current.address, address)) { + // A live ramp waits for the mint on the IBAN's current wallet; moving it now would strand that ramp. + const activeRampId = await (dependencies.findActiveRampForOwner ?? findActiveMoneriumRampForOwner)(current.address); + if (activeRampId) { + throw new APIError({ + isPublic: true, + message: `An EUR pay-in is still in progress for the wallet the IBAN points to (${activeRampId}); wait for it to finish before moving the IBAN`, + status: httpStatus.CONFLICT + }); + } await identity.client.updateIbanDestination(current.iban, { address, chain }); logger.info(`MoneriumWallet: moved the IBAN destination to ${address} on ${chain} through the ${identity.source} app`); } diff --git a/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts index 30a64d09a..68449d65e 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts @@ -61,7 +61,8 @@ describe("MoneriumIssue registration", () => { const readOwnerEureBalance = mock(async () => new Big("5000000000000000000")); const register = createRegisterMoneriumIssue({ createReference: () => "VTX00000000000000000000000000000001", - isContractAddress: async () => false, + findActiveRampForOwner: async () => null, + isContractAddress: async () => false, readOwnerEureBalance, resolveIdentity }); @@ -105,6 +106,7 @@ describe("MoneriumIssue registration", () => { const isContractAddress = mock(async () => false); const register = createRegisterMoneriumIssue({ createReference: () => "VTX00000000000000000000000000000002", + findActiveRampForOwner: async () => null, isContractAddress, readOwnerEureBalance: async () => new Big(0), resolveIdentity: async () => identity(monerium) @@ -122,6 +124,7 @@ describe("MoneriumIssue registration", () => { const resolveIdentity = mock(async () => identity(client())); const register = createRegisterMoneriumIssue({ createReference: () => "unused", + findActiveRampForOwner: async () => null, isContractAddress: async () => false, readOwnerEureBalance: async () => new Big(0), resolveIdentity @@ -136,6 +139,7 @@ describe("MoneriumIssue registration", () => { it("requires the live profile to remain approved", async () => { const register = createRegisterMoneriumIssue({ createReference: () => "unused", + findActiveRampForOwner: async () => null, isContractAddress: async () => false, readOwnerEureBalance: async () => new Big(0), resolveIdentity: async () => identity(client({ state: "pending" }), "pending") @@ -147,6 +151,7 @@ describe("MoneriumIssue registration", () => { it("rejects a profile-linked contract wallet that cannot sign the EOA permit", async () => { const register = createRegisterMoneriumIssue({ createReference: () => "unused", + findActiveRampForOwner: async () => null, isContractAddress: async () => true, readOwnerEureBalance: async () => new Big(0), resolveIdentity: async () => identity(client()) @@ -167,6 +172,7 @@ describe("MoneriumIssue registration", () => { ])("rejects a %s provider-chain IBAN/address match", async (_label, ibans) => { const register = createRegisterMoneriumIssue({ createReference: () => "unused", + findActiveRampForOwner: async () => null, isContractAddress: async () => false, readOwnerEureBalance: async () => new Big(0), resolveIdentity: async () => identity(client({ ibans })) @@ -175,9 +181,30 @@ describe("MoneriumIssue registration", () => { await expect(register(context())).rejects.toThrow("Expected exactly one Monerium base IBAN/address match"); }); + it("rejects a second ramp while one is still live for the same owner", async () => { + const findActiveRampForOwner = mock(async () => "ramp-live"); + const readOwnerEureBalance = mock(async () => new Big(0)); + const register = createRegisterMoneriumIssue({ + createReference: () => "unused", + findActiveRampForOwner, + isContractAddress: async () => false, + readOwnerEureBalance, + resolveIdentity: async () => identity(client({ chain: "polygon" })) + }); + + await expect(register(context({}, Networks.Polygon))).rejects.toMatchObject({ + isPublic: true, + message: expect.stringContaining("ramp-live"), + status: 409 + }); + expect(findActiveRampForOwner).toHaveBeenCalledWith(ADDRESS, undefined); + expect(readOwnerEureBalance).not.toHaveBeenCalled(); + }); + it("fails registration when the owner baseline cannot be read", async () => { const register = createRegisterMoneriumIssue({ createReference: () => "unused", + findActiveRampForOwner: async () => null, isContractAddress: async () => false, readOwnerEureBalance: async () => { throw new Error("RPC unavailable"); diff --git a/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts b/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts index 0fd69c10f..f0be0a44c 100644 --- a/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts +++ b/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts @@ -11,6 +11,7 @@ import httpStatus from "http-status"; import { isAddress } from "viem"; import logger from "../../../../../../config/logger"; import { APIError } from "../../../../../errors/api-error"; +import { findActiveMoneriumRampForOwner } from "../../../../monerium/active-ramp"; import { type MoneriumIdentity, resolveMoneriumIdentity } from "../../../../monerium/identity"; import type { RegisterCtx, RegistrationResult } from "../../core/types"; import { MONERIUM_EURE, MONERIUM_ISSUE_NETWORKS, type MoneriumIssueMetadata, type MoneriumIssueNetwork } from "./simulation"; @@ -51,6 +52,7 @@ export interface MoneriumIssueResponseArtifacts extends Record interface MoneriumIssueRegistrationDependencies { createReference: () => string; + findActiveRampForOwner?: typeof findActiveMoneriumRampForOwner; isContractAddress?: (network: MoneriumIssueNetwork, address: `0x${string}`) => Promise; readOwnerEureBalance: typeof getEvmTokenBalance; resolveIdentity: (userId: string, transaction?: RegisterCtx["transaction"]) => Promise; @@ -133,6 +135,19 @@ export function createRegisterMoneriumIssue( status: httpStatus.BAD_REQUEST }); } + // Permits for one owner share a nonce and the mint executor attributes by balance delta, so a + // second live ramp could deliver this ramp's SEPA credit elsewhere and strand the loser. + const activeRampId = await (dependencies.findActiveRampForOwner ?? findActiveMoneriumRampForOwner)( + address, + ctx.transaction + ); + if (activeRampId) { + throw new APIError({ + isPublic: true, + message: `An EUR pay-in is already in progress for this wallet (${activeRampId}); wait for it to finish before starting another`, + status: httpStatus.CONFLICT + }); + } const ownerEureBalanceBaseline = await dependencies.readOwnerEureBalance({ chain: ctx.metadata.network, ownerAddress: address as EvmAddress, diff --git a/apps/api/src/tests/monerium-active-ramp.integration.test.ts b/apps/api/src/tests/monerium-active-ramp.integration.test.ts new file mode 100644 index 000000000..5b4c9cb0c --- /dev/null +++ b/apps/api/src/tests/monerium-active-ramp.integration.test.ts @@ -0,0 +1,45 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { findActiveMoneriumRampForOwner } from "../api/services/monerium/active-ramp"; +import sequelize from "../config/database"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestRampState } from "../test-utils/factories"; + +const OWNER = "0xAbC0000000000000000000000000000000000001"; + +function moneriumState(owner = OWNER) { + return { blockState: { moneriumIssue: { owner } } } as never; +} + +beforeAll(async () => { + await setupTestDatabase(); +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +describe("findActiveMoneriumRampForOwner", () => { + it("finds a started ramp for the owner regardless of address case", async () => { + const ramp = await createTestRampState({ currentPhase: "moneriumOnrampMint", state: moneriumState() }); + + await expect(findActiveMoneriumRampForOwner(OWNER.toLowerCase())).resolves.toBe(ramp.id); + }); + + it("finds an unstarted ramp only while it can still be started", async () => { + const fresh = await createTestRampState({ currentPhase: "initial", state: moneriumState() }); + await expect(findActiveMoneriumRampForOwner(OWNER)).resolves.toBe(fresh.id); + + await sequelize.query("UPDATE ramp_states SET created_at = now() - interval '16 minutes' WHERE id = :id", { + replacements: { id: fresh.id } + }); + await expect(findActiveMoneriumRampForOwner(OWNER)).resolves.toBeNull(); + }); + + it("ignores terminal ramps and other owners", async () => { + await createTestRampState({ currentPhase: "complete", state: moneriumState() }); + await createTestRampState({ currentPhase: "failed", state: moneriumState() }); + await createTestRampState({ currentPhase: "moneriumOnrampMint", state: moneriumState("0x2222222222222222222222222222222222222222") }); + + await expect(findActiveMoneriumRampForOwner(OWNER)).resolves.toBeNull(); + }); +}); diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index eb05b8052..4b697b7a4 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -96,12 +96,12 @@ recovery. 13. Live contract mutations MUST target exactly `https://api.monerium.dev` and remain independently opt-in. An order contract test MUST NOT run from credentials alone because it can move sandbox EURe. 14. New SEPA/EUR BUY quotes MUST resolve only to the Polygon Monerium flow. A direct EUR BUY quote for a destination that flow cannot serve (Polygon itself or a non-EVM network, `doesNetworkSupportEurOnramp`) MUST return the public `400` `QuoteError.EurOnrampNetworkUnsupported`; the dashboard and widget pickers MUST NOT offer those destinations for EUR. New EUR SELL quotes MUST return a public `400` and MUST NOT fall back to a Mykobo flow. 15. Production startup MUST fail without a Monerium auth-code client ID, exact callback URI, `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET`, and explicit non-negative `MONERIUM_ISSUE_FEE_EUR`. The issue fee MUST NOT silently default to zero. Credentials MUST NOT be accepted from client requests. -16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's `monerium`/`eur` provider-customer binding, resolved across every legal entity the profile owns with the active entity's binding preferred (registration carries no customer type; a business-active profile that onboarded EUR as an individual through the widget must still resolve), and MUST read that profile through the white-label app first and, only when the white-label API answers `403` or `404` for it, through the user's backend-held OAuth token (`resolveMoneriumIdentity`). A missing binding MUST fail with `MONERIUM_ONBOARDING_REQUIRED`; a missing or rejected OAuth session MUST fail with `MONERIUM_REAUTHENTICATION_REQUIRED`; any other white-label failure MUST NOT switch apps. The live profile MUST be `approved`. Which app served the profile is logged, never persisted. Registration MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. +16. Issue registration MUST derive the Monerium profile UUID from the authenticated effective user's `monerium`/`eur` provider-customer binding, resolved across every legal entity the profile owns with the active entity's binding preferred (registration carries no customer type; a business-active profile that onboarded EUR as an individual through the widget must still resolve), and MUST read that profile through the white-label app first and, only when the white-label API answers `403` or `404` for it, through the user's backend-held OAuth token (`resolveMoneriumIdentity`). A missing binding MUST fail with `MONERIUM_ONBOARDING_REQUIRED`; a missing or rejected OAuth session MUST fail with `MONERIUM_REAUTHENTICATION_REQUIRED`; any other white-label failure MUST NOT switch apps. The live profile MUST be `approved`. Which app served the profile is logged, never persisted. Registration MUST reject caller-supplied profile, address, or IBAN identity, perform no IBAN mutation, and accept exactly one provider-returned IBAN whose valid EVM address matches an address linked to Polygon on that profile. Because the self-transfer uses an EOA-signed ERC-2612 permit, registration MUST reject a destination with deployed contract code. It MUST reject registration with a public `409` while another Monerium ramp for the same owner is live (`findActiveMoneriumRampForOwner`: any non-terminal ramp except an unstarted one whose start window has closed), because permits for one owner share an ERC-2612 nonce and the mint executor attributes by balance delta. It MUST read and persist the owner's Polygon EURe balance baseline; inability to obtain an authoritative baseline fails registration. Quote simulation MUST perform no Monerium API or authentication read. 17. Self-transfer registration MUST copy only owner, token, chain, and amount from trusted `monerium-issue` facts and MUST reject an owner that is also the EVM ephemeral. Its EURe permit and exact `transferFrom` MUST be independently validated and reconciled; strict presign completeness MUST require both the user-signed permit and ephemeral-signed transfer. A still-current permit MUST be consumed even when allowance already exists, while an advanced nonce or expired deadline may prove it non-replayable. Permit and transfer hashes MUST remain in namespaced block state, and successful execution MUST verify receipts and the exact allowance reduction. 18. The Polygon conversion MUST verify the pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. 19. Issue execution MUST wait for `currentOwnerBalance >= persistedBaseline + quotedPostFeeEureRaw`. Timeouts and exhausted RPC reads are recoverable. Missing or malformed settlement facts are unrecoverable corruption. The executor MUST transfer only the quoted post-fee amount; excess EURe remains in the owner wallet. This non-deterministic attribution exception is accepted only under RISK-023. 20. The owner permit expires 24 hours after transaction preparation. An expired permit or consumed nonce MUST stop automatic self-transfer unless a sufficient safe allowance remains; the API MUST NOT fabricate or broaden authorization. The absence of automatic reauthorization/recovery is accepted under RISK-024. -21. `POST /v1/monerium/wallet` MUST verify the EOA signature over the fixed ownership message server-side before any provider call, MUST reject addresses with deployed code, MUST link through the app that can read the profile, and MUST request the profile's single IBAN only when the profile has none. Status reads (`GET /v1/monerium/status`, `GET /v1/onboarding/status`) MUST NOT mutate provider state; they report readiness from the same list reads registration uses. +21. `POST /v1/monerium/wallet` MUST verify the EOA signature over the fixed ownership message server-side before any provider call, MUST reject addresses with deployed code, MUST link through the app that can read the profile, and MUST request the profile's single IBAN only when the profile has none. `POST /v1/monerium/iban/move` MUST reject with a public `409` while a live Monerium ramp still waits on the IBAN's current wallet, because the mint executor watches only the registered owner. Status reads (`GET /v1/monerium/status`, `GET /v1/onboarding/status`) MUST NOT mutate provider state; they report readiness from the same list reads registration uses. 22. An IBAN destination MUST change only through `POST /v1/monerium/iban/move`, an explicit request by the authenticated owner naming an address already linked on that chain, because it redirects the profile's future SEPA deposits. The backend MUST NOT move an IBAN as a side effect of linking, status, or registration. 23. The OAuth redirect URI MUST come from the configured allowlist (`MONERIUM_REDIRECT_URI`, `MONERIUM_WIDGET_REDIRECT_URI`) selected by the `client` field, never from caller-supplied URLs, and MUST be bound into the OAuth transaction so the code exchange reuses the same exact URI. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 964f1e302..e6f418d57 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -43,7 +43,7 @@ register and the owning module specification. | 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. | | RISK-021 | Accepted | High | Product + Compliance + Operations | Individual Avenia token-import code is enabled despite unresolved provider, legal, consent, and live-sandbox production-readiness confirmations. The caller is responsible for placing the canonical CPF in the source Sumsub applicant's TIN field; when Avenia omits `accountInfo.taxId`, Vortex accepts provider approval without independently comparing that CPF. This accepts enabled source behavior, not production readiness or evidence that any confirmation exists. | Auth-first profile binding, immutable KYC method selection, durable no-replay claims, exact-attempt polling, rejection of non-empty CPF mismatches, provisional consent evidence, and sensitive-data redaction. | Production rollout requires every [blocking confirmation and sandbox contract flow](../proposal-sumsub-kyc-token-sharing.md#blocking-confirmations) in the proposal. Add mandatory provider-returned CPF validation if caller responsibility proves insufficient. | | RISK-022 | Accepted | Medium | Product + Compliance + Operations | Avenia API and hosted KYB submission do not persist a durable pre-send claim. An ambiguous provider success or concurrent retry can therefore leave an unbound or superseded provider attempt. The low current KYB volume does not justify the additional submission-state machinery. | Provider active-attempt preflight, API conflict reconciliation, exact bound-attempt polling, and fail-closed handling of multiple active attempts. | Add a durable submission claim before increasing KYB volume, relying on unattended recovery, or observing duplicate or orphaned attempts operationally. | -| 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; 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-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 24 hours after preparation 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. | From d7759333c3705fedcd338b3280029fcd60941451 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:53:26 +0200 Subject: [PATCH 22/42] fix(api): give the Monerium permit the swap deadline A 24-hour permit that starts at registration expires on a routine standard SEPA transfer (Friday to Monday). The spender is the ramp's ephemeral and the value exact, so aligning with the one-week swap presign widens time only. --- .../__tests__/monerium-self-transfer.test.ts | 186 +++++++++++------- .../monerium-self-transfer/transactions.ts | 5 +- .../security-spec/05-integrations/monerium.md | 4 +- docs/security-spec/RISK-REGISTER.md | 2 +- 4 files changed, 122 insertions(+), 75 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/monerium-self-transfer.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/monerium-self-transfer.test.ts index e070c2e95..cf50366af 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/monerium-self-transfer.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/monerium-self-transfer.test.ts @@ -9,6 +9,8 @@ import Big from "big.js"; import { Signature as EvmSignature } from "ethers"; import { decodeFunctionData, keccak256 } from "viem"; import { privateKeyToAccount } from "viem/accounts"; +import { config } from "../../../../../config/vars"; +import { ReconciliationRequiredPhaseError } from "../../../../errors/phase-error"; import * as financialOperationNamespace from "../core/financial-operation"; import { allocateNonces } from "../core/prepare"; import { MONERIUM_EURE, MONERIUM_ISSUE_NETWORKS } from "../phases/monerium-issue/simulation"; @@ -47,6 +49,84 @@ function metadata() { return { amount: new Big("1.23"), amountRaw, chain: Networks.Base, token: MONERIUM_EURE } as const; } +/** Signs the prepared permit and transferFrom for a ramp whose permit was prepared at `preparedAt`. */ +async function signedRamp(preparedAt: number) { + const prepared = await prepareMoneriumSelfTransferTxs( + { + accounts: { EVM: { address: ephemeral.address, type: EphemeralAccountType.EVM } }, + globals: {} as never, + ownMetadata: metadata(), + ownRegistrationFacts: facts, + quote: {} as never + }, + { + now: () => preparedAt, + probe: async () => ({ + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, + nonce: 7n, + tokenName: "EURe" + }) + } + ); + const [permitBlueprint, transferBlueprint] = allocateNonces(prepared.intents); + const unsignedPermit = permitBlueprint.txData as SignedTypedData; + const permitHex = await owner.signTypedData({ + domain: unsignedPermit.domain, + message: unsignedPermit.message, + primaryType: unsignedPermit.primaryType, + types: unsignedPermit.types + }); + const permitSignature = EvmSignature.from(permitHex); + const signedPermit: PresignedTx = { + ...permitBlueprint, + txData: [ + { + ...unsignedPermit, + signature: { + deadline: Number(unsignedPermit.message.deadline), + r: permitSignature.r as `0x${string}`, + s: permitSignature.s as `0x${string}`, + v: permitSignature.v + } + } + ] + }; + const rawTransfer = await ephemeral.signTransaction({ + chainId: 8453, + data: (transferBlueprint.txData as { data: `0x${string}` }).data, + gas: 300_000n, + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, + nonce: 0, + to: (transferBlueprint.txData as { to: `0x${string}` }).to, + type: "eip1559", + value: 0n + }); + const signedTransfer: PresignedTx = { ...transferBlueprint, txData: rawTransfer }; + const state = { + currentPhase: "moneriumOnrampSelfTransfer", + errorLogs: [], + get() { + return this; + }, + id: "ramp-1", + phaseHistory: [], + presignedTxs: [signedPermit, signedTransfer], + state: { + accountAddresses: { EVM: ephemeral.address }, + blockState: { [MoneriumSelfTransferContext.key]: facts }, + flow: { id: "test-flow", version: 1 } + }, + unsignedTxs: [permitBlueprint, transferBlueprint], + async update(update: Record) { + Object.assign(this, update); + return this; + } + } as any; + return { rawTransfer, state }; +} + describe("MoneriumSelfTransfer block", () => { it("simulates an exact EURE-on-Base passthrough", async () => { const input = { amount: new Big("1.23"), amountRaw, chain: Networks.Base, token: MONERIUM_EURE } as const; @@ -208,82 +288,12 @@ describe("MoneriumSelfTransfer block", () => { it("consumes a current permit even when allowance already covers the transfer", async () => { operationAttempts.length = 0; - const prepared = await prepareMoneriumSelfTransferTxs( - { - accounts: { EVM: { address: ephemeral.address, type: EphemeralAccountType.EVM } }, - globals: {} as never, - ownMetadata: metadata(), - ownRegistrationFacts: facts, - quote: {} as never - }, - { - now: () => Date.now(), - probe: async () => ({ - maxFeePerGas: 2_000_000_000n, - maxPriorityFeePerGas: 1_000_000n, - nonce: 7n, - tokenName: "EURe" - }) - } - ); - const [permitBlueprint, transferBlueprint] = allocateNonces(prepared.intents); - const unsignedPermit = permitBlueprint.txData as SignedTypedData; - const permitHex = await owner.signTypedData({ - domain: unsignedPermit.domain, - message: unsignedPermit.message, - primaryType: unsignedPermit.primaryType, - types: unsignedPermit.types - }); - const permitSignature = EvmSignature.from(permitHex); - const signedPermit: PresignedTx = { - ...permitBlueprint, - txData: [{ - ...unsignedPermit, - signature: { - deadline: Number(unsignedPermit.message.deadline), - r: permitSignature.r as `0x${string}`, - s: permitSignature.s as `0x${string}`, - v: permitSignature.v - } - }] - }; - const rawTransfer = await ephemeral.signTransaction({ - chainId: 8453, - data: (transferBlueprint.txData as { data: `0x${string}` }).data, - gas: 300_000n, - maxFeePerGas: 2_000_000_000n, - maxPriorityFeePerGas: 1_000_000n, - nonce: 0, - to: (transferBlueprint.txData as { to: `0x${string}` }).to, - type: "eip1559", - value: 0n - }); - const signedTransfer: PresignedTx = { ...transferBlueprint, txData: rawTransfer }; + const { rawTransfer, state } = await signedRamp(Date.now()); const permitHash = `0x${"11".repeat(32)}` as `0x${string}`; const transferHash = keccak256(rawTransfer); let permitNonce = 7n; let transferSent = false; let permitCalls = 0; - const state = { - currentPhase: "moneriumOnrampSelfTransfer", - errorLogs: [], - get() { - return this; - }, - id: "ramp-1", - phaseHistory: [], - presignedTxs: [signedPermit, signedTransfer], - state: { - accountAddresses: { EVM: ephemeral.address }, - blockState: { [MoneriumSelfTransferContext.key]: facts }, - flow: { id: "test-flow", version: 1 } - }, - unsignedTxs: [permitBlueprint, transferBlueprint], - async update(update: Record) { - Object.assign(this, update); - return this; - } - } as any; const executor = new MoneriumSelfTransferExecutor({ getAllowance: async () => (transferSent ? 0n : BigInt(amountRaw)), getPermitNonce: async () => permitNonce, @@ -309,4 +319,38 @@ describe("MoneriumSelfTransfer block", () => { expect(state.state).not.toHaveProperty("permitTxHash"); expect(state.state).not.toHaveProperty("moneriumOnrampSelfTransferHash"); }); + + it("gives the permit the swap deadline so standard SEPA can settle", async () => { + const preparedAt = 1_700_000_000_000; + const { state } = await signedRamp(preparedAt); + const permit = state.presignedTxs[0].txData[0] as SignedTypedData; + + expect(Number(permit.message.deadline)).toBe(preparedAt / 1000 + config.swap.deadlineMinutes * 60); + }); + + it("pauses for reconciliation, not retry, when an expired permit left no allowance", async () => { + operationAttempts.length = 0; + const { state } = await signedRamp(Date.now() - (config.swap.deadlineMinutes + 1) * 60 * 1000); + let permitCalls = 0; + const executor = new MoneriumSelfTransferExecutor({ + getAllowance: async () => 0n, + getPermitNonce: async () => 7n, + getReceipt: async () => null, + getTransactionCount: async () => 0, + sendPermit: async () => { + permitCalls++; + return `0x${"11".repeat(32)}`; + }, + sendRawTransaction: async () => `0x${"22".repeat(32)}`, + waitForReceipt: async () => ({ status: "success" }) + }); + + const error = await executor.execute(state).catch(caught => caught); + + expect(error).toBeInstanceOf(ReconciliationRequiredPhaseError); + expect(error.message).toContain("expired"); + expect(permitCalls).toBe(0); + expect(operationAttempts).toEqual([]); + expect(state.state.blockState.moneriumSelfTransfer.permitInvalidation).toEqual({ observedNonce: "7", reason: "expired" }); + }); }); diff --git a/apps/api/src/api/services/phases/blocks/phases/monerium-self-transfer/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/monerium-self-transfer/transactions.ts index 489194853..76f3a282b 100644 --- a/apps/api/src/api/services/phases/blocks/phases/monerium-self-transfer/transactions.ts +++ b/apps/api/src/api/services/phases/blocks/phases/monerium-self-transfer/transactions.ts @@ -6,6 +6,7 @@ import { type SignedTypedData } from "@vortexfi/shared"; import { encodeFunctionData } from "viem"; +import { config } from "../../../../../../config/vars"; import { requireAccount } from "../../core/accounts"; import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; import { MONERIUM_ISSUE_NETWORKS } from "../monerium-issue/simulation"; @@ -55,7 +56,9 @@ export async function prepareMoneriumSelfTransferTxs( })(); const chainId = getNetworkId(facts.chain); if (chainId === undefined) throw new Error(`MoneriumSelfTransfer requires the ${facts.chain} chain ID`); - const deadline = BigInt(Math.floor((dependencies.now?.() ?? Date.now()) / 1000) + 24 * 60 * 60); + // Same horizon as the downstream swap presign: the spender is this ramp's ephemeral and the + // value exact, so a longer window only lets standard SEPA (up to three business days) settle. + const deadline = BigInt(Math.floor((dependencies.now?.() ?? Date.now()) / 1000) + config.swap.deadlineMinutes * 60); const permit: SignedTypedData = { domain: { chainId, name: probe.tokenName, verifyingContract: tokenAddress, version: "1" }, message: { diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 4b697b7a4..33d491a82 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -100,7 +100,7 @@ recovery. 17. Self-transfer registration MUST copy only owner, token, chain, and amount from trusted `monerium-issue` facts and MUST reject an owner that is also the EVM ephemeral. Its EURe permit and exact `transferFrom` MUST be independently validated and reconciled; strict presign completeness MUST require both the user-signed permit and ephemeral-signed transfer. A still-current permit MUST be consumed even when allowance already exists, while an advanced nonce or expired deadline may prove it non-replayable. Permit and transfer hashes MUST remain in namespaced block state, and successful execution MUST verify receipts and the exact allowance reduction. 18. The Polygon conversion MUST verify the pinned pool's tokens, fee, and factory and verify that the pinned factory, router, and quoter resolve to that deployment before quoting or execution. It MUST quote and execute exact-input EURe-to-USDC only, approve only the exact input, bind the swap recipient to the ephemeral, enforce the standard AMM hard minimum and soft execution threshold, validate both raw signed transactions against their unsigned blueprints and route semantics, verify successful receipts, and reconcile the post-swap allowance and output balance. Polygon USDC fee distribution and post-swap subsidy MUST use the existing configured fee recipients and EVM funding account respectively; neither may substitute the Monerium owner or ephemeral as a treasury destination. 19. Issue execution MUST wait for `currentOwnerBalance >= persistedBaseline + quotedPostFeeEureRaw`. Timeouts and exhausted RPC reads are recoverable. Missing or malformed settlement facts are unrecoverable corruption. The executor MUST transfer only the quoted post-fee amount; excess EURe remains in the owner wallet. This non-deterministic attribution exception is accepted only under RISK-023. -20. The owner permit expires 24 hours after transaction preparation. An expired permit or consumed nonce MUST stop automatic self-transfer unless a sufficient safe allowance remains; the API MUST NOT fabricate or broaden authorization. The absence of automatic reauthorization/recovery is accepted under RISK-024. +20. The owner permit carries the same deadline as the downstream swap presign (`config.swap.deadlineMinutes`, one week) from transaction preparation; its spender is the ramp's own ephemeral and its value exact, so the window bounds time only. An expired permit or consumed nonce MUST stop automatic self-transfer unless a sufficient safe allowance remains, and MUST pause the ramp for reconciliation (not a recoverable retry) when no allowance remains; the API MUST NOT fabricate or broaden authorization. The absence of automatic reauthorization/recovery is accepted under RISK-024. 21. `POST /v1/monerium/wallet` MUST verify the EOA signature over the fixed ownership message server-side before any provider call, MUST reject addresses with deployed code, MUST link through the app that can read the profile, and MUST request the profile's single IBAN only when the profile has none. `POST /v1/monerium/iban/move` MUST reject with a public `409` while a live Monerium ramp still waits on the IBAN's current wallet, because the mint executor watches only the registered owner. Status reads (`GET /v1/monerium/status`, `GET /v1/onboarding/status`) MUST NOT mutate provider state; they report readiness from the same list reads registration uses. 22. An IBAN destination MUST change only through `POST /v1/monerium/iban/move`, an explicit request by the authenticated owner naming an address already linked on that chain, because it redirects the profile's future SEPA deposits. The backend MUST NOT move an IBAN as a side effect of linking, status, or registration. 23. The OAuth redirect URI MUST come from the configured allowlist (`MONERIUM_REDIRECT_URI`, `MONERIUM_WIDGET_REDIRECT_URI`) selected by the `client` field, never from caller-supplied URLs, and MUST be bound into the OAuth transaction so the code exchange reuses the same exact URI. @@ -118,7 +118,7 @@ recovery. | Production test mutation | A live contract check links a wallet or submits an order against real money | Every mutation asserts the exact sandbox origin and requires its own explicit run flag | | Accidental contract-test settlement | A routine live check submits a signed redemption | Every persistent or value-moving sandbox flow has its own explicit `MONERIUM_CONTRACT_RUN_*` gate | | Balance-delta misattribution | An unrelated or duplicate EURe credit increases the linked owner's balance enough to satisfy a ramp | Accepted under RISK-023: the executor advances on the persisted balance delta, then transfers only the quoted amount. No claim of deterministic SEPA-order correlation is made; excess remains with the owner. | -| Permit becomes unusable before settlement | SEPA settlement arrives after the 24-hour permit deadline or after its nonce is consumed | Accepted under RISK-024: execution proves the permit unusable and stops rather than broadening authorization; manual resolution is required when no sufficient allowance remains. | +| Permit becomes unusable before settlement | SEPA settlement arrives after the one-week permit deadline or after its nonce is consumed | Accepted under RISK-024: execution proves the permit unusable and stops rather than broadening authorization; manual resolution is required when no sufficient allowance remains. | | Polygon swap route substitution | A stale or malicious endpoint points the conversion at a different pool, token, fee tier, router, or recipient | Deployment checks pin the pool/factory/router/quoter relationships; quote metadata and signed calldata are validated against constants, exact amounts, the ephemeral recipient, and bounded fee fields before broadcast | | Forged wallet link | A caller links an address it does not control to a profile | The backend verifies the EOA signature over the fixed ownership message and rejects contract code before any provider call; Monerium verifies the signature again | | IBAN redirection | A link, status, or registration call moves the profile's IBAN to another wallet | Only `POST /v1/monerium/iban/move`, an explicit owner request naming an already-linked address, calls `PATCH /ibans`; reads never mutate provider state | diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index e6f418d57..0d68a7221 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -44,7 +44,7 @@ register and the owning module specification. | RISK-021 | Accepted | High | Product + Compliance + Operations | Individual Avenia token-import code is enabled despite unresolved provider, legal, consent, and live-sandbox production-readiness confirmations. The caller is responsible for placing the canonical CPF in the source Sumsub applicant's TIN field; when Avenia omits `accountInfo.taxId`, Vortex accepts provider approval without independently comparing that CPF. This accepts enabled source behavior, not production readiness or evidence that any confirmation exists. | Auth-first profile binding, immutable KYC method selection, durable no-replay claims, exact-attempt polling, rejection of non-empty CPF mismatches, provisional consent evidence, and sensitive-data redaction. | Production rollout requires every [blocking confirmation and sandbox contract flow](../proposal-sumsub-kyc-token-sharing.md#blocking-confirmations) in the proposal. Add mandatory provider-returned CPF validation if caller responsibility proves insufficient. | | RISK-022 | Accepted | Medium | Product + Compliance + Operations | Avenia API and hosted KYB submission do not persist a durable pre-send claim. An ambiguous provider success or concurrent retry can therefore leave an unbound or superseded provider attempt. The low current KYB volume does not justify the additional submission-state machinery. | Provider active-attempt preflight, API conflict reconciliation, exact bound-attempt polling, and fail-closed handling of multiple active attempts. | Add a durable submission claim before increasing KYB volume, relying on unattended recovery, or observing duplicate or orphaned attempts operationally. | | 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 24 hours after preparation 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-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. | ## Review cadence From 54fedcedcadb507262847fa1167a1e96b6e282c2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:53:27 +0200 Subject: [PATCH 23/42] fix(api): pause a Monerium ramp whose permit died without leaving an allowance The executor recorded the invalidation and then threw a recoverable error for a condition that can never resolve, so the processor and recovery worker retried it forever. Reconciliation is the right label; the worker's re-drive of paused ramps is a pre-existing system-wide property left as is. --- .../blocks/phases/monerium-self-transfer/execution.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/api/src/api/services/phases/blocks/phases/monerium-self-transfer/execution.ts b/apps/api/src/api/services/phases/blocks/phases/monerium-self-transfer/execution.ts index 5a8da2806..24f38e259 100644 --- a/apps/api/src/api/services/phases/blocks/phases/monerium-self-transfer/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/monerium-self-transfer/execution.ts @@ -136,6 +136,13 @@ export class MoneriumSelfTransferExecutor extends BasePhaseHandler { const persistedAllowance = this.getState(state).allowanceBeforeTransferRaw; const allowanceBeforeTransfer = persistedAllowance ? BigInt(persistedAllowance) : observedAllowance; if (allowanceBeforeTransfer < BigInt(expectation.amountRaw)) { + // An expired or consumed permit can never establish the allowance; retrying only loops. + const invalidation = this.getState(state).permitInvalidation; + if (invalidation) { + throw this.createReconciliationRequiredError( + `MoneriumSelfTransfer permit is ${invalidation.reason} (owner nonce ${invalidation.observedNonce}) and no allowance remains` + ); + } throw this.createRecoverableError("MoneriumSelfTransfer permit did not establish the exact transfer allowance"); } await this.broadcastTransfer( From 89561150356a6f6c14e30d007b833d84ed55acac Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:54:26 +0200 Subject: [PATCH 24/42] fix(api): evict a Monerium session whose refresh grant is rejected A revoked or expired refresh token answered every later call with a generic 502 until restart, so clients never saw the reconnect prompt the spec promises. Also log the POST /ibans 400 that is assumed to mean 'already requested'. --- .../monerium/monerium.service.test.ts | 35 +++++++++++++++++ .../api/services/monerium/monerium.service.ts | 38 +++++++++++++++---- apps/api/src/api/services/monerium/wallet.ts | 1 + .../security-spec/05-integrations/monerium.md | 1 + 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/services/monerium/monerium.service.test.ts b/apps/api/src/api/services/monerium/monerium.service.test.ts index 8f95f5406..d136c4cef 100644 --- a/apps/api/src/api/services/monerium/monerium.service.test.ts +++ b/apps/api/src/api/services/monerium/monerium.service.test.ts @@ -327,6 +327,41 @@ describe("Monerium OAuth", () => { expect(JSON.stringify(result)).not.toContain("rotated-refresh"); }); + it("evicts the session and asks for reauthentication when the refresh grant is rejected", async () => { + let tokenCalls = 0; + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/auth/token")) { + tokenCalls += 1; + return tokenCalls === 1 + ? jsonResponse({ access_token: "old-access", expires_in: 1, refresh_token: "old-refresh" }) + : new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + } + if (url.endsWith("/auth/context")) { + return jsonResponse({ + email: "owner@example.com", + profiles: [{ id: "profile-a", kind: "personal" }], + userId: "monerium-user-a" + }); + } + return jsonResponse({ id: "profile-a", kind: "personal", state: "pending" }); + }) as unknown as typeof fetch; + + const { authorizationUrl } = await service.startMoneriumOAuth("owner", "owner@example.com", "individual"); + const state = new URL(authorizationUrl).searchParams.get("state") as string; + await service.completeMoneriumOAuth("owner", "authorization-code", state); + + await expect(service.getMoneriumStatus("owner", "individual")).rejects.toMatchObject({ + status: 404, + type: service.MONERIUM_REAUTHENTICATION_REQUIRED + }); + // The stale credential is gone: the next call fails the same way without another refresh attempt. + await expect(service.getMoneriumStatus("owner", "individual")).rejects.toMatchObject({ + type: service.MONERIUM_REAUTHENTICATION_REQUIRED + }); + expect(tokenCalls).toBe(2); + }); + it("returns the custom reauthentication error when credentials are unavailable", async () => { await expect(service.getMoneriumStatus("owner", "individual")).rejects.toMatchObject({ status: 404, diff --git a/apps/api/src/api/services/monerium/monerium.service.ts b/apps/api/src/api/services/monerium/monerium.service.ts index bf2b3cfda..32bdbb665 100644 --- a/apps/api/src/api/services/monerium/monerium.service.ts +++ b/apps/api/src/api/services/monerium/monerium.service.ts @@ -136,8 +136,17 @@ export function selectMoneriumProfile( return matches[0]; } -function upstreamError(_internalMessage: string): APIError { - return new APIError({ message: "Monerium request failed", status: httpStatus.BAD_GATEWAY }); +class MoneriumUpstreamError extends APIError { + constructor( + _internalMessage: string, + readonly upstreamStatus?: number + ) { + super({ message: "Monerium request failed", status: httpStatus.BAD_GATEWAY }); + } +} + +function upstreamError(internalMessage: string, upstreamStatus?: number): APIError { + return new MoneriumUpstreamError(internalMessage, upstreamStatus); } async function fetchJson(url: string, init: RequestInit): Promise { @@ -148,7 +157,7 @@ async function fetchJson(url: string, init: RequestInit): Promise { throw upstreamError("Monerium request timed out or failed"); } if (!response.ok) { - throw upstreamError(`Monerium returned HTTP ${response.status}`); + throw upstreamError(`Monerium returned HTTP ${response.status}`, response.status); } try { return await response.json(); @@ -209,10 +218,25 @@ async function getValidCredentials(customerEntityId: string, customerType: Provi grant_type: "refresh_token", refresh_token: credentials.refreshToken }) - ).then(rotated => { - credentialCache.set(key, rotated); - return rotated; - }); + ).then( + rotated => { + credentialCache.set(key, rotated); + return rotated; + }, + (error: unknown) => { + // A rejected refresh grant (revoked or expired token) cannot heal; keeping the stale + // credential would answer every later call with a generic 502 instead of a reconnect prompt. + if (error instanceof MoneriumUpstreamError && error.upstreamStatus !== undefined && error.upstreamStatus < 500) { + credentialCache.del(key); + throw new APIError({ + message: "Monerium reauthentication is required", + status: httpStatus.NOT_FOUND, + type: MONERIUM_REAUTHENTICATION_REQUIRED + }); + } + throw error; + } + ); refreshes.set(key, refresh); try { return await refresh; diff --git a/apps/api/src/api/services/monerium/wallet.ts b/apps/api/src/api/services/monerium/wallet.ts index d74fc1cf7..6eee60f31 100644 --- a/apps/api/src/api/services/monerium/wallet.ts +++ b/apps/api/src/api/services/monerium/wallet.ts @@ -152,6 +152,7 @@ export async function linkMoneriumWallet( } catch (error) { // Monerium keeps one IBAN per profile and answers 400 when one is already requested. if (!(error instanceof MoneriumApiError && error.status === 400)) throw error; + logger.warn(`MoneriumWallet: POST /ibans answered 400 for ${address} on ${chain}; assuming an IBAN is already requested`); } logger.info(`MoneriumWallet: requested an IBAN for ${address} on ${chain}`); return { address, chain, iban: "pending" }; diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 33d491a82..86be27465 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -193,6 +193,7 @@ Monerium replaces Mykobo as the EU onboarding provider in the dashboard and widg | Email substitution | A client starts verification for another email | Backend derives email from authenticated identity and treats a supplied email only as an equality assertion | | Token disclosure | Tokens leak through API responses, database records, or logs | Tokens are backend-memory-only; persisted mirrors contain profile identifiers and status metadata only | | Refresh replay/race | Concurrent status reads use the same rotating refresh token | Refreshes are coalesced per entity/customer type and the rotated token replaces the prior in-memory value | +| Revoked refresh token | Monerium rejects the refresh grant (4xx), so the cached credential can never be renewed | The stale credential is evicted and the call fails with `MONERIUM_REAUTHENTICATION_REQUIRED` so clients prompt a reconnect instead of a generic 502; a 5xx keeps the credential and surfaces as an upstream error | | Provider hangs | Monerium does not respond | Every provider fetch has an explicit 10-second abort timeout | | Wrong profile association | A context contains multiple legal profiles | Requested customer type is enforced, the matching default is preferred, and ambiguous matches are rejected | | Different Monerium login | A user ignores the prefilled email and authorizes a different Monerium account or profile | The callback matches `/auth/context.email` to the authenticated Vortex email and rejects replacement of an existing Monerium profile ID | From ac27d2632621478a7552b50aa6b59465cce297bb Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:55:18 +0200 Subject: [PATCH 25/42] fix(sdk): fail loudly on a mismatched EUR walletAddress and keep the 0.9.0 error names A walletAddress that is not the Monerium-linked owner filtered the owner permit away and returned an empty transaction list, leaving the ramp unstartable with no hint. The three Monerium* error classes exported by 0.9.0 were removed without aliases; keep them as deprecated aliases. --- docs/api/wire-contract.snapshot.md | 30 +++++++++++++++++++ packages/sdk/src/VortexSdk.ts | 9 +++++- packages/sdk/src/errors.ts | 8 +++++ packages/sdk/test/errors.test.ts | 14 +++++++++ packages/sdk/test/vortexSdk.eurOnramp.test.ts | 14 +++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index f5c7115be..41d22be70 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -4310,6 +4310,26 @@ MissingEurOnrampParametersError: class MissingEurOnrampParametersError { readonly status: number; } +MissingMoneriumOfframpParametersError: { + prototype: { + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; + }; +} + +MissingMoneriumOnrampParametersError: { + prototype: { + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; + }; +} + MissingMykoboOfframpParametersError: class MissingMykoboOfframpParametersError { constructor(); readonly code?: string; @@ -4337,6 +4357,16 @@ MissingRequiredFieldsError: class MissingRequiredFieldsError { readonly status: number; } +MoneriumError: { + prototype: { + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; + }; +} + MoneriumOnboardingRequiredError: class MoneriumOnboardingRequiredError { constructor(message: string, status?: number); readonly code?: string; diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index 6b882fa7e..d39305649 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -24,7 +24,7 @@ import { UnsignedTx } from "@vortexfi/shared"; import { attachSignatures, typedDataToSign, type UserTransactionType, userTransactionType } from "./eip712.js"; -import { TransactionSigningError } from "./errors.js"; +import { EurOnrampError, TransactionSigningError } from "./errors.js"; import { BrlHandler } from "./handlers/BrlHandler.js"; import { DomesticHandler } from "./handlers/DomesticHandler.js"; import { EurHandler } from "./handlers/EurHandler.js"; @@ -161,6 +161,13 @@ export class VortexSdk { rampProcess = await this.eurHandler.registerEurOnramp(quote.id, eurData); // The Monerium owner permit is signed by the linked wallet, not by an ephemeral. unsignedTransactions = await this.getUserTransactions(rampProcess, eurData.walletAddress); + if (unsignedTransactions.length === 0) { + // The backend addresses the permit to the wallet linked to the Monerium profile; a + // different walletAddress would silently leave the ramp without its permit. + throw new EurOnrampError( + `walletAddress ${eurData.walletAddress} is not the wallet linked to the Monerium profile; no owner permit was returned for it` + ); + } } else { throw new Error(`Unsupported onramp from: ${quote.from}`); } diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 3cd62dfde..bae0837cf 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -285,6 +285,14 @@ export class MissingMykoboOfframpParametersError extends MykoboError { } } +// Names published by @vortexfi/sdk@0.9.0; kept as aliases so existing imports keep resolving. +/** @deprecated Renamed to {@link EurOnrampError}. */ +export const MoneriumError = EurOnrampError; +/** @deprecated Renamed to {@link MissingEurOnrampParametersError}. */ +export const MissingMoneriumOnrampParametersError = MissingEurOnrampParametersError; +/** @deprecated EUR offramps are not supported; see {@link MissingMykoboOfframpParametersError}. */ +export const MissingMoneriumOfframpParametersError = MissingMykoboOfframpParametersError; + /** * The effective user's Mykobo KYC is missing/not approved, or the supplied email does not * match the profile bound to the authenticated user. Complete Mykobo KYC before EUR ramps. diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts index 12b4b6c9c..3438f5237 100644 --- a/packages/sdk/test/errors.test.ts +++ b/packages/sdk/test/errors.test.ts @@ -1,6 +1,12 @@ import {describe, expect, test} from "bun:test"; import { DomesticOnrampKycRequiredError, + EurOnrampError, + MissingEurOnrampParametersError, + MissingMoneriumOfframpParametersError, + MissingMoneriumOnrampParametersError, + MissingMykoboOfframpParametersError, + MoneriumError, MoneriumOnboardingRequiredError, MoneriumReauthenticationRequiredError, BrlKycStatusError, @@ -146,3 +152,11 @@ describe("parseAPIError", () => { expect(missingTaxId.message).toBe("Tax ID is required"); }); }); + +describe("deprecated 0.9.0 error names", () => { + test("keep resolving to their renamed classes", () => { + expect(MoneriumError).toBe(EurOnrampError); + expect(MissingMoneriumOnrampParametersError).toBe(MissingEurOnrampParametersError); + expect(MissingMoneriumOfframpParametersError).toBe(MissingMykoboOfframpParametersError); + }); +}); diff --git a/packages/sdk/test/vortexSdk.eurOnramp.test.ts b/packages/sdk/test/vortexSdk.eurOnramp.test.ts index 60071305c..b669c1d78 100644 --- a/packages/sdk/test/vortexSdk.eurOnramp.test.ts +++ b/packages/sdk/test/vortexSdk.eurOnramp.test.ts @@ -53,6 +53,20 @@ describe("VortexSdk.registerRamp for EUR/SEPA BUY", () => { expect(sdk.getUserTransactionType(unsignedTransactions[0])).toBe("evm-typed-data"); }); + test("rejects a walletAddress that is not the Monerium-linked owner instead of returning no permit", async () => { + const sdk = new VortexSdk({ apiBaseUrl: "http://127.0.0.1:1", secretKey: "sk_test_eur", storeEphemeralKeys: false }); + (sdk as unknown as { eurHandler: { registerEurOnramp: unknown } }).eurHandler = { + registerEurOnramp: async (): Promise => ({ id: "ramp_eur", unsignedTxs: [permitTx, ephemeralTx] } as RampProcess) + }; + + await expect( + sdk.registerRamp(quote, { + destinationAddress: "0x0000000000000000000000000000000000000002", + walletAddress: "0x0000000000000000000000000000000000000003" + }) + ).rejects.toThrow("not the wallet linked to the Monerium profile"); + }); + test("updateRamp points EUR BUY integrators at the permit submission helpers", async () => { const sdk = new VortexSdk({ apiBaseUrl: "http://127.0.0.1:1", secretKey: "sk_test_eur", storeEphemeralKeys: false }); await expect(sdk.updateRamp(quote, "ramp_eur", undefined as never)).rejects.toThrow("submitUserTransactions"); From 34b93a5c47df7f1775c4ec5318a95f674dec4ee2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 11:56:25 +0200 Subject: [PATCH 26/42] docs(repo): reconcile the EUR onramp integration notes with the shipped SDK and widget The vortex-integration skill said in one place that the SDK supports EUR BUY and in two others that it does not. The widget callback sample pointed at port 5473 instead of the frontend's 5173, and the Monerium ops doc never named the env vars it depends on. --- .agents/skills/vortex-integration/SKILL.md | 31 +++++++++++++++++----- apps/api/.env.example | 2 +- docs/operations-monerium-interface.md | 10 +++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index df68da004..19a2114e0 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -265,9 +265,9 @@ triggers: ``` ## When to use -The user wants to buy crypto with EUR and has already been provisioned as corridor-ready: an approved Vortex EUR provider binding, a live approved provider profile, exactly one existing Polygon EOA/IBAN destination, and access to that EOA for typed-data signing. Both individual and business legal entities may qualify. The active route delivers only to supported non-Polygon EVM destinations. +The user wants to buy crypto with EUR and is already corridor-ready: an approved Vortex EUR provider binding, a live approved provider profile, exactly one existing Polygon EOA/IBAN destination, and access to that EOA for typed-data signing. Both individual and business legal entities may qualify. The active route delivers only to supported non-Polygon EVM destinations. -Do not use this flow for onboarding, wallet linking, IBAN provisioning, or EUR SELL. Those operations are unavailable in the active product integration. +Users become corridor-ready by completing Monerium OAuth onboarding in the Dashboard or Widget and linking the wallet they will pay in with (`POST /v1/monerium/wallet`); this flow does not cover onboarding, wallet linking, or IBAN provisioning. EUR SELL is unavailable. ## Prerequisites - Quote with TypeScript member `inputCurrency: FiatToken.EURC` (raw JSON value `"EUR"`), `from: "sepa"`, and a supported non-Polygon EVM destination. @@ -275,10 +275,27 @@ Do not use this flow for onboarding, wallet linking, IBAN provisioning, or EUR S - `additionalData.destinationAddress`; do not submit profile, Monerium address, or IBAN identity. - A fresh EVM ephemeral key and a wallet-signing channel for the profile-linked Polygon owner. +## SDK recipe +```js +// walletAddress must be the wallet linked to the Monerium profile; a mismatch throws EurOnrampError. +const { rampProcess, unsignedTransactions } = await vortex.registerRamp(quote, { + destinationAddress: "0xDestinationWallet", + walletAddress: "0xMoneriumLinkedWallet" +}); + +// unsignedTransactions holds the owner's EIP-712 permit; the ephemeral txs are signed by the SDK. +const updated = await vortex.submitUserTransactions(rampProcess.id, unsignedTransactions, { + signTypedData: payload => signTypedData(wagmiConfig, payload) +}); + +// updated.ibanPaymentData (IBAN, BIC, receiver name, reference) is released once every signature +// validates; show it verbatim, have the user pay by SEPA, then start before the deadline. +await vortex.startRamp(rampProcess.id); +``` + ## Direct API sequence -The current `@vortexfi/sdk` EUR handler is not compatible with this flow because it drops the -owner-wallet permit. Use the raw API: +Raw API clients perform the same steps themselves: 1. Create the EUR BUY quote. 2. Call `POST /v1/ramp/register` with the quote ID, fresh EVM signing account, and destination address. @@ -290,8 +307,8 @@ owner-wallet permit. Use the raw API: initiate the SEPA transfer, then call `POST /v1/ramp/start` before the start deadline. 6. Poll status or use Vortex webhooks for the ramp lifecycle. -The permit expires 24 hours after preparation. If SEPA settlement arrives after expiry or the owner -consumes its nonce, automatic execution can stop for manual resolution. +The permit expires one week after preparation. If SEPA settlement arrives after expiry or the owner +consumes its nonce, automatic execution stops for manual resolution. ## Common failures - `400` approved-profile error: the effective legal entity has no approved local Monerium/EUR binding or the live provider profile is not approved. @@ -675,7 +692,7 @@ try { ## Current corridor reality (August 2026) - **BRL via PIX**: onramp and offramp both live. `taxId` deprecated — derived from the user-linked key. -- **EUR via SEPA**: BUY is active through the direct backend API (`FiatToken.EURC`, rail `"sepa"`) for an already-bound approved user with one Polygon EOA/IBAN destination. It requires the linked owner's typed-data permit. The SDK, Widget, and Dashboard do not yet implement that journey. SELL is unavailable. +- **EUR via SEPA**: BUY is active (`FiatToken.EURC`, rail `"sepa"`) for an approved Monerium user with one Polygon EOA/IBAN destination, through the SDK (`walletAddress` + `submitUserTransactions` for the owner permit), the Widget, the Dashboard, and the direct API. Onboarding and wallet linking happen in the Dashboard or Widget. Destinations: EVM networks except Polygon. SELL is unavailable. - **USD (ACH) / MXN (SPEI) / COP (ACH) / ARS (CBU)**: onramp and offramp live via the AlfredPay corridor; registration requires an authenticated user identity. Route resolver determines availability per-combination. - Live corridors deliver to EVM networks; AssetHub ramp execution is currently disabled. diff --git a/apps/api/.env.example b/apps/api/.env.example index 85948e470..0ffa2588b 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -160,7 +160,7 @@ MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id MONERIUM_API_URL=https://api.monerium.dev MONERIUM_REDIRECT_URI=http://localhost:5174/dashboard/monerium/callback # Exact widget callback registered with Monerium; leave unset to disable the widget OAuth flow. -MONERIUM_WIDGET_REDIRECT_URI=http://localhost:5473/widget +MONERIUM_WIDGET_REDIRECT_URI=http://localhost:5173/widget # Server-to-server white-label access (shared client; also the Monerium B2B onramp # credentials). Keep this backend-only. MONERIUM_WHITELABEL_CLIENT_ID=your-monerium-whitelabel-client-id diff --git a/docs/operations-monerium-interface.md b/docs/operations-monerium-interface.md index 9637e5da4..d3eaa89f8 100644 --- a/docs/operations-monerium-interface.md +++ b/docs/operations-monerium-interface.md @@ -47,6 +47,16 @@ submit them through `POST /v1/ramp/update`, show the released SEPA instructions, reported on `GET /v1/monerium/status` (`ramp`) and on the Monerium account of `GET /v1/onboarding/status`. +Configuration (`apps/api/src/config/vars.ts`, samples in `apps/api/.env.example`): + +| Variable | Purpose | +|---|---| +| `MONERIUM_API_URL` | Monerium API base (`https://api.monerium.dev` sandbox, `https://api.monerium.app` production). | +| `MONERIUM_CLIENT_ID` | OAuth authorization-code app used by the dashboard and widget onboarding. | +| `MONERIUM_REDIRECT_URI` / `MONERIUM_WIDGET_REDIRECT_URI` | Exact callback URIs registered with Monerium for the dashboard and the widget (`/widget` on the frontend origin); a mismatch renders Monerium's authorization page blank. The widget URI is optional and disables widget OAuth when unset. | +| `MONERIUM_WHITELABEL_CLIENT_ID` / `MONERIUM_WHITELABEL_CLIENT_SECRET` | White-label client credentials (also the B2B onramp). | +| `MONERIUM_ISSUE_FEE_EUR` | Flat EUR fee subtracted from each issue quote; required in production and must not default silently. | + This release does not create profiles through the white-label API, import external profiles, migrate OAuth profiles into the white-label application, orchestrate KYC/KYB lifecycle state through the white-label API, or support EUR SELL. Those capabilities remain deferred even where the From 795c155962cf40ff54fd3cf6644d73eeea440bd5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 12:05:39 +0200 Subject: [PATCH 27/42] test(api): add the hermetic Monerium EUR onramp corridor scenario The EUR kill-switch was removed on the condition that a fake-world corridor scenario exists; this adds it. A fake Monerium white-label app joins the fake world, FakeEvm learns bytecode probes, simulateContract, ERC-2612 reads, deterministic raw-tx hashes, and strict receipts, and the scenario drives quote -> register -> update -> the real processor to completion, plus the consumed-nonce reconciliation pause and the registration guards. --- .../api/src/test-utils/fake-world/fake-evm.ts | 34 +- .../test-utils/fake-world/fake-monerium.ts | 109 ++++ apps/api/src/test-utils/fake-world/index.ts | 7 +- .../eur-onramp-monerium.scenario.test.ts | 563 ++++++++++++++++++ docs/operations-testing.md | 32 +- 5 files changed, 729 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/test-utils/fake-world/fake-monerium.ts create mode 100644 apps/api/src/tests/corridors/eur-onramp-monerium.scenario.test.ts 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 adc91d6dd..c65b1568b 100644 --- a/apps/api/src/test-utils/fake-world/fake-evm.ts +++ b/apps/api/src/test-utils/fake-world/fake-evm.ts @@ -1,4 +1,5 @@ import { EvmClientManager, type EvmNetworks } from "@vortexfi/shared"; +import { keccak256 } from "viem"; export interface RecordedEvmTx { network: string; @@ -53,6 +54,16 @@ export class FakeEvm { onTransaction?: (tx: RecordedEvmTx) => void; /** First chance to answer any readContract call; return undefined to fall through to defaults. */ onReadContract?: (network: string, params: ReadContractParams) => unknown; + /** Answers simulateContract (eth_call with a decoded result, e.g. a Uniswap quoter); required per call. */ + onSimulateContract?: (network: string, params: ReadContractParams) => unknown; + /** Addresses that report deployed bytecode; everything else is an EOA. */ + readonly contractAddresses = new Set(); + /** + * When true, receipts for hashes this fake never recorded throw like a real node instead of + * confirming generically — required by executors that pre-compute a raw transaction's hash and + * probe for it before broadcasting (Monerium self-transfer, Uniswap). + */ + strictReceipts = false; /** Nabla router getAmountOut. Default: same-decimals 1:1.05. */ onGetAmountOut: (network: string, routerAddress: string, amountIn: bigint) => bigint = (_n, _r, amountIn) => (amountIn * 105n) / 100n; @@ -108,7 +119,9 @@ export class FakeEvm { this.failNextSends -= 1; throw new Error(this.sendFailureMessage); } - const recorded = { ...tx, hash: this.nextHash() }; + // A raw signed transaction has a deterministic hash on a real node; executors that + // pre-compute it (Monerium self-transfer, Uniswap) verify the node agrees. + const recorded = { ...tx, hash: tx.serialized ? keccak256(tx.serialized as `0x${string}`) : this.nextHash() }; this.sentTransactions.push(recorded); this.transactionsByHash.set(recorded.hash, recorded); this.onTransaction?.(recorded); @@ -125,6 +138,11 @@ export class FakeEvm { return this.erc20Balance(network, params.address, params.args?.[0] as string); case "allowance": return MAX_UINT256; + // ERC-2612 probes: a fresh owner has no permit history and the fake token has a fixed name. + case "nonces": + return 0n; + case "name": + return "Fake EURe"; case "getAmountOut": return this.onGetAmountOut(network, params.address, params.args?.[0] as bigint); case "getL1Fee": @@ -165,6 +183,9 @@ export class FakeEvm { // `revertedReceiptHashes` report a mined-but-reverted transaction instead. const receipt = (hash: `0x${string}`) => { const recorded = this.transactionsByHash.get(hash); + if (!recorded && this.strictReceipts) { + throw new Error(`FakeEvm: transaction receipt with hash "${hash}" could not be found`); + } return { blockNumber: 1n, from: recorded?.from, @@ -183,6 +204,8 @@ export class FakeEvm { this.feeEstimates.get(network) ?? { maxFeePerGas: 1_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n }, estimateGas: async () => 21_000n, getBalance: async ({ address }: { address: string }) => this.nativeBalance(network, address), + getBytecode: async ({ address }: { address: string }) => + this.contractAddresses.has(address.toLowerCase()) ? ("0x6080" as `0x${string}`) : undefined, getGasPrice: async () => 1_000_000_000n, getTransaction: async ({ hash }: { hash: `0x${string}` }) => { const recorded = this.transactionsByHash.get(hash); @@ -202,6 +225,15 @@ export class FakeEvm { readContract: async (params: ReadContractParams) => this.readContract(network, params), sendRawTransaction: async ({ serializedTransaction }: { serializedTransaction: string }) => this.recordTransaction({ network, serialized: serializedTransaction }), + simulateContract: async (params: ReadContractParams) => { + const result = this.onSimulateContract?.(network, params); + if (result === undefined) { + throw new Error( + `FakeEvm: simulateContract '${params.functionName}' on ${network} is not scripted — set fakeEvm.onSimulateContract in the test.` + ); + } + return { result }; + }, waitForTransactionReceipt: async ({ hash }: { hash: `0x${string}` }) => receipt(hash) }, `PublicClient(${network})` diff --git a/apps/api/src/test-utils/fake-world/fake-monerium.ts b/apps/api/src/test-utils/fake-world/fake-monerium.ts new file mode 100644 index 000000000..68a7c9135 --- /dev/null +++ b/apps/api/src/test-utils/fake-world/fake-monerium.ts @@ -0,0 +1,109 @@ +import { + type MoneriumAddress, + MoneriumApiError, + MoneriumApiService, + type MoneriumChain, + type MoneriumIban, + type MoneriumProfile +} from "@vortexfi/shared"; + +/** + * Fake Monerium white-label app. Profiles, linked addresses, and IBANs live in + * memory; the active EUR onramp only reads them (registration resolves exactly + * one IBAN/address pair), while the wallet-link routes add or move them. + */ +export class FakeMonerium { + readonly profiles = new Map(); + readonly addresses: MoneriumAddress[] = []; + readonly ibans: MoneriumIban[] = []; + /** Profile ids the white-label app cannot see (answered with 404 like an OAuth-only profile). */ + readonly invisibleProfiles = new Set(); + + /** Registers an approved profile whose IBAN already points to `address` on `chain`. */ + provisionApprovedProfile(profileId: string, address: string, chain: MoneriumChain, iban = "DE89370400440532013000"): void { + this.profiles.set(profileId, { + details: { state: "approved" }, + form: { state: "approved" }, + id: profileId, + kind: "personal", + name: "Ada Example", + state: "approved", + verifications: [] + }); + this.addresses.push({ address, chains: [chain], profile: profileId }); + this.ibans.push({ address, bic: "DEUTDEFF", chain, iban, name: "Monerium EMI", profile: profileId }); + } + + private matches( + entryProfile: string, + entryChain: MoneriumChain | MoneriumChain[], + filters: { chain?: MoneriumChain; profile?: string } + ) { + const chains = Array.isArray(entryChain) ? entryChain : [entryChain]; + return (!filters.profile || entryProfile === filters.profile) && (!filters.chain || chains.includes(filters.chain)); + } + + async getProfile(profileId: string): Promise { + const profile = this.profiles.get(profileId); + if (!profile || this.invisibleProfiles.has(profileId)) { + throw new MoneriumApiError({ endpoint: "/profiles/:profile", method: "GET", status: 404 }); + } + return profile; + } + + async listAddresses(filters: { chain?: MoneriumChain; profile?: string } = {}) { + return { addresses: this.addresses.filter(entry => this.matches(entry.profile, entry.chains, filters)) }; + } + + async listIbans(filters: { chain?: MoneriumChain; profile?: string } = {}) { + return { ibans: this.ibans.filter(entry => this.matches(entry.profile, entry.chain, filters)) }; + } + + async linkAddress(request: { address: string; chain: MoneriumChain; profile: string }) { + this.addresses.push({ address: request.address, chains: [request.chain], profile: request.profile }); + return { httpStatus: 201 as const }; + } + + async requestIban(request: { address: string; chain: MoneriumChain }) { + const owner = this.addresses.find(entry => entry.address.toLowerCase() === request.address.toLowerCase()); + if (!owner) throw new MoneriumApiError({ endpoint: "/ibans", method: "POST", status: 400 }); + this.ibans.push({ + address: request.address, + bic: "DEUTDEFF", + chain: request.chain, + iban: `DE${String(this.ibans.length + 1).padStart(20, "0")}`, + name: "Monerium EMI", + profile: owner.profile + }); + return { httpStatus: 202 as const }; + } + + async updateIbanDestination(iban: string, request: { address: string; chain: MoneriumChain }): Promise { + const entry = this.ibans.find(candidate => candidate.iban === iban); + if (!entry) throw new MoneriumApiError({ endpoint: "/ibans/:iban", method: "PATCH", status: 404 }); + entry.address = request.address; + entry.chain = request.chain; + } + + asService(): MoneriumApiService { + return new Proxy(this, { + get: (obj, prop) => { + if (prop in obj) return (obj as Record)[prop]; + if (prop === "then") return undefined; + throw new Error(`FakeMonerium.${String(prop)} is not implemented — extend src/test-utils/fake-world/fake-monerium.ts.`); + } + }) as unknown as MoneriumApiService; + } +} + +export function installFakeMonerium(): { fakeMonerium: FakeMonerium; restore: () => void } { + const original = MoneriumApiService.getInstance; + const fakeMonerium = new FakeMonerium(); + MoneriumApiService.getInstance = () => fakeMonerium.asService(); + return { + fakeMonerium, + restore: () => { + MoneriumApiService.getInstance = original; + } + }; +} diff --git a/apps/api/src/test-utils/fake-world/index.ts b/apps/api/src/test-utils/fake-world/index.ts index 9b17ef172..2a0f1289d 100644 --- a/apps/api/src/test-utils/fake-world/index.ts +++ b/apps/api/src/test-utils/fake-world/index.ts @@ -2,11 +2,12 @@ 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 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, FakeMykobo, FakePrices, FakeSquidRouter }; +export type { FakeAlfredpay, FakeBrla, FakeEvm, FakeMonerium, FakeMykobo, FakePrices, FakeSquidRouter }; export { installFetchGuard, uninstallFetchGuard }; export interface FakeWorld { @@ -14,6 +15,7 @@ export interface FakeWorld { mykobo: FakeMykobo; brla: FakeBrla; alfredpay: FakeAlfredpay; + monerium: FakeMonerium; prices: FakePrices; squidRouter: FakeSquidRouter; restore: () => void; @@ -29,6 +31,7 @@ export function installFakeWorld(): FakeWorld { installFetchGuard(); const { fakeEvm, restore: restoreEvm } = installFakeEvm(); const { fakeAlfredpay, fakeBrla, fakeMykobo, restore: restoreAnchors } = installFakeAnchors(); + const { fakeMonerium, restore: restoreMonerium } = installFakeMonerium(); const { fakePrices, restore: restorePrices } = installFakePrices(); const { fakeSquidRouter, restore: restoreSquidRouter } = installFakeSquidRouter(); // Not an external boundary, but fire-and-forget app work (the ramp-completion email @@ -102,6 +105,7 @@ export function installFakeWorld(): FakeWorld { alfredpay: fakeAlfredpay, brla: fakeBrla, evm: fakeEvm, + monerium: fakeMonerium, mykobo: fakeMykobo, prices: fakePrices, restore: () => { @@ -109,6 +113,7 @@ export function installFakeWorld(): FakeWorld { restoreBackgroundWorkTracking(); restoreSquidRouter(); restorePrices(); + restoreMonerium(); restoreAnchors(); restoreEvm(); uninstallFetchGuard(); 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 new file mode 100644 index 000000000..67a2820cf --- /dev/null +++ b/apps/api/src/tests/corridors/eur-onramp-monerium.scenario.test.ts @@ -0,0 +1,563 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { + EvmToken, + evmTokenConfig, + FiatToken, + Networks, + type PresignedTx, + RampDirection, + type RampPhase, + type SignedTypedData, + signUnsignedTransactions, + type UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import { Signature as EvmSignature } from "ethers"; +import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; +import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; +import { getOrCreateCustomerEntityForProfile } from "../../api/services/customer-entity.service"; +import { getBlockMetadata } from "../../api/services/phases/blocks/core/metadata"; +import { MoneriumIssueContext, MONERIUM_ISSUE_NETWORKS } from "../../api/services/phases/blocks/phases/monerium-issue/simulation"; +import { moneriumPermitAbi } from "../../api/services/phases/blocks/phases/monerium-self-transfer/contract"; +import { SquidRouterSwapContext } from "../../api/services/phases/blocks/phases/squid-router-swap/simulation"; +import { + POLYGON_EURE, + POLYGON_EURE_USDC_FEE, + POLYGON_EURE_USDC_POOL, + POLYGON_UNISWAP_V3_FACTORY, + POLYGON_UNISWAP_V3_ROUTER, + POLYGON_USDC +} from "../../api/services/phases/blocks/phases/uniswap-v3-fixed-swap/contract"; +import { UniswapV3FixedSwapContext } from "../../api/services/phases/blocks/phases/uniswap-v3-fixed-swap/simulation"; +import phaseProcessor from "../../api/services/phases/phase-processor"; +import { config } from "../../config/vars"; +import FinancialOperation from "../../models/financialOperation.model"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; +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 { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../../test-utils/test-app"; + +function requireToken(network: Networks.Arbitrum, token: EvmToken) { + const details = evmTokenConfig[network][token]; + if (!details) throw new Error(`${token} token config missing for ${network}`); + return details; +} +const USDC_ON_ARBITRUM = requireToken(Networks.Arbitrum, EvmToken.USDC).erc20AddressSourceChain as `0x${string}`; +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", + "fundEphemeral", + "moneriumOnrampSelfTransfer", + "uniswapApprove", + "uniswapSwap", + "distributeFees", + "subsidizePostSwap", + "squidRouterSwap", + "squidRouterPay", + "finalSettlementSubsidy", + "destinationTransfer", + "complete" +]; + +interface CorridorSetup { + rampId: string; + quoteId: string; + userId: string; + owner: PrivateKeyAccount; + ephemeral: PrivateKeyAccount; + destination: `0x${string}`; + /** Raw (18-decimal) EURe the mint must credit the owner with (input minus issue fee). */ + issuedRaw: bigint; + /** Raw (6-decimal) USDC the fixed Uniswap swap yields on Polygon. */ + swapOutputRaw: bigint; + /** Raw (6-decimal) USDC the squid bridge delivers on Arbitrum. */ + bridgedAmountRaw: bigint; + /** Raw (6-decimal) USDC the presigned destination transfer pays out. */ + amountRaw: bigint; + signedTransferFrom: `0x${string}`; + signedUniswapApprove: `0x${string}`; + signedUniswapSwap: `0x${string}`; + signedSquidSwap: `0x${string}`; + signedTransfer: `0x${string}`; + ibanPaymentData: { bic: string; iban: string; receiverName: string; reference: string }; +} + +/** + * Corridor scenario tests for the production EUR onramp (SEPA → EURe minted to + * the Monerium-linked owner on Polygon → owner permit + ephemeral transferFrom + * → fixed Uniswap V3 EURe/USDC swap → fees and subsidy → SquidRouter bridge → + * USDC on Arbitrum). Quote, registration, and presign submission go through + * the real HTTP API; the REAL PhaseProcessor drives every phase against the + * fake external world. This is the hermetic coverage the removed EUR + * kill-switch required. + */ +describe("EUR onramp Monerium corridor (sepa → Polygon mint+swap → USDC on Arbitrum)", () => { + let world: FakeWorld; + let auth: { restore: () => void }; + let chainIdShim: { restore: () => void }; + let app: TestApp; + const originalIssueFee = config.monerium.issueFeeEur; + + beforeAll(async () => { + world = installFakeWorld(); + chainIdShim = installChainIdShim(); + auth = installFakeSupabaseAuth(); + await setupTestDatabase(); + app = await startTestApp(); + config.monerium.issueFeeEur = "1"; + }); + + afterAll(async () => { + config.monerium.issueFeeEur = originalIssueFee; + await app?.close(); + auth?.restore(); + chainIdShim?.restore(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + await updatePartnerPricing("vortex", RampDirection.BUY, { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }); + world.evm.failNextSends = 0; + world.evm.setFeeEstimate(Networks.Arbitrum, 1_000_000_000n); + world.evm.setFeeEstimate(Networks.Polygon, 1_000_000_000n); + world.evm.onTransaction = undefined; + world.evm.contractAddresses.clear(); + world.evm.strictReceipts = true; + world.monerium.profiles.clear(); + world.monerium.addresses.length = 0; + world.monerium.ibans.length = 0; + world.squidRouter.bridgeStatus = "success"; + world.squidRouter.computeToAmount = params => params.fromAmount; + world.squidRouter.computeToAmountMin = params => world.squidRouter.computeToAmount(params); + world.squidRouter.computeToAmountUsd = params => new Big(params.fromAmount).div(1_000_000).toFixed(); + world.squidRouter.toTokenDecimals = 6; + // The pinned Polygon EURe/USDC deployment verifies against these constants; per-ramp + // allowance and nonce reads are layered on top by scriptHappyWorld. + world.evm.onReadContract = (_network, params) => { + switch (params.functionName) { + case "token0": + return POLYGON_EURE; + case "token1": + return POLYGON_USDC; + case "fee": + return POLYGON_EURE_USDC_FEE; + case "factory": + return POLYGON_UNISWAP_V3_FACTORY; + case "getPool": + return POLYGON_EURE_USDC_POOL; + default: + return undefined; + } + }; + world.evm.onSimulateContract = (_network, params) => { + if (params.functionName === "quoteExactInputSingle") { + const amountIn = params.args?.[3] as bigint; + return (amountIn * EURE_USDC_RATE_MICRO) / 10n ** 18n; + } + return undefined; + }; + }); + + async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: "sepa", + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status, `quote creation failed: ${await response.clone().text()}`).toBe(201); + return (await response.json()) as { id: string; outputAmount: string }; + } + + /** A Vortex user bound to an approved Monerium profile whose Polygon IBAN points at `owner`. */ + async function bindMoneriumUser(owner: PrivateKeyAccount): Promise { + const user = await createTestUser(); + const entity = await getOrCreateCustomerEntityForProfile(user.id); + await ProviderCustomer.create({ + customerEntityId: entity.id, + customerType: entity.type, + provider: "monerium", + providerCustomerId: PROFILE_ID, + rail: "eur", + status: VerificationStatus.Approved, + statusExternal: "approved" + }); + world.monerium.provisionApprovedProfile(PROFILE_ID, owner.address, "polygon"); + return user.id; + } + + async function registerViaApi( + quoteId: string, + userId: string, + ephemeral: PrivateKeyAccount, + owner: PrivateKeyAccount, + destination: `0x${string}` + ): Promise { + return app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: destination, walletAddress: owner.address }, + quoteId, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { Authorization: `Bearer ${testUserToken(userId)}`, "Content-Type": "application/json" }, + method: "POST" + }); + } + + function blueprintOf(unsignedTxs: UnsignedTx[], phase: RampPhase, signer?: string): UnsignedTx { + const blueprint = unsignedTxs.find( + tx => tx.phase === phase && (!signer || tx.signer.toLowerCase() === signer.toLowerCase()) + ); + expect(blueprint, `missing ${phase} blueprint in persisted ramp state`).toBeDefined(); + return blueprint as UnsignedTx; + } + + /** Signs the owner's EIP-712 permit the way the SDK/widget do (attachSignatures). */ + async function signOwnerPermit(blueprint: UnsignedTx, owner: PrivateKeyAccount): Promise { + const unsigned = blueprint.txData as SignedTypedData; + const hex = await owner.signTypedData({ + domain: unsigned.domain, + message: unsigned.message, + primaryType: unsigned.primaryType, + types: unsigned.types + } as Parameters[0]); + const { r, s, v } = EvmSignature.from(hex); + return { + ...blueprint, + txData: [{ ...unsigned, signature: { deadline: Number(unsigned.message.deadline), r: r as `0x${string}`, s: s as `0x${string}`, v } }] + } as PresignedTx; + } + + /** + * Quote + registration through the HTTP API, then the ephemeral blueprints are + * signed with the shared production signer and the owner permit with the owner + * key, and the full set goes through the real /v1/ramp/update path. + */ + async function setUpRegisteredRamp(): Promise { + const ephemeralSecret = generatePrivateKey(); + const ephemeral = privateKeyToAccount(ephemeralSecret); + const owner = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + const userId = await bindMoneriumUser(owner); + // The owner already holds some EURe; the mint is attributed by balance delta. + world.evm.setErc20Balance(Networks.Polygon, EURE_ON_POLYGON, owner.address, parseUnits("5", 18)); + + const quote = await createQuoteViaApi(); + const registerResponse = await registerViaApi(quote.id, userId, ephemeral, owner, destination); + expect(registerResponse.status, `registration failed: ${await registerResponse.clone().text()}`).toBe(201); + const registered = (await registerResponse.json()) as { id: string; ibanPaymentData?: unknown }; + // Payment instructions stay hidden until every signature (owner permit included) validates. + expect(registered.ibanPaymentData).toBeUndefined(); + + const persistedQuote = await QuoteTicket.findByPk(quote.id); + if (!persistedQuote) throw new Error("Quote not found after creation"); + const issue = getBlockMetadata(persistedQuote.metadata, MoneriumIssueContext); + const uniswap = getBlockMetadata(persistedQuote.metadata, UniswapV3FixedSwapContext); + const squid = getBlockMetadata(persistedQuote.metadata, SquidRouterSwapContext); + const issuedRaw = BigInt(issue.issue.outputAmountRaw); + const swapOutputRaw = BigInt(uniswap.outputAmountRaw); + const bridgedAmountRaw = BigInt(squid.outputAmountRaw); + expect(issuedRaw).toBe(parseUnits("99", 18)); + expect(swapOutputRaw).toBeGreaterThan(0n); + expect(bridgedAmountRaw).toBeGreaterThan(0n); + + const rampState = await RampState.findByPk(registered.id); + if (!rampState) throw new Error("Ramp state not found after registration"); + const unsignedTxs = rampState.unsignedTxs ?? []; + const permitBlueprint = blueprintOf(unsignedTxs, "moneriumOnrampSelfTransfer", owner.address); + const transferFromBlueprint = blueprintOf(unsignedTxs, "moneriumOnrampSelfTransfer", ephemeral.address); + const transferBlueprint = blueprintOf(unsignedTxs, "destinationTransfer"); + expect(permitBlueprint.network).toBe(Networks.Polygon); + expect(transferFromBlueprint.network).toBe(Networks.Polygon); + expect(blueprintOf(unsignedTxs, "uniswapSwap").network).toBe(Networks.Polygon); + expect(transferBlueprint.network).toBe(Networks.Arbitrum); + + // Like the SDK, the shared signer only receives the ephemeral-owned blueprints. + const ephemeralPresigned = await signUnsignedTransactions( + unsignedTxs.filter(tx => tx.signer.toLowerCase() === ephemeral.address.toLowerCase()), + { evmEphemeral: { address: ephemeral.address, secret: ephemeralSecret } } + ); + const presignedTxs = [...ephemeralPresigned, await signOwnerPermit(permitBlueprint, owner)]; + const signedFor = (phase: RampPhase) => { + const transaction = ephemeralPresigned.find(tx => tx.phase === phase); + expect(transaction, `production signer omitted ${phase}`).toBeDefined(); + return transaction?.txData as `0x${string}`; + }; + + const updateResponse = await app.request("/v1/ramp/update", { + body: JSON.stringify({ presignedTxs, rampId: registered.id }), + headers: { Authorization: `Bearer ${testUserToken(userId)}`, "Content-Type": "application/json" }, + method: "POST" + }); + expect(updateResponse.status, `ramp update failed: ${await updateResponse.clone().text()}`).toBe(200); + const updated = (await updateResponse.json()) as { ibanPaymentData?: CorridorSetup["ibanPaymentData"] }; + if (!updated.ibanPaymentData) throw new Error("ibanPaymentData was not released after the complete presign"); + + const transferTxData = transferBlueprint.txData as unknown as { data: `0x${string}` }; + const { args } = decodeFunctionData({ abi: erc20Abi, data: transferTxData.data }); + + return { + amountRaw: (args as [string, bigint])[1], + bridgedAmountRaw, + destination, + ephemeral, + ibanPaymentData: updated.ibanPaymentData, + issuedRaw, + owner, + quoteId: quote.id, + rampId: registered.id, + signedSquidSwap: signedFor("squidRouterSwap"), + signedTransfer: signedFor("destinationTransfer"), + signedTransferFrom: signedFor("moneriumOnrampSelfTransfer"), + signedUniswapApprove: signedFor("uniswapApprove"), + signedUniswapSwap: signedFor("uniswapSwap"), + swapOutputRaw, + userId + }; + } + + /** + * Scripts the fake world so every phase succeeds on its first check: + * - Monerium has minted the issued EURe to the owner (balance delta), + * - the owner's ERC-2612 nonce and allowance follow the permit and transferFrom, + * - the ephemeral's router allowance follows the Uniswap approve and swap, which + * converts EURe into Polygon USDC at the quoted rate, + * - the bridge credits Arbitrum USDC, and every raw ERC-20 transfer is applied + * to the ledger so fees, subsidy, and the final payout land. + */ + function scriptHappyWorld(setup: CorridorSetup): { consumeOwnerNonce: () => void; permitCalls: () => number } { + const owner = setup.owner.address.toLowerCase(); + const ephemeral = setup.ephemeral.address.toLowerCase(); + let ownerNonce = 0n; + let permitAllowance = 0n; + let routerAllowance = 0n; + let permitCalls = 0; + const verifyDeployment = world.evm.onReadContract; + + world.evm.setErc20Balance( + Networks.Polygon, + EURE_ON_POLYGON, + setup.owner.address, + world.evm.erc20Balance(Networks.Polygon, EURE_ON_POLYGON, setup.owner.address) + setup.issuedRaw + ); + world.evm.setNativeBalance(Networks.Arbitrum, setup.ephemeral.address, 0n); + + world.evm.onReadContract = (network, params) => { + if (params.address.toLowerCase() === EURE_ON_POLYGON.toLowerCase()) { + const [first, second] = (params.args ?? []) as string[]; + if (params.functionName === "nonces" && first?.toLowerCase() === owner) return ownerNonce; + if (params.functionName === "allowance" && first?.toLowerCase() === owner && second?.toLowerCase() === ephemeral) { + return permitAllowance; + } + if ( + params.functionName === "allowance" && + first?.toLowerCase() === ephemeral && + second?.toLowerCase() === POLYGON_UNISWAP_V3_ROUTER.toLowerCase() + ) { + return routerAllowance; + } + } + return verifyDeployment?.(network, params); + }; + + world.evm.onTransaction = tx => { + // Treasury funding of the ephemeral's gas on either chain. + if (!tx.serialized && tx.to?.toLowerCase() === ephemeral && tx.value !== undefined && !tx.data) { + world.evm.setNativeBalance(tx.network, setup.ephemeral.address, world.evm.nativeBalance(tx.network, setup.ephemeral.address) + tx.value); + return; + } + // The treasury-relayed owner permit consumes the owner's nonce and sets the exact allowance. + if (!tx.serialized && tx.to?.toLowerCase() === EURE_ON_POLYGON.toLowerCase() && tx.data) { + const decoded = decodeFunctionData({ abi: moneriumPermitAbi, data: tx.data as `0x${string}` }); + const [, spender, value] = decoded.args as readonly [string, string, bigint, bigint, number, string, string]; + expect(spender.toLowerCase()).toBe(ephemeral); + permitCalls += 1; + ownerNonce += 1n; + permitAllowance = value; + return; + } + if (tx.serialized === setup.signedTransferFrom) { + world.evm.setErc20Balance( + Networks.Polygon, + EURE_ON_POLYGON, + setup.owner.address, + world.evm.erc20Balance(Networks.Polygon, EURE_ON_POLYGON, setup.owner.address) - setup.issuedRaw + ); + world.evm.setErc20Balance(Networks.Polygon, EURE_ON_POLYGON, setup.ephemeral.address, setup.issuedRaw); + permitAllowance -= setup.issuedRaw; + return; + } + if (tx.serialized === setup.signedUniswapApprove) { + routerAllowance = setup.issuedRaw; + return; + } + if (tx.serialized === setup.signedUniswapSwap) { + world.evm.setErc20Balance(Networks.Polygon, EURE_ON_POLYGON, setup.ephemeral.address, 0n); + world.evm.setErc20Balance(Networks.Polygon, POLYGON_USDC, setup.ephemeral.address, setup.swapOutputRaw); + routerAllowance = 0n; + return; + } + if (tx.serialized === setup.signedSquidSwap) { + world.evm.setErc20Balance( + Networks.Arbitrum, + USDC_ON_ARBITRUM, + setup.ephemeral.address, + world.evm.erc20Balance(Networks.Arbitrum, USDC_ON_ARBITRUM, setup.ephemeral.address) + setup.bridgedAmountRaw + ); + return; + } + const parsed = tx.serialized ? parseTransaction(tx.serialized as `0x${string}`) : { data: tx.data, to: tx.to }; + if (!parsed.to || !parsed.data) return; + let decoded: { functionName: string; args: readonly unknown[] }; + try { + decoded = decodeFunctionData({ abi: erc20Abi, data: parsed.data as `0x${string}` }); + } catch { + return; + } + if (decoded.functionName !== "transfer") return; + const [recipient, amount] = decoded.args as [`0x${string}`, bigint]; + world.evm.setErc20Balance(tx.network, parsed.to, recipient, world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount); + }; + + return { + consumeOwnerNonce: () => { + ownerNonce += 1n; + }, + permitCalls: () => permitCalls + }; + } + + function submissionsOf(signedTx: `0x${string}`): number { + return world.evm.sentTransactions.filter(tx => tx.serialized === signedTx).length; + } + + it( + "mints to the owner, pulls the exact permit amount, swaps, bridges, and pays out on Arbitrum", + async () => { + const setup = await setUpRegisteredRamp(); + const script = scriptHappyWorld(setup); + + expect(setup.ibanPaymentData).toMatchObject({ bic: "DEUTDEFF", iban: "DE89370400440532013000", receiverName: "Monerium EMI" }); + expect(setup.ibanPaymentData.reference).toMatch(/^VTX[0-9A-F]{32}$/); + const registrationRoute = world.squidRouter.requestedRoutes.find( + route => + route.fromToken.toLowerCase() === POLYGON_USDC.toLowerCase() && + route.toToken.toLowerCase() === USDC_ON_ARBITRUM.toLowerCase() && + route.fromChain === "137" && + route.toChain === "42161" + ); + expect(registrationRoute, "registration should request a Polygon→Arbitrum USDC route").toBeDefined(); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.errorLogs).toEqual([]); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect((await QuoteTicket.findByPk(setup.quoteId))?.status).toBe("consumed"); + + // One relayed permit, one transferFrom, one approve+swap, one bridge, one payout. + expect(script.permitCalls()).toBe(1); + expect(submissionsOf(setup.signedTransferFrom)).toBe(1); + expect(submissionsOf(setup.signedUniswapApprove)).toBe(1); + expect(submissionsOf(setup.signedUniswapSwap)).toBe(1); + expect(submissionsOf(setup.signedSquidSwap)).toBe(1); + expect(submissionsOf(setup.signedTransfer)).toBe(1); + // Only the quoted post-fee amount left the owner; the pre-existing balance stays. + expect(world.evm.erc20Balance(Networks.Polygon, EURE_ON_POLYGON, setup.owner.address)).toBe(parseUnits("5", 18)); + expect(world.evm.erc20Balance(Networks.Arbitrum, USDC_ON_ARBITRUM, setup.destination)).toBe(setup.amountRaw); + const selfTransfer = final?.state.blockState?.moneriumSelfTransfer as { permitTxHash?: string; transferTxHash?: string }; + expect(selfTransfer.permitTxHash).toBeTruthy(); + expect(selfTransfer.transferTxHash).toBeTruthy(); + }, + 30000 + ); + + it( + "pauses for reconciliation when the owner spent the permit nonce elsewhere, moving no funds", + async () => { + const setup = await setUpRegisteredRamp(); + const script = scriptHappyWorld(setup); + // The owner signed another permit after registration, so the ramp's permit is stale. + script.consumeOwnerNonce(); + + await phaseProcessor.processRamp(setup.rampId); + + const paused = await RampState.findByPk(setup.rampId); + expect(paused?.currentPhase).toBe("moneriumOnrampSelfTransfer"); + expect(paused?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(paused?.errorLogs.at(-1)?.error).toContain("nonce-consumed"); + expect(paused?.errorLogs.at(-1)?.recoverable).toBe(true); + expect(script.permitCalls()).toBe(0); + expect(submissionsOf(setup.signedTransferFrom)).toBe(0); + expect(world.evm.erc20Balance(Networks.Polygon, EURE_ON_POLYGON, setup.owner.address)).toBe( + parseUnits("5", 18) + setup.issuedRaw + ); + }, + 30000 + ); + + it("refuses registration when the linked wallet is a contract or the profile is unbound", async () => { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const owner = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + + const unboundUser = await createTestUser(); + const unbound = await registerViaApi((await createQuoteViaApi()).id, unboundUser.id, ephemeral, owner, destination); + expect(unbound.status).toBe(403); + expect(await unbound.json()).toMatchObject({ type: "MONERIUM_ONBOARDING_REQUIRED" }); + + const userId = await bindMoneriumUser(owner); + world.evm.contractAddresses.add(owner.address.toLowerCase()); + const quote = await createQuoteViaApi(); + const contractWallet = await registerViaApi(quote.id, userId, ephemeral, owner, destination); + expect(contractWallet.status).toBe(400); + expect((await QuoteTicket.findByPk(quote.id))?.status).toBe("pending"); + expect(await FinancialOperation.count()).toBe(0); + }); +}); diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 6b462e8ec..08df62311 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -19,7 +19,7 @@ together with the shared test harness (`apps/api/src/test-utils`) — see "How t |---|---|---|---| | 1. Unit | Pure logic: helpers, token configs, SDK handlers | each package, next to source | `bun test` (Vitest for frontend) | | 2. API integration | Real Express + real Postgres + fake external world, driven over HTTP; incl. the quote pricing goldens (`quote-pricing.golden.test.ts`) and the HTTP surface tests (auth OTP flow, webhooks, ramp history, public routes; `http-surface.invariants.test.ts`) | `apps/api/src/tests/` | `bun test` | -| 3. Corridor scenarios | Phase processor end-to-end per corridor against the fake world: BRL and Alfredpay corridors plus persisted Mykobo on/offramp recovery scenarios. The active Monerium path currently has block-level registration, settlement, self-transfer, Uniswap, topology, and quote-selection coverage but no full fake-world corridor scenario. | `apps/api/src/tests/corridors/` and block tests | `bun test` | +| 3. Corridor scenarios | Phase processor end-to-end per corridor against the fake world: BRL, Alfredpay, and Monerium EUR corridors plus persisted Mykobo on/offramp recovery scenarios. | `apps/api/src/tests/corridors/` and block tests | `bun test` | | 4. SDK contract | Real SDK against the real API in-process: BRL onramp lifecycle (`sdk-contract.test.ts`), the SELL/user-transaction surface — offramp lifecycle via submitUserTransactions, updateRamp, getQuote, listAlfredpayFiatAccounts (`sdk-contract.offramp.test.ts`) — and full per-currency lifecycles for all four Alfredpay currencies in both directions: SELL offramp lifecycles for USD/ach, MXN/spei, COP/ach and ARS/cbu (`sdk-contract.alfredpay-offramp.test.ts`) and BUY onramp lifecycles for MXN/spei, USD/ach, COP/ach and ARS/cbu (`sdk-contract.alfredpay-onramp.test.ts`) | `apps/api/src/tests/sdk-contract*.test.ts` | `bun test` | | 5. Frontend | XState machine tests, actor tests (register/sign/start/KYC-routing against MSW with mocked wallet seams), component tests (RTL + MSW + mock wagmi) | `apps/frontend/src` | Vitest | | 6. E2E | Critical Playwright journeys with a mock wallet: BRL on/offramp plus parameterized Alfredpay journeys for all four currencies in both directions. The dashboard runs its own Playwright config covering auth, account selection, onboarding/KYC/KYB, recipient invitations, the MXN offramp journey, and BRL/MXN/USD/COP/ARS onramps. The nightly job also smoke-tests deployed staging and production BUY/SELL quotes through a cross-chain Squid corridor. | `apps/frontend/e2e/`, `apps/dashboard/e2e/`, `apps/api/src/tests/deployed-quotes.e2e.test.ts` | Playwright + Bun (non-blocking) | @@ -77,7 +77,7 @@ Legend: ✅ directly tested · ◐ covered only via shared code/another corridor | COP (Alfredpay / ACH) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | | ARS (Alfredpay / CBU) | BUY | ✅ | ✅ | ✅ | ✅ + limit breach | ✅ | ✅ | ✅ | | ARS (Alfredpay / CBU) | SELL | ✅ | ✅ | ✅ | ✅ + limit breach | ✅¹ | ✅ | ✅ | -| EUR (Monerium / SEPA) | BUY | ❌³ | ✅ block | ✅ block | ✅ owner/baseline/permit/route | ✅ block | ❌ | ❌ | +| EUR (Monerium / SEPA) | BUY | ✅ | ✅³ | ✅ | ✅ owner/baseline/permit/route, unbound profile, contract wallet, live-ramp/IBAN-move guards | ✅ | ✅ | ❌ | | EUR / SEPA | SELL | — | — | — | ✅ quote rejection | — | 🚫 | 🚫 | | AssetHub (BRL BUY → USDC; USDC SELL → Pix) | both | ❌ deferred | ❌ | ❌ | ❌ | — | ❌ | ❌ | @@ -87,15 +87,17 @@ untested — it needs relayer-contract execution the fake world doesn't model. ² BRL BUY cross-chain (pix → Base mint + Nabla swap → squid → USDC-on-Arbitrum) is happy-path only; failure modes of the shared squid handlers are covered by the MXN cross-chain and BRL cross-chain offramp scenarios. -³ Active Monerium coverage is split across focused block tests. The retained -`corridors/eur-*.scenario.test.ts` files now seed identity-bearing Mykobo metadata directly and -verify only persisted legacy recovery. Add a full Monerium fake-world quote→register→execute -scenario before claiming end-to-end corridor coverage. Monerium's sandbox mints on testnets while -the flow is pinned to Polygon mainnet, so the pay-in is not sandbox-verifiable either. +³ `corridors/eur-onramp-monerium.scenario.test.ts` drives quote → register → update → the real +processor over the fake world (fake Monerium, scripted EURe mint, permit/transferFrom, Uniswap, +bridge). The mint-timeout transient is covered at block level (the executor's five-minute poll is +not shortened in scenarios); the consumed-nonce pause is covered in the scenario. The retained +`corridors/eur-onramp.scenario.test.ts` / `eur-offramp.scenario.test.ts` seed identity-bearing +Mykobo metadata directly and verify only persisted legacy recovery. Monerium's sandbox mints on +testnets while the flow is pinned to Polygon mainnet, so the pay-in is not sandbox-verifiable. **Gaps at a glance** (everything not ✅ above): the Alfredpay permit/TokenRelayer cross-chain -SELL variant is untested (no-permit fallback is); the active EUR onramp lacks SDK, E2E, and full -fake-world corridor coverage; EUR offramp is intentionally unavailable; the +SELL variant is untested (no-permit fallback is); the active EUR onramp lacks an E2E widget +journey; EUR offramp is intentionally unavailable; the AssetHub corridors are runtime-disabled and deliberately deferred (see the decision note under Infrastructure — revisit only if the product restores them). @@ -106,8 +108,8 @@ Infrastructure — revisit only if the product restores them). All external boundaries are stubbed at the existing service seams — the singletons the production code already goes through: -- **Anchors/APIs**: `BrlaApiService` (Avenia), `MykoboApiService`, Alfredpay, SquidRouter, - price feeds. Each fake is configurable per test: succeed with given amounts, return malformed +- **Anchors/APIs**: `BrlaApiService` (Avenia), `MykoboApiService`, Alfredpay, Monerium + (white-label reads, wallet link, IBAN request/move), SquidRouter, price feeds. Each fake is configurable per test: succeed with given amounts, return malformed data, time out, or fail N times then succeed (for retry testing). - **Chains**: faked at the `EvmClientManager` and Pendulum `apiManager` seams with an in-memory balance ledger. Phase handlers genuinely poll balances and observe transfers; tests script the @@ -247,9 +249,11 @@ SEPA/EUR BUY is cataloged through Monerium; SELL returns public `400`. Focused t white-label/OAuth identity resolution, wallet-link and IBAN-move rules, profile-derived registration, Polygon EURe baseline persistence, balance-delta execution, exact self-transfer, pinned Uniswap conversion, flow topology, quote selection, and SELL rejection; the SDK suite -covers EUR onramp registration and the returned owner permit. The old Mykobo corridor scenarios persist legacy metadata directly to exercise -recovery without reconnecting Mykobo to quote creation. A complete Monerium fake-world corridor, -SDK contract, and E2E journey remain open coverage gaps. +covers EUR onramp registration and the returned owner permit. The Monerium corridor scenario +(`corridors/eur-onramp-monerium.scenario.test.ts`) drives the full quote → register → update → +execute path over the fake world. The old Mykobo corridor scenarios persist legacy metadata +directly to exercise recovery without reconnecting Mykobo to quote creation. An E2E widget +journey remains an open coverage gap. ### Live tests From 002c3f46864b223098b7c50d42fe361b79bc91b9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 12:07:47 +0200 Subject: [PATCH 28/42] test(api): cover the Uniswap route rejections and swap failure branches Every field of the fixed EURe/USDC approval and swap now has a negative case, all six executor failure branches are exercised (deadline, missing input, allowance mismatch before and after, soft and hard minimum), the unconfigured-issue-fee 503 is asserted, and the Monerium service test restores the config it mutates. --- .../monerium/monerium.service.test.ts | 4 + ...um-onramp-polygon-cross-chain.flow.test.ts | 22 ++ .../__tests__/uniswap-v3-fixed-swap.test.ts | 274 +++++++++++++++++- 3 files changed, 298 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/monerium/monerium.service.test.ts b/apps/api/src/api/services/monerium/monerium.service.test.ts index d136c4cef..03fb1d5f6 100644 --- a/apps/api/src/api/services/monerium/monerium.service.test.ts +++ b/apps/api/src/api/services/monerium/monerium.service.test.ts @@ -58,6 +58,7 @@ let service: typeof import("./monerium.service"); let controller: typeof import("../../controllers/monerium.controller"); let cache: typeof import("../index").cache; let config: typeof import("../../../config/vars").config; +let originalMoneriumConfig: typeof import("../../../config/vars").config.monerium; const originalFetch = globalThis.fetch; function jsonResponse(value: unknown): Response { @@ -69,6 +70,8 @@ beforeAll(async () => { controller = await import("../../controllers/monerium.controller"); ({ cache } = await import("../index")); ({ config } = await import("../../../config/vars")); + // Bun runs every test file in one process; the URL/client overrides below must not outlive this file. + originalMoneriumConfig = { ...config.monerium }; }); beforeEach(() => { @@ -89,6 +92,7 @@ afterEach(() => { }); afterAll(() => { + Object.assign(config.monerium, originalMoneriumConfig); mock.module("../../../config/database", () => ({ ...databaseReal })); mock.module("../../../models/kycCase.model", () => ({ ...kycCaseReal })); mock.module("../../../models/providerCustomer.model", () => ({ ...providerCustomerReal })); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/monerium-onramp-polygon-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/monerium-onramp-polygon-cross-chain.flow.test.ts index 087a2c20c..3c5067a04 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/monerium-onramp-polygon-cross-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/monerium-onramp-polygon-cross-chain.flow.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, type RampPhase } from "@vortexfi/shared"; +import { config } from "../../../../../config/vars"; import { assemblePhaseFlow } from "../core/phase-flow"; import { getBlockExecutorFlows, resolveBlockFlow } from "../flows/catalog"; import { makeMoneriumOnrampPolygonCrossChainFlow } from "../flows/monerium-onramp-polygon-cross-chain"; @@ -55,4 +56,25 @@ describe("Polygon Monerium cross-chain flow", () => { "No block flow mapped" ); }); + + it("refuses to quote with a public 503 while the issue fee is unconfigured", () => { + const request = { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Arbitrum, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Arbitrum + }; + const originalIssueFee = config.monerium.issueFeeEur; + config.monerium.issueFeeEur = undefined; + try { + expect(() => resolveBlockFlow(request)).toThrow( + expect.objectContaining({ isPublic: true, message: "Monerium issue fee is not configured", status: 503 }) + ); + } finally { + config.monerium.issueFeeEur = originalIssueFee; + } + }); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts index 0f44c567c..e4ef2ffd3 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts @@ -1,9 +1,10 @@ import { afterAll, describe, expect, it, mock } from "bun:test"; import { EphemeralAccountType, type EvmTransactionData, EvmToken, Networks, type PresignedTx } from "@vortexfi/shared"; import Big from "big.js"; -import { decodeFunctionData, erc20Abi } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; +import { decodeFunctionData, encodeFunctionData, erc20Abi, keccak256 } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import QuoteTicket from "../../../../../models/quoteTicket.model"; +import { ReconciliationRequiredPhaseError, RecoverablePhaseError } from "../../../../errors/phase-error"; import * as financialOperationNamespace from "../core/financial-operation"; import { allocateNonces } from "../core/prepare"; import { @@ -87,6 +88,54 @@ async function sign(unsigned: PresignedTx): Promise { return { ...unsigned, txData: serialized }; } +type SwapParams = { + amountIn: bigint; + amountOutMinimum: bigint; + deadline: bigint; + fee: number; + recipient: `0x${string}`; + sqrtPriceLimitX96: bigint; + tokenIn: `0x${string}`; + tokenOut: `0x${string}`; +}; + +/** Prepares the fixed route for the shared ephemeral at `now`, returning blueprints and the persisted state. */ +async function prepared(now = Date.now()) { + const simulated = await simulation(); + const result = await prepareUniswapV3FixedSwapTxs( + { + accounts: { EVM: { address: ephemeral.address, type: EphemeralAccountType.EVM } }, + globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } } } as never, + ownMetadata: simulated.metadata, + ownRegistrationFacts: undefined, + quote: {} as never + }, + { now: () => now, probeFees: async () => ({ maxFeePerGas: 2_000_000_000n, maxPriorityFeePerGas: 1_000_000n }) } + ); + const [approval, swap] = allocateNonces(result.intents); + const state = result.state as UniswapV3FixedSwapPreparation; + const expectation = { + amountInRaw: inputAmountRaw, + deadline: state.deadline, + hardMinimumOutputRaw: state.hardMinimumOutputRaw, + signer: ephemeral.address + }; + return { approval, expectation, simulated, state, swap }; +} + +function withData(blueprint: PresignedTx, patch: Partial): PresignedTx { + return { ...blueprint, txData: { ...(blueprint.txData as EvmTransactionData), ...patch } }; +} + +function swapParamsOf(blueprint: PresignedTx): SwapParams { + const decoded = decodeFunctionData({ abi: uniswapV3RouterAbi, data: (blueprint.txData as EvmTransactionData).data as `0x${string}` }); + return decoded.args[0] as SwapParams; +} + +function encodeSwap(params: SwapParams): `0x${string}` { + return encodeFunctionData({ abi: uniswapV3RouterAbi, args: [params], functionName: "exactInputSingle" }); +} + describe("fixed Polygon Uniswap V3 EURe/USDC swap", () => { it("quotes the pinned pool without exposing EURE through the public token registry", async () => { const result = await simulation(); @@ -260,3 +309,224 @@ describe("fixed Polygon Uniswap V3 EURe/USDC swap", () => { expect(outputBalance).toBe(116_000_000n); }); }); + +describe("fixed Polygon Uniswap V3 route validation", () => { + const stranger = privateKeyToAccount(generatePrivateKey()); + const OTHER_TOKEN = "0x1111111111111111111111111111111111111111" as const; + + it.each<[string, (approval: PresignedTx) => PresignedTx | Promise]>([ + ["a different spender", approval => + withData(approval, { + data: encodeFunctionData({ abi: erc20Abi, args: [OTHER_TOKEN, BigInt(inputAmountRaw)], functionName: "approve" }) + })], + ["an approval above the fixed input", approval => + withData(approval, { + data: encodeFunctionData({ abi: erc20Abi, args: [POLYGON_UNISWAP_V3_ROUTER, BigInt(inputAmountRaw) + 1n], functionName: "approve" }) + })], + ["a token other than EURe", approval => withData(approval, { to: OTHER_TOKEN })], + ["a transfer instead of an approval", approval => + withData(approval, { + data: encodeFunctionData({ abi: erc20Abi, args: [POLYGON_UNISWAP_V3_ROUTER, BigInt(inputAmountRaw)], functionName: "transfer" }) + })], + ["a native value attached", approval => withData(approval, { value: "1" })], + ["a signer other than the ephemeral", async approval => { + const txData = approval.txData as EvmTransactionData; + const serialized = await stranger.signTransaction({ + chainId: 137, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas as string), + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas as string), + nonce: approval.nonce, + to: txData.to as `0x${string}`, + type: "eip1559", + value: 0n + }); + return { ...approval, signer: stranger.address, txData: serialized }; + }] + ])("rejects an approval with %s", async (_label, mutate) => { + const { approval, expectation } = await prepared(); + const mutated = await mutate(approval); + const signed = typeof mutated.txData === "string" ? mutated : await sign(mutated); + await expect(validateUniswapApproval(signed, expectation)).rejects.toThrow(); + }); + + it.each<[string, (params: SwapParams) => Partial]>([ + ["tokenIn", () => ({ tokenIn: OTHER_TOKEN })], + ["tokenOut", () => ({ tokenOut: OTHER_TOKEN })], + ["fee tier", () => ({ fee: 3000 })], + ["recipient", () => ({ recipient: OTHER_TOKEN })], + ["deadline", params => ({ deadline: params.deadline + 1n })], + ["amountIn", params => ({ amountIn: params.amountIn + 1n })], + ["amountOutMinimum", params => ({ amountOutMinimum: params.amountOutMinimum - 1n })], + ["sqrtPriceLimitX96", () => ({ sqrtPriceLimitX96: 1n })] + ])("rejects a swap whose %s differs from the fixed route", async (_label, mutate) => { + const { expectation, swap } = await prepared(); + const params = swapParamsOf(swap); + const signed = await sign(withData(swap, { data: encodeSwap({ ...params, ...mutate(params) }) })); + await expect(validateUniswapSwap(signed, expectation)).rejects.toThrow("does not match the fixed Polygon EURe/USDC route"); + }); + + it("rejects a swap sent to a router other than the pinned one", async () => { + const { expectation, swap } = await prepared(); + const signed = await sign(withData(swap, { to: OTHER_TOKEN })); + await expect(validateUniswapSwap(signed, expectation)).rejects.toThrow("signer or router does not match"); + }); + + it("rejects a swap that is not exactInputSingle", async () => { + const { expectation, swap } = await prepared(); + const signed = await sign( + withData(swap, { + data: encodeFunctionData({ abi: erc20Abi, args: [POLYGON_UNISWAP_V3_ROUTER, 1n], functionName: "approve" }) + }) + ); + await expect(validateUniswapSwap(signed, expectation)).rejects.toThrow(); + }); +}); + +describe("fixed Polygon Uniswap V3 execution failure branches", () => { + const originalFindByPk = QuoteTicket.findByPk; + + function rampState(approval: PresignedTx, swap: PresignedTx, signedApproval: PresignedTx, signedSwap: PresignedTx, state: unknown) { + return { + currentPhase: "uniswapSwap", + errorLogs: [], + get() { + return this; + }, + id: "ramp-uniswap-failure", + phaseHistory: [], + presignedTxs: [signedApproval, signedSwap], + quoteId: "quote-uniswap-failure", + state: { + blockState: { uniswapV3FixedSwap: state }, + evmEphemeralAddress: ephemeral.address, + flow: { id: "test-flow", version: 1 } + }, + unsignedTxs: [approval, swap], + async update(update: Record) { + Object.assign(this, update); + return this; + } + } as never; + } + + type Deps = ConstructorParameters[0]; + + function happyDependencies(overrides: Partial> = {}): NonNullable { + let allowance = BigInt(inputAmountRaw); + let outputBalance = 0n; + return { + getAllowance: async () => allowance, + getBalance: async token => (token === POLYGON_EURE ? BigInt(inputAmountRaw) : outputBalance), + getReceipt: async () => null, + quote: async () => 116_000_000n, + sendRawTransaction: async transaction => { + allowance = 0n; + outputBalance = 116_000_000n; + return keccak256(transaction); + }, + simulateTransaction: async () => {}, + verifyDeployment: async () => {}, + waitForReceipt: async () => ({ status: "success" }), + ...overrides + }; + } + + async function runSwap(overrides: Partial>, now = Date.now()) { + operationAttempts.length = 0; + const { approval, simulated, state, swap } = await prepared(now); + const signedApproval = await sign(approval); + const signedSwap = await sign(swap); + QuoteTicket.findByPk = mock(async () => ({ + metadata: { blocks: { uniswapV3FixedSwap: simulated.metadata } } + })) as typeof QuoteTicket.findByPk; + try { + return await new UniswapSwapExecutor(happyDependencies(overrides)) + .execute(rampState(approval, swap, signedApproval, signedSwap, state)) + .then(() => null) + .catch((error: unknown) => error); + } finally { + QuoteTicket.findByPk = originalFindByPk; + } + } + + it("retries later when the signed deadline has already passed", async () => { + const error = await runSwap({}, Date.now() - 8 * 24 * 60 * 60 * 1000); + expect(error).toBeInstanceOf(RecoverablePhaseError); + expect((error as Error).message).toContain("expired"); + expect(operationAttempts).toEqual([]); + }); + + it("retries later while the EURe has not reached the ephemeral", async () => { + const error = await runSwap({ getBalance: async () => 0n }); + expect(error).toBeInstanceOf(RecoverablePhaseError); + expect((error as Error).message).toContain("has not reached the ephemeral"); + expect(operationAttempts).toEqual([]); + }); + + it("pauses when the router allowance does not match the exact input", async () => { + const error = await runSwap({ getAllowance: async () => BigInt(inputAmountRaw) - 1n }); + expect(error).toBeInstanceOf(ReconciliationRequiredPhaseError); + expect((error as Error).message).toContain("allowance"); + expect(operationAttempts).toEqual([]); + }); + + it("retries later when the live quote moved below the soft minimum", async () => { + const error = await runSwap({ quote: async () => 113_679_999n }); + expect(error).toBeInstanceOf(RecoverablePhaseError); + expect((error as Error).message).toContain("soft minimum"); + expect(operationAttempts).toEqual([]); + }); + + it("pauses when the swap leaves an allowance behind", async () => { + let sent = false; + const error = await runSwap({ + getAllowance: async () => (sent ? 1n : BigInt(inputAmountRaw)), + getBalance: async token => (token === POLYGON_EURE ? BigInt(inputAmountRaw) : 116_000_000n), + sendRawTransaction: async transaction => { + sent = true; + return keccak256(transaction); + } + }); + expect(error).toBeInstanceOf(ReconciliationRequiredPhaseError); + expect((error as Error).message).toContain("left unexpected allowance"); + expect(operationAttempts).toEqual(["uniswap-presigned-broadcast"]); + }); + + it("pauses when the swap produced less USDC than the hard minimum", async () => { + let sent = false; + const error = await runSwap({ + getAllowance: async () => (sent ? 0n : BigInt(inputAmountRaw)), + getBalance: async token => (token === POLYGON_EURE ? BigInt(inputAmountRaw) : sent ? 110_199_999n : 0n), + sendRawTransaction: async transaction => { + sent = true; + return keccak256(transaction); + } + }); + expect(error).toBeInstanceOf(ReconciliationRequiredPhaseError); + expect((error as Error).message).toContain("below"); + expect(operationAttempts).toEqual(["uniswap-presigned-broadcast"]); + }); + + it("pauses when the approval established a different allowance", async () => { + operationAttempts.length = 0; + const { approval, simulated, state, swap } = await prepared(); + const signedApproval = await sign(approval); + const signedSwap = await sign(swap); + QuoteTicket.findByPk = mock(async () => ({ + metadata: { blocks: { uniswapV3FixedSwap: simulated.metadata } } + })) as typeof QuoteTicket.findByPk; + const state_ = rampState(approval, swap, signedApproval, signedSwap, state) as { currentPhase: string }; + state_.currentPhase = "uniswapApprove"; + try { + const error = await new UniswapApproveExecutor(happyDependencies({ getAllowance: async () => 1n })) + .execute(state_ as never) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ReconciliationRequiredPhaseError); + expect((error as Error).message).toContain("established allowance 1"); + } finally { + QuoteTicket.findByPk = originalFindByPk; + } + }); +}); From f72f44e2578a38b07349bf8c66c9d7da765abec2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 12:30:53 +0200 Subject: [PATCH 29/42] docs(api): note that the Monerium issue fee must stay zero until it is collected on-chain --- docs/operations-monerium-interface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operations-monerium-interface.md b/docs/operations-monerium-interface.md index d3eaa89f8..049bb452c 100644 --- a/docs/operations-monerium-interface.md +++ b/docs/operations-monerium-interface.md @@ -55,7 +55,7 @@ Configuration (`apps/api/src/config/vars.ts`, samples in `apps/api/.env.example` | `MONERIUM_CLIENT_ID` | OAuth authorization-code app used by the dashboard and widget onboarding. | | `MONERIUM_REDIRECT_URI` / `MONERIUM_WIDGET_REDIRECT_URI` | Exact callback URIs registered with Monerium for the dashboard and the widget (`/widget` on the frontend origin); a mismatch renders Monerium's authorization page blank. The widget URI is optional and disables widget OAuth when unset. | | `MONERIUM_WHITELABEL_CLIENT_ID` / `MONERIUM_WHITELABEL_CLIENT_SECRET` | White-label client credentials (also the B2B onramp). | -| `MONERIUM_ISSUE_FEE_EUR` | Flat EUR fee subtracted from each issue quote; required in production and must not default silently. | +| `MONERIUM_ISSUE_FEE_EUR` | Flat EUR fee subtracted from each issue quote; required in production and must not default silently. Keep it at `0`: Monerium mints SEPA credits at par, and the flow models the fee as provider-deducted, so a non-zero value is shown to the user but never collected (it stays in the owner's wallet). Collecting it needs a full-input self-transfer plus on-chain fee distribution first. | This release does not create profiles through the white-label API, import external profiles, migrate OAuth profiles into the white-label application, orchestrate KYC/KYB lifecycle state From fded533963284cb25614809364b443dd8299ff0b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 15 Sep 2026 17:17:40 +0200 Subject: [PATCH 30/42] fix(repo): protect Monerium ramps and confirm IBAN moves Bind each EUR action to the selected legal profile and serialize ramp registration with IBAN destination changes so a live pay-in cannot be redirected. Require an informed confirmation before changing a provisioned IBAN destination, and keep unexpected OAuth and wallet failures visible to Sentry. --- .agents/skills/vortex-integration/SKILL.md | 7 +- .../api/controllers/monerium.controller.ts | 25 ++++-- .../api/controllers/onboarding.controller.ts | 2 +- .../src/api/services/monerium/active-ramp.ts | 16 ++++ .../api/services/monerium/identity.test.ts | 33 ++++++- .../api/src/api/services/monerium/identity.ts | 88 ++++++++++++++----- .../monerium/monerium.service.test.ts | 32 +++++++ .../api/services/monerium/monerium.service.ts | 17 +++- .../src/api/services/monerium/wallet.test.ts | 33 ++++++- apps/api/src/api/services/monerium/wallet.ts | 81 +++++++++++------ .../monerium-issue.registration.test.ts | 40 ++++++++- .../phases/monerium-issue/registration.ts | 27 +++++- .../eur-onramp-monerium.scenario.test.ts | 43 ++++++++- .../monerium-active-ramp.integration.test.ts | 81 ++++++++++++++++- ...erium-binding-entities.integration.test.ts | 48 ++++++++-- .../monerium/MoneriumWalletLinkFlow.test.ts | 25 ++++++ .../monerium/MoneriumWalletLinkFlow.tsx | 61 ++++++++----- .../onboarding/monerium/walletStep.ts | 12 +++ .../src/components/transfer/OnrampForm.tsx | 6 +- .../machines/actors/register.actor.test.ts | 4 + .../machines/actors/registerAdditionalData.ts | 1 + .../src/machines/moneriumKyc.machine.ts | 4 +- .../machines/moneriumWallet.machine.test.ts | 22 ++++- .../src/machines/moneriumWallet.machine.ts | 46 ++++++++-- apps/frontend/src/services/api/api-client.ts | 1 + apps/frontend/src/translations/en.json | 6 +- apps/frontend/src/translations/pt.json | 6 +- docs/api/pages/09-fiat-corridors.md | 4 +- docs/api/wire-contract.snapshot.md | 31 ++++--- docs/operations-monerium-interface.md | 11 ++- docs/product-dashboard.md | 7 +- .../security-spec/05-integrations/monerium.md | 12 +-- packages/kyc/src/monerium/api.ts | 4 +- packages/kyc/src/monerium/machine.test.ts | 25 +++++- packages/kyc/src/monerium/machine.ts | 6 +- packages/kyc/src/monerium/service.ts | 6 +- packages/kyc/src/monerium/types.ts | 6 +- packages/sdk/src/handlers/EurHandler.ts | 1 + packages/sdk/src/types.ts | 2 + packages/sdk/test/eurHandler.test.ts | 13 +++ .../shared/src/endpoints/ramp.endpoints.ts | 2 + 41 files changed, 746 insertions(+), 151 deletions(-) create mode 100644 apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.test.ts create mode 100644 apps/dashboard/src/components/onboarding/monerium/walletStep.ts diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index 19a2114e0..69623ef18 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -24,7 +24,7 @@ A machine-loadable capability catalog for AI coding agents integrating Vortex in - **Decimals**: all amounts are strings. Never parse them through JS `Number` — use `BigInt`, `decimal.js`, or equivalent. - **Quote TTL**: quotes expire (see `expiresAt`). Re-quote, never reuse stale quotes. - **Presigned counts**: this is **per ephemeral-signed transaction, not per ramp**. Each transaction an ephemeral key signs must be submitted as 5 presigned variants — 1 primary plus exactly 4 backups with consecutive nonces in `meta.additionalTxs` (`NUMBER_OF_PRESIGNED_TXS = 5`); the API rejects any other backup count. A ramp can contain several ephemeral-signed transactions across its phases. (The SDK builds these for you; only raw-API integrations need to construct them.) -- **Currently implemented SDK corridors**: BRL via PIX, USD via ACH, MXN via SPEI, COP via ACH, and ARS via CBU support BUY and SELL; EUR via SEPA (Monerium) supports BUY only. EUR BUY needs `walletAddress` (the user's Monerium-linked wallet, linked in the Dashboard or Widget) and the returned owner permit signed through `submitUserTransactions`; `MONERIUM_ONBOARDING_REQUIRED` / `MONERIUM_REAUTHENTICATION_REQUIRED` mean the user must (re)connect Monerium first. These corridors deliver to EVM networks only (no AssetHub). +- **Currently implemented SDK corridors**: BRL via PIX, USD via ACH, MXN via SPEI, COP via ACH, and ARS via CBU support BUY and SELL; EUR via SEPA (Monerium) supports BUY only. EUR BUY needs `walletAddress` (the user's Monerium-linked wallet, linked in the Dashboard or Widget) and the returned owner permit signed through `submitUserTransactions`. Supply `customerType` to select the same individual or business Monerium profile used at onboarding; it is required when both types are bound (`MONERIUM_CUSTOMER_TYPE_REQUIRED` otherwise). `MONERIUM_ONBOARDING_REQUIRED` / `MONERIUM_REAUTHENTICATION_REQUIRED` mean the user must (re)connect Monerium first. These corridors deliver to EVM networks only (no AssetHub). - **EUR currency value**: TypeScript uses the member `FiatToken.EURC`, which serializes to the wire value `"EUR"`. Raw JSON clients must send `"EUR"`, with `"sepa"` as the rail identifier. - **taxId is deprecated for BRL**: the user's tax ID is derived server-side from the authenticated profile. Sending a `taxId` that mismatches the derived one is rejected; stop sending it in new integrations. - **Deferred offramp funding**: the SDK checks the source wallet balance at `registerRamp` by default. Server integrations that register before funding a temporary wallet may configure `offrampFundingMode: "deferred"`. This skips only the SDK pre-flight; fund the exact `walletAddress` before signing/submitting user transactions, then update and start before the registration window expires. Backend execution-time balance checks remain authoritative. @@ -273,12 +273,14 @@ Users become corridor-ready by completing Monerium OAuth onboarding in the Dashb - Quote with TypeScript member `inputCurrency: FiatToken.EURC` (raw JSON value `"EUR"`), `from: "sepa"`, and a supported non-Polygon EVM destination. - A secret credential or Supabase session for the corridor-ready legal entity. - `additionalData.destinationAddress`; do not submit profile, Monerium address, or IBAN identity. +- `additionalData.customerType` (`"individual"` or `"business"`) when the user owns both legal profiles; use the same type as onboarding and wallet linking. - A fresh EVM ephemeral key and a wallet-signing channel for the profile-linked Polygon owner. ## SDK recipe ```js // walletAddress must be the wallet linked to the Monerium profile; a mismatch throws EurOnrampError. const { rampProcess, unsignedTransactions } = await vortex.registerRamp(quote, { + customerType: "individual", destinationAddress: "0xDestinationWallet", walletAddress: "0xMoneriumLinkedWallet" }); @@ -298,7 +300,7 @@ await vortex.startRamp(rampProcess.id); Raw API clients perform the same steps themselves: 1. Create the EUR BUY quote. -2. Call `POST /v1/ramp/register` with the quote ID, fresh EVM signing account, and destination address. +2. Call `POST /v1/ramp/register` with the quote ID, fresh EVM signing account, destination address, and `additionalData.customerType` when needed. 3. Partition every returned `unsignedTx` by `signer`. Sign ephemeral-owned raw transactions with the ephemeral key. Send the EIP-712 `moneriumOnrampSelfTransfer` permit to the linked owner wallet. 4. Submit the complete signed set to `POST /v1/ramp/update`. Partial updates are accepted, but @@ -312,6 +314,7 @@ consumes its nonce, automatic execution stops for manual resolution. ## Common failures - `400` approved-profile error: the effective legal entity has no approved local Monerium/EUR binding or the live provider profile is not approved. +- `409 MONERIUM_CUSTOMER_TYPE_REQUIRED`: both legal types are bound; repeat registration with the type used for wallet linking. - `409` expected-one-destination error: the profile does not have exactly one matching Polygon EOA/IBAN destination. Vortex does not create, select, or move one in this release. - Contract-wallet error: the linked mint destination must be an EOA for the ERC-2612 handoff. - Missing payment instructions after register: expected; submit every owner and ephemeral signature through update first. diff --git a/apps/api/src/api/controllers/monerium.controller.ts b/apps/api/src/api/controllers/monerium.controller.ts index 9193f2620..4de485e37 100644 --- a/apps/api/src/api/controllers/monerium.controller.ts +++ b/apps/api/src/api/controllers/monerium.controller.ts @@ -20,6 +20,10 @@ function customerType(value: unknown): CustomerType { return value; } +function optionalCustomerType(value: unknown): CustomerType | undefined { + return value === undefined ? undefined : customerType(value); +} + function oauthClient(value: unknown): MoneriumOAuthClient { if (value === undefined) return "dashboard"; if (!MONERIUM_OAUTH_CLIENTS.includes(value as MoneriumOAuthClient)) { @@ -85,7 +89,7 @@ export async function status(req: Request, res: Response, next: NextFunction): P } // Readiness needs a live read; a persisted approval stays readable when the OAuth session is gone. try { - res.status(httpStatus.OK).json({ ...result, ramp: await getMoneriumRampReadiness(user.userId) }); + res.status(httpStatus.OK).json({ ...result, ramp: await getMoneriumRampReadiness(user.userId, result.customerType) }); } catch (error) { if (!(error instanceof APIError && error.type === MONERIUM_REAUTHENTICATION_REQUIRED)) throw error; res.status(httpStatus.OK).json({ ...result, rampError: { code: error.type, message: error.message } }); @@ -99,9 +103,14 @@ export async function linkWallet(req: Request, res: Response, next: NextFunction try { const user = authenticatedUser(req); const body = (req.body ?? {}) as Record; - res - .status(httpStatus.OK) - .json(await linkMoneriumWallet(user.userId, { address: body.address, chain: body.chain, signature: body.signature })); + res.status(httpStatus.OK).json( + await linkMoneriumWallet(user.userId, { + address: body.address, + chain: body.chain, + customerType: optionalCustomerType(body.customerType), + signature: body.signature + }) + ); } catch (error) { next(error); } @@ -111,7 +120,13 @@ export async function moveIban(req: Request, res: Response, next: NextFunction): try { const user = authenticatedUser(req); const body = (req.body ?? {}) as Record; - res.status(httpStatus.OK).json(await moveMoneriumIban(user.userId, { address: body.address, chain: body.chain })); + res.status(httpStatus.OK).json( + await moveMoneriumIban(user.userId, { + address: body.address, + chain: body.chain, + customerType: optionalCustomerType(body.customerType) + }) + ); } catch (error) { next(error); } diff --git a/apps/api/src/api/controllers/onboarding.controller.ts b/apps/api/src/api/controllers/onboarding.controller.ts index 520c70141..f3445da82 100644 --- a/apps/api/src/api/controllers/onboarding.controller.ts +++ b/apps/api/src/api/controllers/onboarding.controller.ts @@ -149,7 +149,7 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise< ); customer.set("statusExternal", refreshed.statusExternal); if (refreshed.status === "APPROVED") { - rampReadiness.set(customer.id, await getMoneriumRampReadiness(userId)); + rampReadiness.set(customer.id, await getMoneriumRampReadiness(userId, customer.customerType)); } } catch (error) { if (error instanceof APIError && error.type === MONERIUM_REAUTHENTICATION_REQUIRED) { diff --git a/apps/api/src/api/services/monerium/active-ramp.ts b/apps/api/src/api/services/monerium/active-ramp.ts index 49d7715e1..ffed1a39d 100644 --- a/apps/api/src/api/services/monerium/active-ramp.ts +++ b/apps/api/src/api/services/monerium/active-ramp.ts @@ -31,3 +31,19 @@ export async function findActiveMoneriumRampForOwner(owner: string, transaction? }); return ramp?.id ?? null; } + +/** Hold this through registration's ramp insert or an IBAN move's provider mutation. */ +export async function lockMoneriumProfile(profileId: string, transaction: Transaction): Promise { + await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { + replacements: { key: `monerium:profile:${profileId}` }, + transaction + }); +} + +/** Serialize ramps sharing the EOA's balance baseline and permit nonce, even across profiles. */ +export async function lockMoneriumOwner(owner: string, transaction: Transaction): Promise { + await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { + replacements: { key: `monerium:owner:${owner.toLowerCase()}` }, + transaction + }); +} diff --git a/apps/api/src/api/services/monerium/identity.test.ts b/apps/api/src/api/services/monerium/identity.test.ts index 741bc4dff..3522816a1 100644 --- a/apps/api/src/api/services/monerium/identity.test.ts +++ b/apps/api/src/api/services/monerium/identity.test.ts @@ -62,10 +62,24 @@ describe("resolveMoneriumIdentity", () => { const identity = await resolve("user-1"); - expect(identity).toMatchObject({ client: user, profileId: PROFILE_ID, source: "oauth" }); + expect(identity).toMatchObject({ profileId: PROFILE_ID, source: "oauth" }); + await identity.client.listAddresses({ chain: "polygon", profile: PROFILE_ID }); + expect(user.listAddresses).toHaveBeenCalledWith({ chain: "polygon", profile: PROFILE_ID }); expect(getUserClient).toHaveBeenCalledWith("entity-1", "individual"); }); + it("passes an explicit legal type through binding resolution", async () => { + const loadBinding = mock(async () => binding); + const resolve = createResolveMoneriumIdentity({ + getUserClient: async () => client(async () => profile()), + getWhiteLabelClient: () => client(async () => profile()), + loadBinding + }); + + await resolve("user-1", undefined, "individual"); + expect(loadBinding).toHaveBeenCalledWith("user-1", undefined, "individual"); + }); + it("propagates white-label failures other than invisibility instead of switching apps", async () => { const getUserClient = mock(async () => client(async () => profile())); const resolve = createResolveMoneriumIdentity({ @@ -112,4 +126,21 @@ describe("resolveMoneriumIdentity", () => { expect(error).toBeInstanceOf(APIError); expect(error).toMatchObject({ isPublic: true, status: 404, type: MONERIUM_REAUTHENTICATION_REQUIRED }); }); + + it("maps a rejected user token after the profile read to reauthentication required", async () => { + const user = client(async () => profile()); + user.listAddresses.mockImplementation(async () => Promise.reject(apiError(401))); + const resolve = createResolveMoneriumIdentity({ + getUserClient: async () => user, + getWhiteLabelClient: () => client(async () => Promise.reject(apiError(403))), + loadBinding: async () => binding + }); + + const identity = await resolve("user-1"); + await expect(identity.client.listAddresses({ chain: "polygon", profile: PROFILE_ID })).rejects.toMatchObject({ + isPublic: true, + status: 404, + type: MONERIUM_REAUTHENTICATION_REQUIRED + }); + }); }); diff --git a/apps/api/src/api/services/monerium/identity.ts b/apps/api/src/api/services/monerium/identity.ts index 612a36fc0..7cceea37f 100644 --- a/apps/api/src/api/services/monerium/identity.ts +++ b/apps/api/src/api/services/monerium/identity.ts @@ -31,25 +31,44 @@ export interface MoneriumIdentity { export interface MoneriumIdentityDependencies { getUserClient: (customerEntityId: string, customerType: ProviderCustomerType) => Promise; getWhiteLabelClient: () => MoneriumIdentityClient; - loadBinding: (userId: string, transaction?: Transaction) => Promise; + loadBinding: ( + userId: string, + transaction?: Transaction, + customerType?: ProviderCustomerType + ) => Promise; } /** - * Ramp registration carries no customer type, so the binding is looked up across every entity the - * profile owns: the active entity's binding wins, otherwise the one bound entity. A business-active - * profile that onboarded through the widget (always `individual`) is therefore still registerable. + * Without a customer type, a single bound legal profile is unambiguous. With two bound profiles, + * callers must name the intended legal type rather than silently operating on the active entity. */ -export async function loadMoneriumBinding(userId: string, transaction?: Transaction): Promise { - const entity = await getOrCreateCustomerEntityForProfile(userId, undefined, transaction); +export async function loadMoneriumBinding( + userId: string, + transaction?: Transaction, + customerType?: ProviderCustomerType +): Promise { + const entity = await getOrCreateCustomerEntityForProfile(userId, customerType, transaction); const bindings = await ProviderCustomer.findAll({ ...(transaction ? { transaction } : {}), where: { customerEntityId: await findCustomerEntityIdsForProfile(userId, transaction), + ...(customerType ? { customerType } : {}), provider: "monerium", rail: "eur" } }); - const binding = bindings.find(candidate => candidate.customerEntityId === entity.id) ?? bindings[0]; + const bound = bindings.filter(candidate => candidate.providerCustomerId); + if (bound.length > 1) { + throw new APIError({ + isPublic: true, + message: customerType + ? "Multiple Monerium profiles are bound for this customer type" + : "Specify customerType to select the Monerium legal profile", + status: httpStatus.CONFLICT, + type: customerType ? "MONERIUM_BINDING_AMBIGUOUS" : "MONERIUM_CUSTOMER_TYPE_REQUIRED" + }); + } + const binding = bound[0] ?? bindings.find(candidate => candidate.customerEntityId === entity.id) ?? bindings[0]; if (!binding) return { customerEntityId: entity.id, customerType: entity.type, profileId: null }; return { customerEntityId: binding.customerEntityId, @@ -66,6 +85,35 @@ function isInvisibleToApp(error: unknown): boolean { return error instanceof MoneriumApiError && (error.status === 403 || error.status === 404); } +function reauthenticationRequired(): APIError { + return new APIError({ + isPublic: true, + message: "Monerium reauthentication is required", + status: httpStatus.NOT_FOUND, + type: MONERIUM_REAUTHENTICATION_REQUIRED + }); +} + +/** The access token can be revoked between the profile read and any later Monerium call. */ +function withReauthenticationErrors(user: MoneriumIdentityClient): MoneriumIdentityClient { + async function call(request: () => Promise): Promise { + try { + return await request(); + } catch (error) { + if (error instanceof MoneriumApiError && error.status === 401) throw reauthenticationRequired(); + throw error; + } + } + return { + getProfile: (...args) => call(() => user.getProfile(...args)), + linkAddress: (...args) => call(() => user.linkAddress(...args)), + listAddresses: (...args) => call(() => user.listAddresses(...args)), + listIbans: (...args) => call(() => user.listIbans(...args)), + requestIban: (...args) => call(() => user.requestIban(...args)), + updateIbanDestination: (...args) => call(() => user.updateIbanDestination(...args)) + }; +} + /** * Resolves which Monerium application can read the authenticated user's profile: the white-label * app first (client credentials), then the OAuth app through the user's backend-held token. Both @@ -79,8 +127,12 @@ export function createResolveMoneriumIdentity( loadBinding: loadMoneriumBinding } ) { - return async function resolveMoneriumIdentity(userId: string, transaction?: Transaction): Promise { - const binding = await dependencies.loadBinding(userId, transaction); + return async function resolveMoneriumIdentity( + userId: string, + transaction?: Transaction, + customerType?: ProviderCustomerType + ): Promise { + const binding = await dependencies.loadBinding(userId, transaction, customerType); if (!binding?.profileId) { throw new APIError({ isPublic: true, @@ -99,21 +151,9 @@ export function createResolveMoneriumIdentity( if (!isInvisibleToApp(error)) throw error; } - const user = await dependencies.getUserClient(binding.customerEntityId, binding.customerType); - try { - const profile = await user.getProfile(profileId); - return { client: user, profile, profileId, source: "oauth" }; - } catch (error) { - if (error instanceof MoneriumApiError && error.status === 401) { - throw new APIError({ - isPublic: true, - message: "Monerium reauthentication is required", - status: httpStatus.NOT_FOUND, - type: MONERIUM_REAUTHENTICATION_REQUIRED - }); - } - throw error; - } + const user = withReauthenticationErrors(await dependencies.getUserClient(binding.customerEntityId, binding.customerType)); + const profile = await user.getProfile(profileId); + return { client: user, profile, profileId, source: "oauth" }; }; } diff --git a/apps/api/src/api/services/monerium/monerium.service.test.ts b/apps/api/src/api/services/monerium/monerium.service.test.ts index 03fb1d5f6..f9cd5b1ea 100644 --- a/apps/api/src/api/services/monerium/monerium.service.test.ts +++ b/apps/api/src/api/services/monerium/monerium.service.test.ts @@ -2,6 +2,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock // Load this shared consumer before the module mocks below; Bun does not unregister mock.module // replacements, and the API suite may import transfer eligibility after this file. import "../recipients/transfer-eligibility.service"; +// Controller tests import the wallet route, which needs real Sequelize-backed ramp models. +import "./wallet"; // Value copies taken before the mock.module calls below; restored in afterAll because bun // module mocks are process-wide and would poison later test files (e.g. integration tests // that need the real sequelize instance and models). @@ -366,6 +368,36 @@ describe("Monerium OAuth", () => { expect(tokenCalls).toBe(2); }); + it("asks for reauthentication when Monerium revokes an otherwise unexpired access token", async () => { + let profileReads = 0; + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/auth/token")) { + return jsonResponse({ access_token: "access", expires_in: 3600, refresh_token: "refresh" }); + } + if (url.endsWith("/auth/context")) { + return profileReads > 0 + ? new Response(JSON.stringify({ error: "invalid_token" }), { status: 401 }) + : jsonResponse({ + email: "owner@example.com", + profiles: [{ id: "profile-a", kind: "personal" }], + userId: "monerium-user-a" + }); + } + profileReads += 1; + return jsonResponse({ id: "profile-a", kind: "personal", state: "approved" }); + }) as unknown as typeof fetch; + + const { authorizationUrl } = await service.startMoneriumOAuth("owner", "owner@example.com", "individual"); + const state = new URL(authorizationUrl).searchParams.get("state") as string; + await service.completeMoneriumOAuth("owner", "authorization-code", state); + + await expect(service.getMoneriumStatus("owner", "individual")).rejects.toMatchObject({ + status: 404, + type: service.MONERIUM_REAUTHENTICATION_REQUIRED + }); + }); + it("returns the custom reauthentication error when credentials are unavailable", async () => { await expect(service.getMoneriumStatus("owner", "individual")).rejects.toMatchObject({ status: 404, diff --git a/apps/api/src/api/services/monerium/monerium.service.ts b/apps/api/src/api/services/monerium/monerium.service.ts index 32bdbb665..5033d6cf5 100644 --- a/apps/api/src/api/services/monerium/monerium.service.ts +++ b/apps/api/src/api/services/monerium/monerium.service.ts @@ -473,8 +473,21 @@ export async function getMoneriumStatus(userId: string, customerType: ProviderCu } } const credentials = await getValidCredentials(entity.id, customerType); - const { profile } = await readProfile(credentials, customerType); - return mirrorProfile(entity.id, customerType, profile); + try { + const { profile } = await readProfile(credentials, customerType); + return mirrorProfile(entity.id, customerType, profile); + } catch (error) { + if (error instanceof MoneriumUpstreamError && error.upstreamStatus === 401) { + credentialCache.del(credentialsCacheKey(entity.id, customerType)); + throw new APIError({ + isPublic: true, + message: "Monerium reauthentication is required", + status: httpStatus.NOT_FOUND, + type: MONERIUM_REAUTHENTICATION_REQUIRED + }); + } + throw error; + } } export function resetMoneriumMemoryForTests(): void { diff --git a/apps/api/src/api/services/monerium/wallet.test.ts b/apps/api/src/api/services/monerium/wallet.test.ts index 56ce1bf6b..09bd519ff 100644 --- a/apps/api/src/api/services/monerium/wallet.test.ts +++ b/apps/api/src/api/services/monerium/wallet.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, mock } from "bun:test"; import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, MoneriumApiError, Networks } from "@vortexfi/shared"; import { privateKeyToAccount } from "viem/accounts"; +import type { Transaction } from "sequelize"; import { APIError } from "../../errors/api-error"; import type { MoneriumIdentity } from "./identity"; import { getMoneriumRampReadiness, linkMoneriumWallet, moveMoneriumIban, verifyMoneriumWalletOwnership } from "./wallet"; @@ -34,7 +35,10 @@ function deps(monerium: ReturnType, overrides: Partial null, isContractAddress: async () => false, + lockOwner: async () => undefined, resolveIdentity: async () => identity, + runWithProfileLock: async (_profileId: string, work: (transaction: Transaction) => Promise): Promise => + work(undefined as unknown as Transaction), verifyOwnership: async () => true, ...overrides }; @@ -45,9 +49,15 @@ async function ownerSignature(): Promise<`0x${string}`> { } describe("getMoneriumRampReadiness", () => { + it("reads the profile matching the requested legal type", async () => { + const monerium = client({ addresses: [OWNER.address], ibans: [iban(OWNER.address)] }); + const resolveIdentity = mock(async () => deps(monerium).resolveIdentity("user-1")); + await getMoneriumRampReadiness("user-1", "individual", deps(monerium, { resolveIdentity })); + expect(resolveIdentity).toHaveBeenCalledWith("user-1", undefined, "individual"); + }); it("reports provisioned when the IBAN points to a linked address on the ramp chain", async () => { const monerium = client({ addresses: [OWNER.address], ibans: [iban(OWNER.address)] }); - await expect(getMoneriumRampReadiness("user-1", deps(monerium))).resolves.toEqual({ + await expect(getMoneriumRampReadiness("user-1", undefined, deps(monerium))).resolves.toEqual({ chain: "polygon", iban: "provisioned", linkedAddress: OWNER.address, @@ -58,14 +68,14 @@ describe("getMoneriumRampReadiness", () => { it("reports elsewhere when the profile's IBAN sits on another chain or address", async () => { const monerium = client({ addresses: [OWNER.address], ibans: [iban(OTHER, "ethereum")] }); - await expect(getMoneriumRampReadiness("user-1", deps(monerium))).resolves.toMatchObject({ + await expect(getMoneriumRampReadiness("user-1", undefined, deps(monerium))).resolves.toMatchObject({ iban: "elsewhere", linkedAddress: OWNER.address }); }); it("reports missing with no linked address when nothing is provisioned", async () => { - await expect(getMoneriumRampReadiness("user-1", deps(client()))).resolves.toMatchObject({ iban: "missing", linkedAddress: null }); + await expect(getMoneriumRampReadiness("user-1", undefined, deps(client()))).resolves.toMatchObject({ iban: "missing", linkedAddress: null }); }); }); @@ -183,10 +193,25 @@ describe("moveMoneriumIban", () => { await expect( moveMoneriumIban("user-1", { address: OWNER.address, chain: "polygon" }, deps(monerium, { findActiveRampForOwner })) ).rejects.toMatchObject({ isPublic: true, status: 409 }); - expect(findActiveRampForOwner).toHaveBeenCalledWith(OTHER); + expect(findActiveRampForOwner).toHaveBeenCalledWith(OTHER, undefined); expect(monerium.updateIbanDestination).not.toHaveBeenCalled(); }); + it("locks the IBAN's current owner before checking for a live ramp and moving it", async () => { + const monerium = client({ addresses: [OWNER.address], ibans: [iban(OTHER, "ethereum")] }); + const lockOwner = mock(async () => undefined); + const findActiveRampForOwner = mock(async () => null); + await moveMoneriumIban( + "user-1", + { address: OWNER.address, chain: "polygon" }, + deps(monerium, { lockOwner, findActiveRampForOwner }) + ); + expect(lockOwner).toHaveBeenCalledWith(OTHER, undefined); + expect(lockOwner).toHaveBeenCalledTimes(1); + expect(findActiveRampForOwner).toHaveBeenCalledWith(OTHER, undefined); + expect(monerium.updateIbanDestination).toHaveBeenCalledTimes(1); + }); + it("requires exactly one IBAN", async () => { const monerium = client({ addresses: [OWNER.address] }); await expect(moveMoneriumIban("user-1", { address: OWNER.address, chain: "polygon" }, deps(monerium))).rejects.toMatchObject({ status: 409 }); diff --git a/apps/api/src/api/services/monerium/wallet.ts b/apps/api/src/api/services/monerium/wallet.ts index 6eee60f31..1ca2f3014 100644 --- a/apps/api/src/api/services/monerium/wallet.ts +++ b/apps/api/src/api/services/monerium/wallet.ts @@ -6,12 +6,15 @@ import { Networks } from "@vortexfi/shared"; import httpStatus from "http-status"; +import type { Transaction } from "sequelize"; import { isAddress, isHex, verifyMessage } from "viem"; +import sequelize from "../../../config/database"; import logger from "../../../config/logger"; +import type { ProviderCustomerType } from "../../../models/providerCustomer.model"; import { APIError } from "../../errors/api-error"; import { matchingDestinations } from "../phases/blocks/phases/monerium-issue/registration"; import { MONERIUM_ISSUE_NETWORKS, type MoneriumIssueNetwork } from "../phases/blocks/phases/monerium-issue/simulation"; -import { findActiveMoneriumRampForOwner } from "./active-ramp"; +import { findActiveMoneriumRampForOwner, lockMoneriumOwner, lockMoneriumProfile } from "./active-ramp"; import { type MoneriumIdentity, type MoneriumIdentitySource, resolveMoneriumIdentity } from "./identity"; /** Chain the active EUR onramp mints on; readiness is measured against it. */ @@ -43,10 +46,23 @@ export interface MoneriumWalletLinkResult extends MoneriumWalletDestination { export interface MoneriumWalletDependencies { findActiveRampForOwner?: typeof findActiveMoneriumRampForOwner; isContractAddress: (network: MoneriumIssueNetwork, address: `0x${string}`) => Promise; - resolveIdentity: (userId: string) => Promise; + resolveIdentity: ( + userId: string, + transaction?: Transaction, + customerType?: ProviderCustomerType + ) => Promise; + lockOwner?: typeof lockMoneriumOwner; + runWithProfileLock?: (profileId: string, work: (transaction: Transaction) => Promise) => Promise; verifyOwnership: (address: `0x${string}`, signature: `0x${string}`) => Promise; } +async function runWithProfileLock(profileId: string, work: (transaction: Transaction) => Promise): Promise { + return sequelize.transaction(async transaction => { + await lockMoneriumProfile(profileId, transaction); + return work(transaction); + }); +} + /** EOA signature over Monerium's fixed ownership message; malformed signatures count as not owned. */ export async function verifyMoneriumWalletOwnership(address: `0x${string}`, signature: `0x${string}`): Promise { try { @@ -94,9 +110,10 @@ async function readDestinations(identity: MoneriumIdentity, chain: MoneriumChain /** Whether the profile can register the EUR onramp today, from the same reads registration uses. */ export async function getMoneriumRampReadiness( userId: string, + customerType?: ProviderCustomerType, dependencies: MoneriumWalletDependencies = defaultDependencies ): Promise { - const identity = await dependencies.resolveIdentity(userId); + const identity = await dependencies.resolveIdentity(userId, undefined, customerType); const chain = MONERIUM_RAMP_CHAIN; const { addresses, ibans } = await readDestinations(identity, chain); const matches = matchingDestinations(identity.profileId, chain, addresses, ibans); @@ -112,7 +129,7 @@ export async function getMoneriumRampReadiness( */ export async function linkMoneriumWallet( userId: string, - input: { address?: unknown; chain?: unknown; signature?: unknown }, + input: { address?: unknown; chain?: unknown; customerType?: ProviderCustomerType; signature?: unknown }, dependencies: MoneriumWalletDependencies = defaultDependencies ): Promise { const { address, chain } = parseDestination(input); @@ -129,7 +146,7 @@ export async function linkMoneriumWallet( }); } - const identity = await dependencies.resolveIdentity(userId); + const identity = await dependencies.resolveIdentity(userId, undefined, input.customerType); const before = await readDestinations(identity, chain); if (!before.addresses.some(entry => sameAddress(entry.address, address))) { await identity.client.linkAddress({ @@ -161,34 +178,40 @@ export async function linkMoneriumWallet( /** Moves the profile's single IBAN to an already-linked address. Only ever called on the owner's explicit request. */ export async function moveMoneriumIban( userId: string, - input: { address?: unknown; chain?: unknown }, + input: { address?: unknown; chain?: unknown; customerType?: ProviderCustomerType }, dependencies: MoneriumWalletDependencies = defaultDependencies ): Promise { const { address, chain } = parseDestination(input); - const identity = await dependencies.resolveIdentity(userId); - const { addresses, ibans } = await readDestinations(identity, chain); - if (!addresses.some(entry => sameAddress(entry.address, address))) { - throw new APIError({ - message: `address is not linked to the Monerium profile on ${chain}`, - status: httpStatus.BAD_REQUEST - }); - } - if (ibans.length !== 1) { - throw new APIError({ message: `Expected exactly one Monerium IBAN, found ${ibans.length}`, status: httpStatus.CONFLICT }); - } - const current = ibans[0]; - if (current.chain !== chain || !sameAddress(current.address, address)) { - // A live ramp waits for the mint on the IBAN's current wallet; moving it now would strand that ramp. - const activeRampId = await (dependencies.findActiveRampForOwner ?? findActiveMoneriumRampForOwner)(current.address); - if (activeRampId) { + const identity = await dependencies.resolveIdentity(userId, undefined, input.customerType); + return (dependencies.runWithProfileLock ?? runWithProfileLock)(identity.profileId, async transaction => { + const { addresses, ibans } = await readDestinations(identity, chain); + if (!addresses.some(entry => sameAddress(entry.address, address))) { throw new APIError({ - isPublic: true, - message: `An EUR pay-in is still in progress for the wallet the IBAN points to (${activeRampId}); wait for it to finish before moving the IBAN`, - status: httpStatus.CONFLICT + message: `address is not linked to the Monerium profile on ${chain}`, + status: httpStatus.BAD_REQUEST }); } - await identity.client.updateIbanDestination(current.iban, { address, chain }); - logger.info(`MoneriumWallet: moved the IBAN destination to ${address} on ${chain} through the ${identity.source} app`); - } - return { address, chain, iban: "provisioned" }; + if (ibans.length !== 1) { + throw new APIError({ message: `Expected exactly one Monerium IBAN, found ${ibans.length}`, status: httpStatus.CONFLICT }); + } + const current = ibans[0]; + if (current.chain !== chain || !sameAddress(current.address, address)) { + await (dependencies.lockOwner ?? lockMoneriumOwner)(current.address, transaction); + // A live ramp waits for the mint on the IBAN's current wallet; moving it now would strand that ramp. + const activeRampId = await (dependencies.findActiveRampForOwner ?? findActiveMoneriumRampForOwner)( + current.address, + transaction + ); + if (activeRampId) { + throw new APIError({ + isPublic: true, + message: `An EUR pay-in is still in progress for the wallet the IBAN points to (${activeRampId}); wait for it to finish before moving the IBAN`, + status: httpStatus.CONFLICT + }); + } + await identity.client.updateIbanDestination(current.iban, { address, chain }); + logger.info(`MoneriumWallet: moved the IBAN destination to ${address} on ${chain} through the ${identity.source} app`); + } + return { address, chain, iban: "provisioned" }; + }); } diff --git a/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts index 68449d65e..3be30ae46 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/monerium-issue.registration.test.ts @@ -55,6 +55,28 @@ function context(input: Record = {}, network: MoneriumIssueNetw } describe("MoneriumIssue registration", () => { + it("locks the profile before reading the IBAN in the registration transaction", async () => { + const monerium = client(); + const transaction = {} as never; + const lockProfile = mock(async () => { + expect(monerium.listIbans).not.toHaveBeenCalled(); + }); + const lockOwner = mock(async () => undefined); + const register = createRegisterMoneriumIssue({ + createReference: () => "VTX00000000000000000000000000000001", + findActiveRampForOwner: async () => null, + isContractAddress: async () => false, + lockOwner, + lockProfile, + readOwnerEureBalance: async () => new Big(0), + resolveIdentity: async () => identity(monerium) + }); + + await register({ ...context(), transaction }); + expect(lockProfile).toHaveBeenCalledWith(PROFILE_ID, transaction); + expect(lockOwner).toHaveBeenCalledWith(ADDRESS, transaction); + }); + it("derives the Polygon owner and persists its EURe balance baseline", async () => { const monerium = client({ chain: "polygon" }); const resolveIdentity = mock(async () => identity(monerium)); @@ -69,7 +91,7 @@ describe("MoneriumIssue registration", () => { const result = await register(context({}, Networks.Polygon)); - expect(resolveIdentity).toHaveBeenCalledWith("effective-user-1", undefined); + expect(resolveIdentity).toHaveBeenCalledWith("effective-user-1", undefined, undefined); expect(monerium.getProfile).not.toHaveBeenCalled(); expect(monerium.listAddresses).toHaveBeenCalledWith({ chain: "polygon", profile: PROFILE_ID }); expect(monerium.listIbans).toHaveBeenCalledWith({ chain: "polygon", profile: PROFILE_ID }); @@ -120,6 +142,22 @@ describe("MoneriumIssue registration", () => { expect(result.facts).toMatchObject({ chain: Networks.PolygonAmoy, owner: ADDRESS }); }); + it("registers against the requested legal profile type", async () => { + const resolveIdentity = mock(async () => identity(client())); + const register = createRegisterMoneriumIssue({ + createReference: () => "VTX00000000000000000000000000000002", + findActiveRampForOwner: async () => null, + isContractAddress: async () => false, + readOwnerEureBalance: async () => new Big(0), + resolveIdentity + }); + + await register(context({ customerType: "individual" })); + expect(resolveIdentity).toHaveBeenCalledWith("effective-user-1", undefined, "individual"); + await expect(register(context({ customerType: "wrong" }))).rejects.toMatchObject({ status: 400 }); + expect(resolveIdentity).toHaveBeenCalledTimes(1); + }); + it("rejects caller-controlled identity before resolving any provider customer", async () => { const resolveIdentity = mock(async () => identity(client())); const register = createRegisterMoneriumIssue({ diff --git a/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts b/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts index f0be0a44c..3eea480b3 100644 --- a/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts +++ b/apps/api/src/api/services/phases/blocks/phases/monerium-issue/registration.ts @@ -10,8 +10,9 @@ import crypto from "crypto"; import httpStatus from "http-status"; import { isAddress } from "viem"; import logger from "../../../../../../config/logger"; +import type { ProviderCustomerType } from "../../../../../../models/providerCustomer.model"; import { APIError } from "../../../../../errors/api-error"; -import { findActiveMoneriumRampForOwner } from "../../../../monerium/active-ramp"; +import { findActiveMoneriumRampForOwner, lockMoneriumOwner, lockMoneriumProfile } from "../../../../monerium/active-ramp"; import { type MoneriumIdentity, resolveMoneriumIdentity } from "../../../../monerium/identity"; import type { RegisterCtx, RegistrationResult } from "../../core/types"; import { MONERIUM_EURE, MONERIUM_ISSUE_NETWORKS, type MoneriumIssueMetadata, type MoneriumIssueNetwork } from "./simulation"; @@ -27,6 +28,7 @@ const CALLER_IDENTITY_FIELDS = [ export interface MoneriumIssueRegistrationInput extends Record { address?: string; + customerType?: ProviderCustomerType; iban?: string; moneriumAddress?: string; moneriumIban?: string; @@ -54,8 +56,14 @@ interface MoneriumIssueRegistrationDependencies { createReference: () => string; findActiveRampForOwner?: typeof findActiveMoneriumRampForOwner; isContractAddress?: (network: MoneriumIssueNetwork, address: `0x${string}`) => Promise; + lockOwner?: typeof lockMoneriumOwner; + lockProfile?: typeof lockMoneriumProfile; readOwnerEureBalance: typeof getEvmTokenBalance; - resolveIdentity: (userId: string, transaction?: RegisterCtx["transaction"]) => Promise; + resolveIdentity: ( + userId: string, + transaction?: RegisterCtx["transaction"], + customerType?: ProviderCustomerType + ) => Promise; } function createPaymentReference(): string { @@ -100,15 +108,27 @@ export function createRegisterMoneriumIssue( }); } + if ( + ctx.input.customerType !== undefined && + ctx.input.customerType !== "individual" && + ctx.input.customerType !== "business" + ) { + throw new APIError({ message: "customerType must be individual or business", status: httpStatus.BAD_REQUEST }); + } const { client, profile, profileId, source } = await dependencies.resolveIdentity( ctx.authenticatedUser.id, - ctx.transaction + ctx.transaction, + ctx.input.customerType ); if (profile.id !== profileId || profile.state !== "approved") { throw new APIError({ message: "The Monerium profile is not approved", status: httpStatus.BAD_REQUEST }); } logger.info(`MoneriumIssue: resolved the Monerium profile through the ${source} app`); + // Registration keeps this transaction open through the ramp insert. A concurrent registration + // or IBAN move for this profile must see the committed ramp/destination before proceeding. + if (ctx.transaction) await (dependencies.lockProfile ?? lockMoneriumProfile)(profileId, ctx.transaction); + const moneriumChain = MONERIUM_ISSUE_NETWORKS[ctx.metadata.network].chain; const [addressResponse, ibanResponse] = await Promise.all([ client.listAddresses({ chain: moneriumChain, profile: profileId }), @@ -125,6 +145,7 @@ export function createRegisterMoneriumIssue( const destination = destinations[0]; const address = destination.address.address; const iban = destination.iban; + if (ctx.transaction) await (dependencies.lockOwner ?? lockMoneriumOwner)(address, ctx.transaction); const isContractAddress = dependencies.isContractAddress ?? (async (network: MoneriumIssueNetwork, owner: `0x${string}`) => 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 67a2820cf..fc33c7329 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 @@ -15,7 +15,7 @@ import Big from "big.js"; import { Signature as EvmSignature } from "ethers"; import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; -import { getOrCreateCustomerEntityForProfile } from "../../api/services/customer-entity.service"; +import { getOrCreateCustomerEntityForProfile, selectActiveCustomerEntity } from "../../api/services/customer-entity.service"; import { getBlockMetadata } from "../../api/services/phases/blocks/core/metadata"; import { MoneriumIssueContext, MONERIUM_ISSUE_NETWORKS } from "../../api/services/phases/blocks/phases/monerium-issue/simulation"; import { moneriumPermitAbi } from "../../api/services/phases/blocks/phases/monerium-self-transfer/contract"; @@ -235,11 +235,12 @@ describe("EUR onramp Monerium corridor (sepa → Polygon mint+swap → USDC on A userId: string, ephemeral: PrivateKeyAccount, owner: PrivateKeyAccount, - destination: `0x${string}` + destination: `0x${string}`, + customerType?: "individual" | "business" ): Promise { return app.request("/v1/ramp/register", { body: JSON.stringify({ - additionalData: { destinationAddress: destination, walletAddress: owner.address }, + additionalData: { ...(customerType ? { customerType } : {}), destinationAddress: destination, walletAddress: owner.address }, quoteId, signingAccounts: [{ address: ephemeral.address, type: "EVM" }] }), @@ -560,4 +561,40 @@ describe("EUR onramp Monerium corridor (sepa → Polygon mint+swap → USDC on A expect((await QuoteTicket.findByPk(quote.id))?.status).toBe("pending"); expect(await FinancialOperation.count()).toBe(0); }); + + it("registers the individual profile even when a different approved business profile is active", async () => { + const user = await createTestUser(); + const individual = await getOrCreateCustomerEntityForProfile(user.id, "individual"); + const business = await selectActiveCustomerEntity(user.id, "business"); + const individualOwner = privateKeyToAccount(generatePrivateKey()); + const businessOwner = privateKeyToAccount(generatePrivateKey()); + const businessProfileId = "3e26276e-330e-4058-a81c-5b543cd8f78e"; + for (const [entity, profileId] of [[individual, PROFILE_ID], [business, businessProfileId]] as const) { + await ProviderCustomer.create({ + customerEntityId: entity.id, + customerType: entity.type, + provider: "monerium", + providerCustomerId: profileId, + rail: "eur", + status: VerificationStatus.Approved, + statusExternal: "approved" + }); + } + world.monerium.provisionApprovedProfile(PROFILE_ID, individualOwner.address, "polygon"); + world.monerium.provisionApprovedProfile(businessProfileId, businessOwner.address, "polygon", "DE12500105170648489890"); + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + + const omitted = await registerViaApi((await createQuoteViaApi()).id, user.id, ephemeral, individualOwner, destination); + expect(omitted.status).toBe(409); + expect(await omitted.json()).toMatchObject({ type: "MONERIUM_CUSTOMER_TYPE_REQUIRED" }); + + const response = await registerViaApi((await createQuoteViaApi()).id, user.id, ephemeral, individualOwner, destination, "individual"); + expect(response.status, await response.clone().text()).toBe(201); + const registered = (await response.json()) as { id: string }; + const ramp = await RampState.findByPk(registered.id); + const issue = ramp?.state.blockState?.moneriumIssue as { moneriumProfileId?: string; owner?: string } | undefined; + expect(issue?.moneriumProfileId).toBe(PROFILE_ID); + expect(issue?.owner?.toLowerCase()).toBe(individualOwner.address.toLowerCase()); + }); }); diff --git a/apps/api/src/tests/monerium-active-ramp.integration.test.ts b/apps/api/src/tests/monerium-active-ramp.integration.test.ts index 5b4c9cb0c..9c6f6a38f 100644 --- a/apps/api/src/tests/monerium-active-ramp.integration.test.ts +++ b/apps/api/src/tests/monerium-active-ramp.integration.test.ts @@ -1,6 +1,9 @@ -import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import { findActiveMoneriumRampForOwner } from "../api/services/monerium/active-ramp"; +import { beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { findActiveMoneriumRampForOwner, lockMoneriumOwner, lockMoneriumProfile } from "../api/services/monerium/active-ramp"; +import type { MoneriumIdentity } from "../api/services/monerium/identity"; +import { moveMoneriumIban } from "../api/services/monerium/wallet"; import sequelize from "../config/database"; +import RampState from "../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestRampState } from "../test-utils/factories"; @@ -43,3 +46,77 @@ describe("findActiveMoneriumRampForOwner", () => { await expect(findActiveMoneriumRampForOwner(OWNER)).resolves.toBeNull(); }); }); + +describe("Monerium owner registration lock", () => { + it("makes a concurrent registration for another profile see the first ramp after it commits", async () => { + const ramp = await createTestRampState({ state: moneriumState("0x2222222222222222222222222222222222222222") }); + let firstWritten!: () => void; + let releaseFirst!: () => void; + const written = new Promise(resolve => (firstWritten = resolve)); + const release = new Promise(resolve => (releaseFirst = resolve)); + + const first = sequelize.transaction(async transaction => { + await lockMoneriumProfile("profile-1", transaction); + await lockMoneriumOwner(OWNER, transaction); + expect(await findActiveMoneriumRampForOwner(OWNER, transaction)).toBeNull(); + await RampState.update({ state: moneriumState() }, { transaction, where: { id: ramp.id } }); + firstWritten(); + await release; + }); + await written; + + const second = sequelize.transaction(async transaction => { + await lockMoneriumProfile("profile-2", transaction); + await lockMoneriumOwner(OWNER.toLowerCase(), transaction); + return findActiveMoneriumRampForOwner(OWNER, transaction); + }); + // Give the second transaction time to reach the lock while the first insert is uncommitted. + await new Promise(resolve => setTimeout(resolve, 50)); + releaseFirst(); + + await first; + expect(await second).toBe(ramp.id); + }); + + it("keeps a concurrent IBAN move from redirecting a just-registered pay-in", async () => { + const ramp = await createTestRampState({ state: moneriumState("0x3333333333333333333333333333333333333333") }); + let firstWritten!: () => void; + let releaseFirst!: () => void; + const written = new Promise(resolve => (firstWritten = resolve)); + const release = new Promise(resolve => (releaseFirst = resolve)); + const first = sequelize.transaction(async transaction => { + await lockMoneriumProfile("profile-1", transaction); + await lockMoneriumOwner(OWNER, transaction); + await RampState.update({ state: moneriumState() }, { transaction, where: { id: ramp.id } }); + firstWritten(); + await release; + }); + await written; + + const updateIbanDestination = mock(async () => undefined); + const identity = { + client: { + listAddresses: async () => ({ addresses: [{ address: "0x2222222222222222222222222222222222222222", chains: ["polygon"], profile: "profile-1" }] }), + listIbans: async () => ({ ibans: [{ address: OWNER, chain: "polygon", iban: "DE89370400440532013000", profile: "profile-1" }] }), + updateIbanDestination + }, + profileId: "profile-1", + source: "whitelabel" + } as unknown as MoneriumIdentity; + const move = moveMoneriumIban( + "user-1", + { address: "0x2222222222222222222222222222222222222222", chain: "polygon" }, + { + isContractAddress: async () => false, + resolveIdentity: async () => identity, + verifyOwnership: async () => true + } + ).catch(error => error); + await new Promise(resolve => setTimeout(resolve, 50)); + releaseFirst(); + + await first; + expect(await move).toMatchObject({ status: 409, message: expect.stringContaining(ramp.id) }); + expect(updateIbanDestination).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/tests/monerium-binding-entities.integration.test.ts b/apps/api/src/tests/monerium-binding-entities.integration.test.ts index 34e89f25a..07e4dde75 100644 --- a/apps/api/src/tests/monerium-binding-entities.integration.test.ts +++ b/apps/api/src/tests/monerium-binding-entities.integration.test.ts @@ -15,9 +15,8 @@ beforeEach(async () => { await resetTestDatabase(); }); -// The widget always onboards EUR as `individual`, while the dashboard switcher and managed -// profiles can make a business entity active. Registration carries no customer type, so the -// binding must be found on whichever entity holds it. +// The widget onboards EUR as `individual`, while the dashboard can select `business`. +// Once both are bound, every mutating path must select the same legal profile as its status read. describe("loadMoneriumBinding", () => { it("finds the Monerium binding on a non-active entity", async () => { const user = await createTestUser(); @@ -41,7 +40,7 @@ describe("loadMoneriumBinding", () => { expect(business.type).toBe("business"); }); - it("prefers the active entity's binding when several entities are bound", async () => { + it("requires a type when both entities are bound and selects the requested profile", async () => { const user = await createTestUser(); const individual = await getOrCreateCustomerEntityForProfile(user.id, "individual"); const business = await selectActiveCustomerEntity(user.id, "business"); @@ -57,11 +56,50 @@ describe("loadMoneriumBinding", () => { }); } - await expect(loadMoneriumBinding(user.id)).resolves.toEqual({ + await expect(loadMoneriumBinding(user.id)).rejects.toMatchObject({ + status: 409, + type: "MONERIUM_CUSTOMER_TYPE_REQUIRED" + }); + await expect(loadMoneriumBinding(user.id, undefined, "business")).resolves.toEqual({ customerEntityId: business.id, customerType: "business", profileId: "business-profile" }); + await expect(loadMoneriumBinding(user.id, undefined, "individual")).resolves.toEqual({ + customerEntityId: individual.id, + customerType: "individual", + profileId: "individual-profile" + }); + }); + + it("does not let an unbound active entity hide another approved profile", async () => { + const user = await createTestUser(); + const individual = await getOrCreateCustomerEntityForProfile(user.id, "individual"); + await ProviderCustomer.create({ + customerEntityId: individual.id, + customerType: "individual", + provider: "monerium", + providerCustomerId: PROFILE_ID, + rail: "eur", + status: VerificationStatus.Approved, + statusExternal: "approved" + }); + const business = await selectActiveCustomerEntity(user.id, "business"); + await ProviderCustomer.create({ + customerEntityId: business.id, + customerType: "business", + provider: "monerium", + providerCustomerId: null, + rail: "eur", + status: VerificationStatus.Started, + statusExternal: "authorization_started" + }); + + await expect(loadMoneriumBinding(user.id)).resolves.toEqual({ + customerEntityId: individual.id, + customerType: "individual", + profileId: PROFILE_ID + }); }); it("reports the active entity with no profile when nothing is bound", async () => { diff --git a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.test.ts b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.test.ts new file mode 100644 index 000000000..c2b0f8f32 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { MoneriumRampReadiness, MoneriumWalletLinkResult } from "@vortexfi/kyc"; +import { moneriumWalletStep } from "./walletStep"; + +const OLD = "0x1111111111111111111111111111111111111111"; +const NEW = "0x2222222222222222222222222222222222222222"; +const ramp: MoneriumRampReadiness = { chain: "polygon", iban: "provisioned", linkedAddress: OLD, source: "oauth" }; + +describe("Monerium wallet step", () => { + it("does not show ready when a different wallet is connected to a provisioned IBAN", () => { + assert.equal(moneriumWalletStep(ramp, NEW, undefined), "link"); + }); + + it("asks before moving the IBAN after linking the new wallet", () => { + const linked: MoneriumWalletLinkResult = { address: NEW, chain: "polygon", iban: "elsewhere" }; + assert.equal(moneriumWalletStep(ramp, NEW, linked), "move"); + assert.equal(moneriumWalletStep({ ...ramp, iban: "elsewhere" }, NEW, linked), "move"); + }); + + it("shows ready only when the connected wallet receives the IBAN's deposits", () => { + assert.equal(moneriumWalletStep(ramp, OLD, undefined), "ready"); + assert.equal(moneriumWalletStep({ ...ramp, linkedAddress: NEW }, NEW, undefined), "ready"); + }); +}); diff --git a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx index 978d81923..d0045e4df 100644 --- a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx +++ b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx @@ -11,6 +11,7 @@ import type { OnboardingStatus } from "@/domain/types"; import { ONBOARDING_STATUS_QUERY_KEY } from "@/hooks/useApprovedCorridors"; import { apiClient } from "@/services/api/api-client"; import { signMoneriumWalletLinkMessage } from "@/services/transactions/userSigning"; +import { moneriumWalletStep } from "./walletStep"; const api = createMoneriumKycApi(apiClient); export const MONERIUM_STATUS_QUERY_KEY = ["monerium-status"] as const; @@ -33,18 +34,17 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon const status = useQuery({ queryFn: () => api.getStatus(customerType), queryKey: [...MONERIUM_STATUS_QUERY_KEY, customerType], - refetchInterval: query => (query.state.data?.ramp?.iban === "provisioned" || query.state.error ? false : 5_000), + refetchInterval: query => { + const current = query.state.data?.ramp; + return query.state.error || + !address || + (current?.iban === "provisioned" && current.linkedAddress?.toLowerCase() === address.toLowerCase()) + ? false + : 5_000; + }, retry: false }); const ramp = status.data?.ramp; - const reported = useRef(false); - - useEffect(() => { - if (ramp?.iban === "provisioned" && !reported.current) { - reported.current = true; - onSettled("approved"); - } - }, [onSettled, ramp?.iban]); function refresh() { queryClient.invalidateQueries({ queryKey: MONERIUM_STATUS_QUERY_KEY }); @@ -55,14 +55,14 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon mutationFn: async () => { if (!address || !ramp) throw new Error("Connect a wallet first"); const signature = await signMoneriumWalletLinkMessage(); - return api.linkWallet({ address, chain: ramp.chain, signature }); + return api.linkWallet({ address, chain: ramp.chain, customerType, signature }); }, onSuccess: refresh }); const move = useMutation({ mutationFn: async () => { if (!address || !ramp) throw new Error("Connect a wallet first"); - return api.moveIban({ address, chain: ramp.chain }); + return api.moveIban({ address, chain: ramp.chain, customerType }); }, onSuccess: refresh }); @@ -70,6 +70,15 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon mutationFn: () => api.startOAuth(customerType), onSuccess: ({ authorizationUrl }) => requestAnimationFrame(() => window.location.assign(authorizationUrl)) }); + const step = ramp ? moneriumWalletStep(ramp, address, link.data) : "link"; + const reported = useRef(false); + + useEffect(() => { + if (step === "ready" && !reported.current) { + reported.current = true; + onSettled("approved"); + } + }, [onSettled, step]); if (status.isPending) { return ( @@ -124,7 +133,7 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon ); } - if (ramp.iban === "provisioned" && ramp.linkedAddress) { + if (step === "ready" && ramp.linkedAddress) { return ( <> @@ -144,11 +153,14 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon ); } - const isLinked = !!address && ramp.linkedAddress?.toLowerCase() === address.toLowerCase(); - const needsMove = ramp.iban === "elsewhere" && isLinked; + const isLinked = + !!address && + (ramp.linkedAddress?.toLowerCase() === address.toLowerCase() || link.data?.address.toLowerCase() === address.toLowerCase()); + const needsMove = step === "move"; const busy = link.isPending || move.isPending; const failure = link.error ?? move.error; const requested = link.data?.iban === "pending"; + const moveSubmitted = !!address && move.data?.address.toLowerCase() === address.toLowerCase(); return ( <> @@ -160,17 +172,24 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon Your EUR arrives as EURe in this wallet and is swapped from there, so it must be a regular wallet you control (no smart-contract wallet). Signing proves ownership; it costs no gas.

- {needsMove && ( -

- Your Monerium IBAN currently points to another wallet or chain. Move it to {shortenAddress(address)} so EUR - pay-ins mint here. -

+ {needsMove && address && ( +
+

Do you want to change where your Monerium IBAN sends EUR?

+

+ Moving it to {shortenAddress(address)} changes the wallet that receives future deposits to this IBAN. Other + services using the same IBAN, such as Gnosis Pay, may depend on its current wallet. Check those services before + you confirm. +

+
)} {requested && !needsMove && (

IBAN requested. Monerium is provisioning it; this usually takes a moment.

)} + {moveSubmitted && needsMove && ( +

Waiting for Monerium to update this IBAN’s destination…

+ )} {failure &&

{failure.message}

}
@@ -181,8 +200,8 @@ export function MoneriumWalletLinkFlow({ customerType, onClose, onSettled }: Mon {!address ? ( ) : needsMove ? ( - ) : (