diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index 747a6cd08..ce4f0b9fe 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`. 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. @@ -265,23 +265,42 @@ 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 to supported EVM destinations, Polygon included. -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. +- Quote with TypeScript member `inputCurrency: FiatToken.EURC` (raw JSON value `"EUR"`), `from: "sepa"`, and a supported 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" +}); + +// 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. +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 @@ -290,11 +309,12 @@ 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. +- `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. @@ -675,7 +695,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: any supported EVM network. 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 4f4709eca..40bc78f65 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -159,8 +159,13 @@ 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:5173/widget +# Set to false to stop quoting new EUR pay-ins (registered ramps keep executing). +EUR_ONRAMP_ENABLED=true # Server-to-server white-label access (shared client; also the Monerium B2B onramp -# credentials). Keep this backend-only. +# credentials). Keep this backend-only. Optional: when unset, every Monerium read goes +# through the user's OAuth session instead. MONERIUM_WHITELABEL_CLIENT_ID=your-monerium-whitelabel-client-id MONERIUM_WHITELABEL_CLIENT_SECRET=your-monerium-whitelabel-client-secret # Required in production. Flat EUR fee subtracted from each Monerium issue quote. diff --git a/apps/api/src/api/controllers/monerium.controller.ts b/apps/api/src/api/controllers/monerium.controller.ts index 64f39307a..4de485e37 100644 --- a/apps/api/src/api/controllers/monerium.controller.ts +++ b/apps/api/src/api/controllers/monerium.controller.ts @@ -1,7 +1,15 @@ 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, + MONERIUM_REAUTHENTICATION_REQUIRED, + type MoneriumOAuthClient, + startMoneriumOAuth +} from "../services/monerium/monerium.service"; +import { getMoneriumRampReadiness, linkMoneriumWallet, moveMoneriumIban } from "../services/monerium/wallet"; type CustomerType = "individual" | "business"; @@ -12,6 +20,21 @@ 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)) { + 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 +59,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); } @@ -57,7 +82,51 @@ 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, 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 } }); + } + } 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, + customerType: optionalCustomerType(body.customerType), + 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, + 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 1983548f5..f3445da82 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,14 +148,21 @@ 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, customer.customerType)); + } } catch (error) { if (error instanceof APIError && error.type === MONERIUM_REAUTHENTICATION_REQUIRED) { providerErrors.set(customer.id, { 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)}` + ); } }) ); @@ -368,6 +377,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/active-ramp.ts b/apps/api/src/api/services/monerium/active-ramp.ts new file mode 100644 index 000000000..ffed1a39d --- /dev/null +++ b/apps/api/src/api/services/monerium/active-ramp.ts @@ -0,0 +1,49 @@ +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; +} + +/** 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 new file mode 100644 index 000000000..0bafd5fd2 --- /dev/null +++ b/apps/api/src/api/services/monerium/identity.test.ts @@ -0,0 +1,157 @@ +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), + 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", () => { + 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({ 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("goes straight to the user's OAuth token when no white-label credentials are configured", async () => { + const user = client(async () => profile()); + const resolve = createResolveMoneriumIdentity({ + getUserClient: async () => user, + getWhiteLabelClient: () => null, + loadBinding: async () => binding + }); + + expect(await resolve("user-1")).toMatchObject({ profileId: PROFILE_ID, source: "oauth" }); + }); + + 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({ + 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 }); + }); + + 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 new file mode 100644 index 000000000..b5cd314db --- /dev/null +++ b/apps/api/src/api/services/monerium/identity.ts @@ -0,0 +1,165 @@ +import { MoneriumApiError, MoneriumApiService, type MoneriumProfile } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import type { Transaction } from "sequelize"; +import { config } from "../../../config/vars"; +import ProviderCustomer, { type ProviderCustomerType } from "../../../models/providerCustomer.model"; +import { APIError } from "../../errors/api-error"; +import { findCustomerEntityIdsForProfile, 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< + MoneriumApiService, + "getProfile" | "linkAddress" | "listAddresses" | "listIbans" | "requestIban" | "updateIbanDestination" +>; + +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; + /** `null` when no white-label credentials are configured; every read then uses the user's OAuth token. */ + getWhiteLabelClient: () => MoneriumIdentityClient | null; + loadBinding: ( + userId: string, + transaction?: Transaction, + customerType?: ProviderCustomerType + ) => Promise; +} + +/** + * 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, + 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 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, + customerType: binding.customerType, + profileId: binding.providerCustomerId + }; +} + +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); +} + +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 + * 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: () => + config.monerium.whiteLabelClientId && config.monerium.whiteLabelClientSecret ? MoneriumApiService.getInstance() : null, + loadBinding: loadMoneriumBinding + } +) { + 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, + 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(); + if (whiteLabel) { + try { + const profile = await whiteLabel.getProfile(profileId); + return { client: whiteLabel, profile, profileId, source: "whitelabel" }; + } catch (error) { + if (!isInvisibleToApp(error)) 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" }; + }; +} + +export const resolveMoneriumIdentity = createResolveMoneriumIdentity(); 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..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). @@ -58,6 +60,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 +72,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 +94,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 })); @@ -327,6 +333,71 @@ 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("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, @@ -349,6 +420,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 6c6751263..5033d6cf5 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"; @@ -134,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 { @@ -146,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(); @@ -207,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; @@ -219,6 +245,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 @@ -305,14 +339,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 }); @@ -341,7 +387,7 @@ export async function startMoneriumOAuth( customerEntityId: entity.id, customerType, expectedEmail: email.trim().toLowerCase(), - redirectUri: config.monerium.redirectUri, + redirectUri, userId, verifier }; @@ -427,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 new file mode 100644 index 000000000..09bd519ff --- /dev/null +++ b/apps/api/src/api/services/monerium/wallet.test.ts @@ -0,0 +1,219 @@ +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"; + +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 { + findActiveRampForOwner: async () => 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 + }; +} + +async function ownerSignature(): Promise<`0x${string}`> { + return OWNER.signMessage({ message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE }); +} + +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", undefined, 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", 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", undefined, 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("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, 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 new file mode 100644 index 000000000..1ca2f3014 --- /dev/null +++ b/apps/api/src/api/services/monerium/wallet.ts @@ -0,0 +1,217 @@ +import { + EvmClientManager, + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + MoneriumApiError, + type MoneriumChain, + 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, 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. */ +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 { + findActiveRampForOwner?: typeof findActiveMoneriumRampForOwner; + isContractAddress: (network: MoneriumIssueNetwork, address: `0x${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 { + 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, + customerType?: ProviderCustomerType, + dependencies: MoneriumWalletDependencies = defaultDependencies +): Promise { + 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); + 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; customerType?: ProviderCustomerType; 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, undefined, input.customerType); + 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.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" }; +} + +/** 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; customerType?: ProviderCustomerType }, + dependencies: MoneriumWalletDependencies = defaultDependencies +): Promise { + const { address, chain } = parseDestination(input); + 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({ + 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 (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/README.md b/apps/api/src/api/services/phases/blocks/README.md index 66f5ab7e8..9f5d1eb2d 100644 --- a/apps/api/src/api/services/phases/blocks/README.md +++ b/apps/api/src/api/services/phases/blocks/README.md @@ -340,7 +340,7 @@ route. | Presigned tx | Lane | Owning phase | Rationale | |--------------|------|--------------|-----------| | `nablaApprove`, `nablaSwap` | main | `NablaSwap` | its executors broadcast them; amounts from `nablaSwapEvm` | -| `uniswapApprove`, `uniswapSwap` | main | `PolygonEureUsdcUniswapSwap` | exact EURe approval and pinned-pool `exactInputSingle` conversion | +| `uniswapApprove`, `uniswapSwap` | main | `PolygonEureUsdcUniswapSwap` | exact EURe approval and pinned-path `exactInput` conversion (EURe → USDC.e → USDC) | | `distributeFees` | main | `DistributeFees` | fee amounts from `quote.metadata.fees` | | `squidRouterApprove`, `squidRouterSwap` | main | `SquidRouterSwap` | bridge input from `evmToEvm` | | `destinationTransfer` | main | `DestinationTransfer` | delivers `quote.outputAmount` to the user | @@ -605,8 +605,10 @@ Unmapped cases fail at quote resolution; there is no alternate engine: `BRL_ONRAMP_BASE_SAME_CHAIN_SWAP`. The latter emits source approve/swap transactions only, with destination transfer at the next nonce and no Squid pay, backup bridge, or final-settlement work. -8. **EUR selection is Monerium-only.** Supported non-Polygon EVM destinations use - `MoneriumOnrampPolygonCrossChain`. Mykobo Base variants are excluded from new +8. **EUR selection is Monerium-only.** Non-Polygon EVM destinations use + `MoneriumOnrampPolygonCrossChain`; Polygon destinations use + `MoneriumOnrampPolygonSameChain` (direct USDC transfer, or one same-chain + Squid swap for other tokens). Mykobo Base variants are excluded from new quote resolution but retained for persisted compatibility. EUR SELL has no active definition and is rejected by `QuoteService`. 9. **AlfredPay SELL reconciliation stays block-owned.** `AlfredpayOfframp` 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..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 @@ -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" }, @@ -51,22 +55,44 @@ 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 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, + findActiveRampForOwner: async () => null, 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, 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 +128,10 @@ describe("MoneriumIssue registration", () => { const isContractAddress = mock(async () => false); const register = createRegisterMoneriumIssue({ createReference: () => "VTX00000000000000000000000000000002", - getClient: () => monerium as never, - isContractAddress, + findActiveRampForOwner: async () => null, + isContractAddress, readOwnerEureBalance: async () => new Big(0), - resolveProfileId: async () => PROFILE_ID + resolveIdentity: async () => identity(monerium) }); const result = await register(context({}, Networks.PolygonAmoy)); @@ -116,29 +142,45 @@ 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 resolveProfileId = mock(async () => PROFILE_ID); + const resolveIdentity = mock(async () => identity(client())); const register = createRegisterMoneriumIssue({ createReference: () => "unused", - getClient: () => client() as never, - isContractAddress: async () => false, + findActiveRampForOwner: async () => null, + 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, + findActiveRampForOwner: async () => null, + 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 +189,10 @@ 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, + findActiveRampForOwner: async () => null, + 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,24 +210,44 @@ 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, + findActiveRampForOwner: async () => null, + 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"); }); - it("fails registration when the owner baseline cannot be read", async () => { + 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", - getClient: () => client({ chain: "polygon" }) as never, + 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"); }, - 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/__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..13d93ed83 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"; @@ -38,7 +39,7 @@ describe("Polygon Monerium cross-chain flow", () => { ]); }); - it("is the production SEPA EUR onramp and rejects same-chain Polygon settlement", () => { + it("is the production SEPA EUR onramp for non-Polygon destinations", () => { const request = { from: EPaymentMethod.SEPA, inputAmount: "100", @@ -51,8 +52,29 @@ describe("Polygon Monerium cross-chain flow", () => { expect(resolveBlockFlow(request).name).toBe("MoneriumOnrampPolygonCrossChain"); expect(getBlockExecutorFlows().map(flow => flow.identity.id)).toContain("MoneriumOnrampPolygonCrossChain"); - expect(() => resolveBlockFlow({ ...request, network: Networks.Polygon, to: Networks.Polygon })).toThrow( - "No block flow mapped" + expect(resolveBlockFlow({ ...request, network: Networks.Polygon, to: Networks.Polygon }).name).toBe( + "MoneriumOnrampPolygonSameChain" ); }); + + 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__/monerium-onramp-polygon-same-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/monerium-onramp-polygon-same-chain.flow.test.ts new file mode 100644 index 000000000..ad7f9f224 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/monerium-onramp-polygon-same-chain.flow.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, type RampPhase } from "@vortexfi/shared"; +import { assemblePhaseFlow } from "../core/phase-flow"; +import { getBlockExecutorFlows, resolveBlockFlow, resolvePersistedBlockFlow } from "../flows/catalog"; +import { makeMoneriumOnrampPolygonSameChainFlow } from "../flows/monerium-onramp-polygon-same-chain"; + +const polygonPrefix: RampPhase[] = [ + "moneriumOnrampMint", + "fundEphemeral", + "moneriumOnrampSelfTransfer", + "uniswapApprove", + "uniswapSwap", + "distributeFees", + "subsidizePostSwap" +]; + +const request = { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.Polygon, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Polygon +}; + +describe("Polygon Monerium same-chain flow", () => { + it("settles Polygon USDC straight from the fee-settled swap output", () => { + const flow = makeMoneriumOnrampPolygonSameChainFlow(EvmToken.USDC, "1.25"); + + expect(flow.phases).toEqual([...polygonPrefix, "destinationTransfer"]); + expect(assemblePhaseFlow(flow)).toEqual(["initial", ...polygonPrefix, "destinationTransfer", "complete"]); + expect(flow.contextKeys).not.toContain("squidRouterSwap"); + }); + + it("adds one same-chain Squid swap for other Polygon tokens", () => { + const flow = makeMoneriumOnrampPolygonSameChainFlow(EvmToken.USDT, "1.25"); + + expect(flow.phases).toEqual([...polygonPrefix, "squidRouterSwap", "destinationTransfer"]); + expect(flow.phases).not.toContain("squidRouterPay"); + }); + + it("owns Polygon destinations in the catalog and resolves its persisted variants", () => { + expect(getBlockExecutorFlows().map(flow => flow.identity.id)).toContain("MoneriumOnrampPolygonSameChain"); + for (const outputCurrency of [EvmToken.USDC, EvmToken.USDT]) { + const flow = resolveBlockFlow({ ...request, outputCurrency }); + expect(flow.name).toBe("MoneriumOnrampPolygonSameChain"); + const metadata = { + blocks: Object.fromEntries(flow.contextKeys.map(key => [key, {}])), + flow: flow.identity, + globals: { + fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } }, + partner: null, + request: { ...request, outputCurrency } + } + }; + expect(resolvePersistedBlockFlow(metadata).phases).toEqual(flow.phases); + } + expect(resolveBlockFlow({ ...request, network: Networks.Arbitrum, to: Networks.Arbitrum }).name).toBe( + "MoneriumOnrampPolygonCrossChain" + ); + }); +}); 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/__tests__/uniswap-v3-fixed-swap.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/uniswap-v3-fixed-swap.test.ts index 0f44c567c..8fc252e83 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,17 +1,19 @@ 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, encodePacked, 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 { POLYGON_EURE, - POLYGON_EURE_USDC_FEE, - POLYGON_EURE_USDC_POOL, + POLYGON_EURE_USDC_PATH, + POLYGON_EURE_USDC_ROUTE, POLYGON_UNISWAP_V3_ROUTER, POLYGON_USDC, + POLYGON_USDCE, uniswapV3RouterAbi } from "../phases/uniswap-v3-fixed-swap/contract"; import { simulateUniswapV3FixedSwap } from "../phases/uniswap-v3-fixed-swap/simulation"; @@ -87,8 +89,53 @@ async function sign(unsigned: PresignedTx): Promise { return { ...unsigned, txData: serialized }; } +type SwapParams = { + amountIn: bigint; + amountOutMinimum: bigint; + deadline: bigint; + path: `0x${string}`; + recipient: `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: "exactInput" }); +} + describe("fixed Polygon Uniswap V3 EURe/USDC swap", () => { - it("quotes the pinned pool without exposing EURE through the public token registry", async () => { + it("quotes the pinned two-hop path without exposing EURE through the public token registry", async () => { const result = await simulation(); expect(result.output).toMatchObject({ @@ -98,15 +145,15 @@ describe("fixed Polygon Uniswap V3 EURe/USDC swap", () => { }); expect(result.output.amount.toFixed()).toBe("116"); expect(result.metadata).toMatchObject({ - fee: POLYGON_EURE_USDC_FEE, inputToken: POLYGON_EURE, outputToken: POLYGON_USDC, - pool: POLYGON_EURE_USDC_POOL, + path: POLYGON_EURE_USDC_PATH, + pools: POLYGON_EURE_USDC_ROUTE.map(hop => hop.pool), router: POLYGON_UNISWAP_V3_ROUTER }); }); - it("prepares exact approval, fixed-pool swap, cleanup, and native prefunding", async () => { + it("prepares exact approval, fixed-path swap, cleanup, and native prefunding", async () => { const simulated = await simulation(); const prepared = await prepareUniswapV3FixedSwapTxs( { @@ -138,11 +185,8 @@ describe("fixed Polygon Uniswap V3 EURe/USDC swap", () => { expect(params).toMatchObject({ amountIn: BigInt(inputAmountRaw), amountOutMinimum: 110_200_000n, - fee: POLYGON_EURE_USDC_FEE, - recipient: ephemeral.address, - sqrtPriceLimitX96: 0n, - tokenIn: POLYGON_EURE, - tokenOut: POLYGON_USDC + path: POLYGON_EURE_USDC_PATH, + recipient: ephemeral.address }); expect(state).toEqual({ deadline: "1700604800", @@ -260,3 +304,225 @@ 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(); + }); + + const pathTypes = ["address", "uint24", "address", "uint24", "address"] as const; + it.each<[string, (params: SwapParams) => Partial]>([ + ["output token", () => ({ path: encodePacked(pathTypes, [POLYGON_EURE, 3000, POLYGON_USDCE, 100, OTHER_TOKEN]) })], + ["intermediate token", () => ({ path: encodePacked(pathTypes, [POLYGON_EURE, 3000, OTHER_TOKEN, 100, POLYGON_USDC]) })], + ["fee tier", () => ({ path: encodePacked(pathTypes, [POLYGON_EURE, 500, POLYGON_USDCE, 100, POLYGON_USDC]) })], + ["hop count", () => ({ path: encodePacked(["address", "uint24", "address"], [POLYGON_EURE, 500, POLYGON_USDC]) })], + ["recipient", () => ({ recipient: OTHER_TOKEN })], + ["deadline", params => ({ deadline: params.deadline + 1n })], + ["amountIn", params => ({ amountIn: params.amountIn + 1n })], + ["amountOutMinimum", params => ({ amountOutMinimum: params.amountOutMinimum - 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 exactInput", 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; + } + }); +}); 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..5e82d2abe 100644 --- a/apps/api/src/api/services/phases/blocks/flows/catalog.ts +++ b/apps/api/src/api/services/phases/blocks/flows/catalog.ts @@ -1,6 +1,8 @@ import { AssetHubToken, + doesNetworkSupportEurOnramp, EPaymentMethod, + type EvmNetworks, EvmToken, evmTokenConfig, FiatToken, @@ -43,6 +45,7 @@ import { makeEurOnrampBaseSameChainSwapFlow } from "./eur-onramp-base-same-chain"; import { makeMoneriumOnrampPolygonCrossChainFlow } from "./monerium-onramp-polygon-cross-chain"; +import { makeMoneriumOnrampPolygonSameChainFlow } from "./monerium-onramp-polygon-same-chain"; type FlowRequest = FlowMetadata["globals"]["request"]; @@ -58,6 +61,30 @@ function isDormantMoneriumEure(currency: unknown): boolean { return currency === "EURE"; } +function isMoneriumOnrampRequest(request: FlowRequest, network: Networks | undefined): network is EvmNetworks { + return ( + request.rampType === RampDirection.BUY && + request.from === EPaymentMethod.SEPA && + request.inputCurrency === FiatToken.EURC && + network !== undefined && + doesNetworkSupportEurOnramp(network) && + isEvmToken(request.outputCurrency) && + evmTokenConfig[network][request.outputCurrency] !== undefined + ); +} + +function requireMoneriumIssueFee(): string { + const issueFeeEur = config.monerium.issueFeeEur; + if (issueFeeEur === undefined) { + throw new APIError({ + isPublic: true, + message: "Monerium issue fee is not configured", + status: httpStatus.SERVICE_UNAVAILABLE + }); + } + return issueFeeEur; +} + const flowDefinitions: FlowDefinition[] = [ { create() { @@ -120,33 +147,35 @@ 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 || + network === Networks.Polygon || + !doesNetworkSupportEurOnramp(network) || + !isEvmToken(request.outputCurrency) + ) { throw new APIError({ message: "Unsupported Monerium destination", status: httpStatus.BAD_REQUEST }); } - if (issueFeeEur === undefined) { - throw new APIError({ - isPublic: true, - message: "Monerium issue fee is not configured", - status: httpStatus.SERVICE_UNAVAILABLE - }); - } - return makeMoneriumOnrampPolygonCrossChainFlow(network, request.outputCurrency, issueFeeEur); + return makeMoneriumOnrampPolygonCrossChainFlow(network, request.outputCurrency, requireMoneriumIssueFee()); }, executorFlow: makeMoneriumOnrampPolygonCrossChainFlow(Networks.Arbitrum, EvmToken.USDC, config.monerium.issueFeeEur ?? "0"), legacyCompatible: false, matches(request) { const network = getNetworkFromDestination(request.to); - return ( - request.rampType === RampDirection.BUY && - request.from === EPaymentMethod.SEPA && - request.inputCurrency === FiatToken.EURC && - network !== undefined && - network !== Networks.Polygon && - isNetworkEVM(network) && - isEvmToken(request.outputCurrency) && - evmTokenConfig[network][request.outputCurrency] !== undefined - ); + return isMoneriumOnrampRequest(request, network) && network !== Networks.Polygon; + } + }, + { + create(request) { + if (!isEvmToken(request.outputCurrency)) { + throw new APIError({ message: "Unsupported Monerium destination", status: httpStatus.BAD_REQUEST }); + } + return makeMoneriumOnrampPolygonSameChainFlow(request.outputCurrency, requireMoneriumIssueFee()); + }, + executorFlow: makeMoneriumOnrampPolygonSameChainFlow(EvmToken.USDC, config.monerium.issueFeeEur ?? "0"), + legacyCompatible: false, + matches(request) { + const network = getNetworkFromDestination(request.to); + return isMoneriumOnrampRequest(request, network) && network === Networks.Polygon; } }, { diff --git a/apps/api/src/api/services/phases/blocks/flows/monerium-onramp-polygon-cross-chain.ts b/apps/api/src/api/services/phases/blocks/flows/monerium-onramp-polygon-cross-chain.ts index 39f76f376..e71319aa4 100644 --- a/apps/api/src/api/services/phases/blocks/flows/monerium-onramp-polygon-cross-chain.ts +++ b/apps/api/src/api/services/phases/blocks/flows/monerium-onramp-polygon-cross-chain.ts @@ -13,11 +13,8 @@ import { SquidRouterSwap } from "../phases/squid-router-swap"; import { SubsidizePost } from "../phases/subsidize-post"; import { PolygonEureUsdcUniswapSwap } from "../phases/uniswap-v3-fixed-swap"; -export function makeMoneriumOnrampPolygonCrossChainFlow( - toChain: ToChain, - toToken: ToToken, - issueFeeEur: string -) { +/** Shared prefix: SEPA credit mints EURe to the owner on Polygon, ends as fee-settled Polygon USDC on the ephemeral. */ +export function moneriumOnrampPolygonUsdcFlow(issueFeeEur: string) { return FlowBuilder.start(fiatRequestIO(FiatToken.EURC), MoneriumIssue(Networks.Polygon, issueFeeEur)) .pipe(FundEphemeral(MONERIUM_EURE, Networks.Polygon)) .pipe(MoneriumSelfTransfer()) @@ -28,7 +25,15 @@ export function makeMoneriumOnrampPolygonCrossChainFlow()) + .pipe(SubsidizePost()); +} + +export function makeMoneriumOnrampPolygonCrossChainFlow( + toChain: ToChain, + toToken: ToToken, + issueFeeEur: string +) { + return moneriumOnrampPolygonUsdcFlow(issueFeeEur) .pipe(SquidRouterSwap(Networks.Polygon, toChain, EvmToken.USDC, toToken)) .pipe(FinalSettlementSubsidy()) .pipe(DestinationTransfer()) diff --git a/apps/api/src/api/services/phases/blocks/flows/monerium-onramp-polygon-same-chain.ts b/apps/api/src/api/services/phases/blocks/flows/monerium-onramp-polygon-same-chain.ts new file mode 100644 index 000000000..6573183a9 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/flows/monerium-onramp-polygon-same-chain.ts @@ -0,0 +1,18 @@ +import { EvmToken, Networks } from "@vortexfi/shared"; +import type { TokenBrand } from "../core/types"; +import { DestinationTransfer } from "../phases/destination-transfer"; +import { SameChainSquidRouterSwap } from "../phases/squid-router-swap"; +import { moneriumOnrampPolygonUsdcFlow } from "./monerium-onramp-polygon-cross-chain"; + +/** Polygon destinations settle from the fee-settled USDC directly; other Polygon tokens take one same-chain Squid swap. */ +export function makeMoneriumOnrampPolygonSameChainFlow(toToken: ToToken, issueFeeEur: string) { + if (toToken === EvmToken.USDC) { + return moneriumOnrampPolygonUsdcFlow(issueFeeEur) + .pipe(DestinationTransfer()) + .build("MoneriumOnrampPolygonSameChain", { isDirectTransfer: false }); + } + return moneriumOnrampPolygonUsdcFlow(issueFeeEur) + .pipe(SameChainSquidRouterSwap(Networks.Polygon, EvmToken.USDC, toToken)) + .pipe(DestinationTransfer()) + .build("MoneriumOnrampPolygonSameChain", { isDirectTransfer: false }); +} 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..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 @@ -4,15 +4,16 @@ 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 type { ProviderCustomerType } from "../../../../../../models/providerCustomer.model"; import { APIError } from "../../../../../errors/api-error"; -import { getOrCreateCustomerEntityForProfile } from "../../../../customer-entity.service"; +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; @@ -52,44 +54,23 @@ export interface MoneriumIssueResponseArtifacts extends Record interface MoneriumIssueRegistrationDependencies { createReference: () => string; - getClient: () => Pick; + findActiveRampForOwner?: typeof findActiveMoneriumRampForOwner; isContractAddress?: (network: MoneriumIssueNetwork, address: `0x${string}`) => Promise; + lockOwner?: typeof lockMoneriumOwner; + lockProfile?: typeof lockMoneriumProfile; 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"], + customerType?: ProviderCustomerType + ) => Promise; } 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[], @@ -112,9 +93,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 +108,26 @@ export function createRegisterMoneriumIssue( }); } - const profileId = await dependencies.resolveProfileId(ctx.authenticatedUser.id, ctx.transaction); - const client = dependencies.getClient(); - const profile = await client.getProfile(profileId); + 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.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([ @@ -151,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}`) => @@ -161,6 +156,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/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( 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/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/contract.ts b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/contract.ts index e4c40425c..03e442c5e 100644 --- a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/contract.ts +++ b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/contract.ts @@ -1,14 +1,28 @@ import { Networks } from "@vortexfi/shared"; -import { parseAbi } from "viem"; +import { encodePacked, parseAbi } from "viem"; import { MONERIUM_ISSUE_NETWORKS } from "../monerium-issue/simulation"; export const POLYGON_UNISWAP_V3_FACTORY = "0x1F98431c8aD98523631AE4a59f267346ea31F984" as const; export const POLYGON_UNISWAP_V3_QUOTER = "0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6" as const; export const POLYGON_UNISWAP_V3_ROUTER = "0xE592427A0AEce92De3Edee1F18E0157C05861564" as const; -export const POLYGON_EURE_USDC_POOL = "0x368A930B71326e3f640Df36378d931DbE3D03746" as const; export const POLYGON_EURE = MONERIUM_ISSUE_NETWORKS[Networks.Polygon].eureAddress; export const POLYGON_USDC = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" as const; -export const POLYGON_EURE_USDC_FEE = 500; +export const POLYGON_USDCE = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" as const; + +/** + * EURe -> USDC.e (0.30%) -> USDC (0.01%). The direct EURe/USDC pool holds ~1k USDC in range and + * dies near 1,000 EUR; the bridged-USDC pool holds ~20k and USDC.e/USDC is deep, so this path + * quotes 5,000 EUR at ~0.3% impact (2026-09-17). Ordered from input to output. + */ +export const POLYGON_EURE_USDC_ROUTE = [ + { fee: 3000, pool: "0x7d4324293304797cB662C6EA1B904B6AF2b485F5", tokenIn: POLYGON_EURE, tokenOut: POLYGON_USDCE }, + { fee: 100, pool: "0xD36ec33c8bed5a9F7B6630855f1533455b98a418", tokenIn: POLYGON_USDCE, tokenOut: POLYGON_USDC } +] as const; +/** Uniswap v3 packed path for `exactInput` / `quoteExactInput`: token, fee, token, fee, token. */ +export const POLYGON_EURE_USDC_PATH = encodePacked( + ["address", "uint24", "address", "uint24", "address"], + [POLYGON_EURE, POLYGON_EURE_USDC_ROUTE[0].fee, POLYGON_USDCE, POLYGON_EURE_USDC_ROUTE[1].fee, POLYGON_USDC] +); export const UNISWAP_APPROVE_GAS_LIMIT = 100_000n; export const UNISWAP_SWAP_GAS_LIMIT = 500_000n; @@ -25,10 +39,10 @@ export const uniswapV3FactoryAbi = parseAbi([ export const uniswapV3QuoterAbi = parseAbi([ "function factory() view returns (address)", - "function quoteExactInputSingle(address tokenIn,address tokenOut,uint24 fee,uint256 amountIn,uint160 sqrtPriceLimitX96) returns (uint256 amountOut)" + "function quoteExactInput(bytes path,uint256 amountIn) returns (uint256 amountOut)" ]); export const uniswapV3RouterAbi = parseAbi([ "function factory() view returns (address)", - "function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 deadline,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96) params) payable returns (uint256 amountOut)" + "function exactInput((bytes path,address recipient,uint256 deadline,uint256 amountIn,uint256 amountOutMinimum) params) payable returns (uint256 amountOut)" ]); diff --git a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/simulation.ts index 0c2128cce..6a7ffb83a 100644 --- a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/simulation.ts @@ -7,8 +7,8 @@ import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; import { MONERIUM_EURE } from "../monerium-issue/simulation"; import { POLYGON_EURE, - POLYGON_EURE_USDC_FEE, - POLYGON_EURE_USDC_POOL, + POLYGON_EURE_USDC_PATH, + POLYGON_EURE_USDC_ROUTE, POLYGON_UNISWAP_V3_FACTORY, POLYGON_UNISWAP_V3_QUOTER, POLYGON_UNISWAP_V3_ROUTER, @@ -20,7 +20,6 @@ import { } from "./contract"; export interface UniswapV3FixedSwapMetadata { - fee: number; inputAmountDecimal: SerializableBig; inputAmountRaw: string; inputToken: string; @@ -28,7 +27,8 @@ export interface UniswapV3FixedSwapMetadata { outputAmountDecimal: SerializableBig; outputAmountRaw: string; outputToken: string; - pool: string; + path: string; + pools: string[]; quoter: string; router: string; } @@ -46,26 +46,38 @@ function sameAddress(left: string, right: string): boolean { export async function verifyPolygonEureUsdcDeployment(): Promise { const client = EvmClientManager.getInstance().getClient(Networks.Polygon); - const [token0, token1, fee, poolFactory, canonicalPool, routerFactory, quoterFactory] = await Promise.all([ - client.readContract({ abi: uniswapV3PoolAbi, address: POLYGON_EURE_USDC_POOL, functionName: "token0" }), - client.readContract({ abi: uniswapV3PoolAbi, address: POLYGON_EURE_USDC_POOL, functionName: "token1" }), - client.readContract({ abi: uniswapV3PoolAbi, address: POLYGON_EURE_USDC_POOL, functionName: "fee" }), - client.readContract({ abi: uniswapV3PoolAbi, address: POLYGON_EURE_USDC_POOL, functionName: "factory" }), - client.readContract({ - abi: uniswapV3FactoryAbi, - address: POLYGON_UNISWAP_V3_FACTORY, - args: [POLYGON_EURE, POLYGON_USDC, POLYGON_EURE_USDC_FEE], - functionName: "getPool" - }), + const [routerFactory, quoterFactory, ...hops] = await Promise.all([ client.readContract({ abi: uniswapV3RouterAbi, address: POLYGON_UNISWAP_V3_ROUTER, functionName: "factory" }), - client.readContract({ abi: uniswapV3QuoterAbi, address: POLYGON_UNISWAP_V3_QUOTER, functionName: "factory" }) + client.readContract({ abi: uniswapV3QuoterAbi, address: POLYGON_UNISWAP_V3_QUOTER, functionName: "factory" }), + ...POLYGON_EURE_USDC_ROUTE.map(hop => + Promise.all([ + client.readContract({ abi: uniswapV3PoolAbi, address: hop.pool, functionName: "token0" }), + client.readContract({ abi: uniswapV3PoolAbi, address: hop.pool, functionName: "token1" }), + client.readContract({ abi: uniswapV3PoolAbi, address: hop.pool, functionName: "fee" }), + client.readContract({ abi: uniswapV3PoolAbi, address: hop.pool, functionName: "factory" }), + client.readContract({ + abi: uniswapV3FactoryAbi, + address: POLYGON_UNISWAP_V3_FACTORY, + args: [hop.tokenIn, hop.tokenOut, hop.fee], + functionName: "getPool" + }) + ]) + ) ]); + const hopsMatch = POLYGON_EURE_USDC_ROUTE.every((hop, index) => { + const [token0, token1, fee, poolFactory, canonicalPool] = hops[index]; + const pair = [token0, token1].map(getAddress).sort(); + const expected = [hop.tokenIn, hop.tokenOut].map(getAddress).sort(); + return ( + pair[0] === expected[0] && + pair[1] === expected[1] && + fee === hop.fee && + sameAddress(poolFactory, POLYGON_UNISWAP_V3_FACTORY) && + sameAddress(canonicalPool, hop.pool) + ); + }); if ( - !sameAddress(token0, POLYGON_EURE) || - !sameAddress(token1, POLYGON_USDC) || - fee !== POLYGON_EURE_USDC_FEE || - !sameAddress(poolFactory, POLYGON_UNISWAP_V3_FACTORY) || - !sameAddress(canonicalPool, POLYGON_EURE_USDC_POOL) || + !hopsMatch || !sameAddress(routerFactory, POLYGON_UNISWAP_V3_FACTORY) || !sameAddress(quoterFactory, POLYGON_UNISWAP_V3_FACTORY) ) { @@ -78,8 +90,8 @@ export async function quotePolygonEureToUsdc(amountIn: bigint): Promise const { result } = await client.simulateContract({ abi: uniswapV3QuoterAbi, address: POLYGON_UNISWAP_V3_QUOTER, - args: [POLYGON_EURE, POLYGON_USDC, POLYGON_EURE_USDC_FEE, amountIn, 0n], - functionName: "quoteExactInputSingle" + args: [POLYGON_EURE_USDC_PATH, amountIn], + functionName: "quoteExactInput" }); return result; } @@ -99,7 +111,6 @@ export async function simulateUniswapV3FixedSwap( ctx.addNote(`UniswapV3FixedSwap: ${input.amount.toFixed()} EURE -> ${outputAmountDecimal.toFixed()} USDC on Polygon`); return { metadata: { - fee: POLYGON_EURE_USDC_FEE, inputAmountDecimal: input.amount, inputAmountRaw: input.amountRaw, inputToken: POLYGON_EURE, @@ -107,7 +118,8 @@ export async function simulateUniswapV3FixedSwap( outputAmountDecimal, outputAmountRaw: outputAmountRaw.toString(), outputToken: POLYGON_USDC, - pool: POLYGON_EURE_USDC_POOL, + path: POLYGON_EURE_USDC_PATH, + pools: POLYGON_EURE_USDC_ROUTE.map(hop => hop.pool), quoter: POLYGON_UNISWAP_V3_QUOTER, router: POLYGON_UNISWAP_V3_ROUTER }, diff --git a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/transactions.ts index b5a05b4b0..d57ff8d4b 100644 --- a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/transactions.ts +++ b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/transactions.ts @@ -15,7 +15,7 @@ import { getEvmFundingAccount } from "../../core/evm-funding"; import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; import { POLYGON_EURE, - POLYGON_EURE_USDC_FEE, + POLYGON_EURE_USDC_PATH, POLYGON_UNISWAP_V3_ROUTER, POLYGON_USDC, UNISWAP_APPROVE_GAS_LIMIT, @@ -90,14 +90,11 @@ export async function prepareUniswapV3FixedSwapTxs( amountIn: BigInt(ctx.ownMetadata.inputAmountRaw), amountOutMinimum: BigInt(hardMinimumOutputRaw), deadline, - fee: POLYGON_EURE_USDC_FEE, - recipient: ephemeral.address as `0x${string}`, - sqrtPriceLimitX96: 0n, - tokenIn: POLYGON_EURE, - tokenOut: POLYGON_USDC + path: POLYGON_EURE_USDC_PATH, + recipient: ephemeral.address as `0x${string}` } ], - functionName: "exactInputSingle" + functionName: "exactInput" }), ...feeFields, gas: UNISWAP_SWAP_GAS_LIMIT.toString(), diff --git a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/validation.ts b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/validation.ts index 6eb97579e..425d945d5 100644 --- a/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/validation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/uniswap-v3-fixed-swap/validation.ts @@ -1,6 +1,6 @@ import { getNetworkId, type PresignedTx } from "@vortexfi/shared"; import { decodeFunctionData, erc20Abi, getAddress, parseTransaction, recoverTransactionAddress } from "viem"; -import { POLYGON_EURE, POLYGON_EURE_USDC_FEE, POLYGON_UNISWAP_V3_ROUTER, POLYGON_USDC, uniswapV3RouterAbi } from "./contract"; +import { POLYGON_EURE, POLYGON_EURE_USDC_PATH, POLYGON_UNISWAP_V3_ROUTER, uniswapV3RouterAbi } from "./contract"; export interface UniswapV3SwapExpectation { amountInRaw: string; @@ -56,17 +56,14 @@ export async function validateUniswapSwap(tx: PresignedTx, expectation: UniswapV throw new Error("Uniswap swap signer or router does not match the fixed route"); } const decoded = decodeFunctionData({ abi: uniswapV3RouterAbi, data: parsed.data ?? "0x" }); - if (decoded.functionName !== "exactInputSingle") throw new Error("Uniswap swap call is not exactInputSingle"); + if (decoded.functionName !== "exactInput") throw new Error("Uniswap swap call is not exactInput"); const params = decoded.args[0]; if ( - !sameAddress(params.tokenIn, POLYGON_EURE) || - !sameAddress(params.tokenOut, POLYGON_USDC) || - params.fee !== POLYGON_EURE_USDC_FEE || + params.path.toLowerCase() !== POLYGON_EURE_USDC_PATH.toLowerCase() || !sameAddress(params.recipient, expectation.signer) || params.deadline !== BigInt(expectation.deadline) || params.amountIn !== BigInt(expectation.amountInRaw) || - params.amountOutMinimum !== BigInt(expectation.hardMinimumOutputRaw) || - params.sqrtPriceLimitX96 !== 0n + params.amountOutMinimum !== BigInt(expectation.hardMinimumOutputRaw) ) { throw new Error("Uniswap swap does not match the fixed Polygon EURe/USDC route"); } 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..3174a6107 --- /dev/null +++ b/apps/api/src/api/services/quote/eur-onramp-network.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, QuoteError, RampDirection } from "@vortexfi/shared"; +import { config } from "../../../config/vars"; +import { QuoteService } from "."; + +const request = { + from: EPaymentMethod.SEPA, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + network: Networks.AssetHub, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.AssetHub +}; + +describe("EUR onramp destination rejection", () => { + it("returns a public bad request for an AssetHub destination", async () => { + await expect(new QuoteService().createQuote(request)).rejects.toMatchObject({ + isPublic: true, + message: QuoteError.EurOnrampNetworkUnsupported, + status: 400 + }); + }); +}); + +describe("EUR onramp kill switch", () => { + const enabled = { ...request, network: Networks.Polygon, to: Networks.Polygon }; + const unavailable = { isPublic: true, message: QuoteError.AnchorTemporarilyUnavailable, status: 503 }; + + it("rejects direct and best quotes for new EUR pay-ins while disabled", async () => { + config.monerium.eurOnrampEnabled = false; + try { + await expect(new QuoteService().createQuote(enabled)).rejects.toMatchObject(unavailable); + const { network: _network, to: _to, ...best } = enabled; + await expect(new QuoteService().createBestQuote(best)).rejects.toMatchObject(unavailable); + } finally { + config.monerium.eurOnrampEnabled = true; + } + }); +}); diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 46d348d3d..b3bd1ba2a 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,8 @@ export class QuoteService extends BaseRampService { } ): Promise { assertEurOfframpSupported(request); + assertEurOnrampEnabled(request); + assertEurOnrampNetworkSupported(request); return this.executeQuoteCalculation(request); } @@ -86,6 +89,7 @@ export class QuoteService extends BaseRampService { } ): Promise { assertEurOfframpSupported(request); + assertEurOnrampEnabled(request); const { rampType, from, to, networks } = request; // Determine eligible networks based on the corridor @@ -300,6 +304,33 @@ function assertEurOfframpSupported(request: Pick): void { + if (request.rampType === RampDirection.BUY && request.inputCurrency === FiatToken.EURC && !config.monerium.eurOnrampEnabled) { + throw new APIError({ + isPublic: true, + message: QuoteError.AnchorTemporarilyUnavailable, + status: httpStatus.SERVICE_UNAVAILABLE + }); + } +} + +/** + * Best-quote aggregation skips unsupported networks on its own; only a direct quote needs the + * public rejection so clients can tell an unsupported destination from a pricing failure. + */ +function assertEurOnrampNetworkSupported(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/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts index 1897afb19..a0fcf4d8e 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts @@ -113,6 +113,11 @@ const EUR_ONRAMP_BASE = { to: Networks.Base }; const EUR_ONRAMP_TO_ARBITRUM = { ...EUR_ONRAMP_BASE, to: Networks.Arbitrum }; +const EUR_ONRAMP_TO_POLYGON = { + ...EUR_ONRAMP_BASE, + metadata: { flow: { id: "MoneriumOnrampPolygonSameChain" } }, + to: Networks.Polygon +}; const LEGACY_EUR_ONRAMP = { ...EUR_ONRAMP_BASE, metadata: { globals: {} } }; const IDENTITY_BEARING_LEGACY_EUR_ONRAMP = { ...EUR_ONRAMP_BASE, @@ -183,6 +188,10 @@ describe("quoteToSigningNetworks", () => { }); }); + it("same-chain EUR on-ramp signs on Polygon only", () => { + expect(quoteToSigningNetworks(EUR_ONRAMP_TO_POLYGON)).toEqual({ evm: [Networks.Polygon], substrate: [] }); + }); + it("persisted identity-less Mykobo EUR onramps continue to sign on Base", () => { expect(quoteToSigningNetworks(LEGACY_EUR_ONRAMP)).toEqual({ evm: [Networks.Base], substrate: [] }); }); diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.ts index 9fa376f9a..a8a4e071a 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.ts @@ -89,7 +89,13 @@ export function quoteToSigningNetworks(quote: FreshnessQuote): SigningNetworks { ? (quote.metadata.flow as { id?: unknown } | undefined)?.id : undefined; // Persisted Mykobo quotes sign on Base; active Monerium quotes sign on Polygon. - evm.add(quote.metadata === undefined || flowId === "MoneriumOnrampPolygonCrossChain" ? Networks.Polygon : Networks.Base); + evm.add( + quote.metadata === undefined || + flowId === "MoneriumOnrampPolygonCrossChain" || + flowId === "MoneriumOnrampPolygonSameChain" + ? Networks.Polygon + : Networks.Base + ); addIfEvm(toNetwork); } else if (isDomesticToken(quote.inputCurrency as FiatToken)) { evm.add(Networks.Polygon); // mint chain diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 2f3a17484..d73ea8747 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -126,22 +126,15 @@ describe("vars deployment environment validation", () => { expect(result.stderr).toContain("MONERIUM_CLIENT_ID"); }); - it("requires the Monerium white-label credentials in production", async () => { - const missingClientId = await importVarsWithEnv({ + it("boots in production without the Monerium white-label credentials", async () => { + const result = await importVarsWithEnv({ DEPLOYMENT_ENV: "production", MONERIUM_WHITELABEL_CLIENT_ID: "", - NODE_ENV: "production" - }); - const missingClientSecret = await importVarsWithEnv({ - DEPLOYMENT_ENV: "production", MONERIUM_WHITELABEL_CLIENT_SECRET: "", NODE_ENV: "production" }); - expect(missingClientId.exitCode).toBe(1); - expect(missingClientId.stderr).toContain("MONERIUM_WHITELABEL_CLIENT_ID"); - expect(missingClientSecret.exitCode).toBe(1); - expect(missingClientSecret.stderr).toContain("MONERIUM_WHITELABEL_CLIENT_SECRET"); + expect(result.exitCode).toBe(0); }); it("requires an explicit Monerium issue fee in production", async () => { diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index f32a0f051..8fcd437bc 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -214,9 +214,11 @@ interface Config { monerium: { apiUrl: string; clientId: string; + eurOnrampEnabled: boolean; issueFeeEur: string | undefined; redirectUri: string; whiteLabelClientId: string; + widgetRedirectUri: string | undefined; whiteLabelClientSecret: string; }; // B2B whitelabel onramp integration (docs/architecture-monerium-b2b-onramp.md §3). @@ -331,10 +333,13 @@ export const config: Config = { process.env.MONERIUM_API_URL || (process.env.SANDBOX_ENABLED === "true" ? "https://api.monerium.dev" : "https://api.monerium.app"), clientId: process.env.MONERIUM_CLIENT_ID || "", + // Kill switch for new EUR pay-in quotes; ramps already registered keep executing. + eurOnrampEnabled: process.env.EUR_ONRAMP_ENABLED !== "false", 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 @@ -517,8 +522,7 @@ if (config.env === "production") { if (!config.monerium.clientId) missing.push("MONERIUM_CLIENT_ID"); if (!config.monerium.issueFeeEur) missing.push("MONERIUM_ISSUE_FEE_EUR"); if (!process.env.MONERIUM_REDIRECT_URI) missing.push("MONERIUM_REDIRECT_URI"); - if (!config.monerium.whiteLabelClientId) missing.push("MONERIUM_WHITELABEL_CLIENT_ID"); - if (!config.monerium.whiteLabelClientSecret) missing.push("MONERIUM_WHITELABEL_CLIENT_SECRET"); + // The white-label pair is optional: without it every Monerium read uses the user's OAuth token. if (missing.length > 0) { throw new Error(`Missing required environment variables in production: ${missing.join(", ")}`); 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..be2d59b19 --- /dev/null +++ b/apps/api/src/tests/corridors/eur-onramp-monerium.scenario.test.ts @@ -0,0 +1,605 @@ +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, 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"; +import { SquidRouterSwapContext } from "../../api/services/phases/blocks/phases/squid-router-swap/simulation"; +import { + POLYGON_EURE, + POLYGON_EURE_USDC_ROUTE, + 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.e -> USDC deployment verifies against these constants; + // per-ramp allowance and nonce reads are layered on top by scriptHappyWorld. + const sameAddress = (a: unknown, b: string) => typeof a === "string" && a.toLowerCase() === b.toLowerCase(); + world.evm.onReadContract = (_network, params) => { + const hop = POLYGON_EURE_USDC_ROUTE.find(candidate => sameAddress(params.address, candidate.pool)); + switch (params.functionName) { + case "token0": + return hop?.tokenIn; + case "token1": + return hop?.tokenOut; + case "fee": + return hop?.fee; + case "factory": + return POLYGON_UNISWAP_V3_FACTORY; + case "getPool": { + const [tokenIn, tokenOut, fee] = params.args ?? []; + return POLYGON_EURE_USDC_ROUTE.find( + candidate => candidate.fee === fee && sameAddress(tokenIn, candidate.tokenIn) && sameAddress(tokenOut, candidate.tokenOut) + )?.pool; + } + default: + return undefined; + } + }; + world.evm.onSimulateContract = (_network, params) => { + if (params.functionName === "quoteExactInput") { + const amountIn = params.args?.[1] 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}`, + customerType?: "individual" | "business" + ): Promise { + return app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { ...(customerType ? { customerType } : {}), 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); + }); + + 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 new file mode 100644 index 000000000..9c6f6a38f --- /dev/null +++ b/apps/api/src/tests/monerium-active-ramp.integration.test.ts @@ -0,0 +1,122 @@ +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"; + +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(); + }); +}); + +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 new file mode 100644 index 000000000..07e4dde75 --- /dev/null +++ b/apps/api/src/tests/monerium-binding-entities.integration.test.ts @@ -0,0 +1,115 @@ +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 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(); + 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("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"); + 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)).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 () => { + 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/apps/dashboard/src/components/onboarding/CorridorCard.tsx b/apps/dashboard/src/components/onboarding/CorridorCard.tsx index d18d2c817..eff95f662 100644 --- a/apps/dashboard/src/components/onboarding/CorridorCard.tsx +++ b/apps/dashboard/src/components/onboarding/CorridorCard.tsx @@ -13,6 +13,7 @@ import type { AlfredpayCorridorId } from "@/domain/fiatAccounts"; import { STATUS_META } from "@/domain/status"; import type { Corridor, OnboardingRoute, OnboardingStatus, SenderAccount } from "@/domain/types"; import { useFiatAccounts } from "@/hooks/useFiatAccounts"; +import { moneriumWalletLinkRequired } from "./monerium/walletStep"; import { PayoutAccountsSection } from "./PayoutAccountsSection"; import { StatusBadge } from "./StatusBadge"; @@ -45,13 +46,16 @@ 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" && moneriumWalletLinkRequired(onboarding); 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 ? ( { + 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"); + }); +}); + +describe("Monerium wallet link requirement", () => { + it("yields to reauthentication when readiness could not be read", () => { + assert.equal(moneriumWalletLinkRequired({ ramp: null, reauthenticationRequired: true, status: "approved" }), false); + assert.equal(moneriumWalletLinkRequired({ ramp: null, reauthenticationRequired: false, status: "approved" }), true); + assert.equal(moneriumWalletLinkRequired({ ramp, reauthenticationRequired: false, status: "approved" }), false); + assert.equal(moneriumWalletLinkRequired({ ramp: null, reauthenticationRequired: false, status: "in_review" }), false); + }); +}); diff --git a/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx new file mode 100644 index 000000000..d0045e4df --- /dev/null +++ b/apps/dashboard/src/components/onboarding/monerium/MoneriumWalletLinkFlow.tsx @@ -0,0 +1,218 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createMoneriumKycApi, MoneriumAuthorizationRequiredError, type MoneriumCustomerType } from "@vortexfi/kyc"; +import { AlertTriangle, CheckCircle2, Loader2, Wallet } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { useAccount } from "wagmi"; +import { ConnectWalletButton } from "@/components/layout/ConnectWalletButton"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import { shortenAddress } from "@/domain/transfer"; +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; + +interface MoneriumWalletLinkFlowProps { + customerType: MoneriumCustomerType; + onClose: () => 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 => { + 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; + + 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, 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, customerType }); + }, + onSuccess: refresh + }); + const reauthorize = useMutation({ + 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 ( + + +

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 (step === "ready" && 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() || 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 ( + <> + + +
+

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 && 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}

} +
+
+ + + {!address ? ( + + ) : needsMove ? ( + + ) : ( + + )} + + + ); +} + +function Centered({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/dashboard/src/components/onboarding/monerium/walletStep.ts b/apps/dashboard/src/components/onboarding/monerium/walletStep.ts new file mode 100644 index 000000000..5c5adcee1 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/monerium/walletStep.ts @@ -0,0 +1,22 @@ +import type { MoneriumRampReadiness, MoneriumWalletLinkResult } from "@vortexfi/kyc"; +import type { Onboarding } from "@/domain/types"; + +/** An approved profile still needs its pay-in wallet linked; a lost OAuth session must be reconnected first. */ +export function moneriumWalletLinkRequired( + onboarding: Pick | undefined +): boolean { + return ( + onboarding?.status === "approved" && onboarding.reauthenticationRequired !== true && onboarding.ramp?.iban !== "provisioned" + ); +} + +export function moneriumWalletStep( + ramp: MoneriumRampReadiness, + address: string | undefined, + linked: MoneriumWalletLinkResult | undefined +): "ready" | "move" | "link" { + const currentWallet = !!address && ramp.linkedAddress?.toLowerCase() === address.toLowerCase(); + if (ramp.iban === "provisioned" && currentWallet) return "ready"; + const linkedHere = currentWallet || (!!address && linked?.address.toLowerCase() === address.toLowerCase()); + return ramp.iban !== "missing" && linkedHere ? "move" : "link"; +} diff --git a/apps/dashboard/src/components/transfer/OnrampForm.tsx b/apps/dashboard/src/components/transfer/OnrampForm.tsx index 473d3db2e..793782e69 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,20 @@ 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 + ? { + customerType: account.type === "company" ? "business" : "individual", + destinationAddress: values.destinationAddress, + walletAddress: address + } + : { destinationAddress: values.destinationAddress }, meta: { accountId: account.id, amountIn: quote.inputAmount, @@ -187,7 +200,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 +304,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 +342,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..265452362 100644 --- a/apps/dashboard/src/hooks/useActiveAccount.ts +++ b/apps/dashboard/src/hooks/useActiveAccount.ts @@ -1,56 +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, - 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/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.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; +} 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/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx b/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx new file mode 100644 index 000000000..febb09a15 --- /dev/null +++ b/apps/frontend/src/components/Monerium/MoneriumKycFlow.tsx @@ -0,0 +1,137 @@ +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; + /** 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, link, primaryLabel, onPrimary, secondaryLabel, onSecondary }: ActionPanelProps) => ( +
+

{title}

+

{description}

+ {link && ( + + {link.label} + + )} + + +
+); + +/** 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") { + // 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 ( + + ); + } + + 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..296e9b53d --- /dev/null +++ b/apps/frontend/src/components/Monerium/MoneriumWalletFlow.tsx @@ -0,0 +1,85 @@ +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 ( +
+

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

+ + +
+ ); + } + 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/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index f2928d113..efee7265f 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 settles on EVM networks only. + if (direction === RampDirection.BUY && fiatToken === FiatToken.EURC) { + return onChainTokens.filter(token => doesNetworkSupportEurOnramp(token.network)); + } + return onChainTokens; } 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..77a3a60ee 100644 --- a/apps/frontend/src/machines/actors/register.actor.test.ts +++ b/apps/frontend/src/machines/actors/register.actor.test.ts @@ -52,6 +52,22 @@ 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({ + customerType: "individual", + destinationAddress: "0x2222222222222222222222222222222222222222", + sessionId: "session-1", + walletAddress: "0x1111111111111111111111111111111111111111" + }); + expect(buildRegisterRampAdditionalData({ ...buyContext, kybLink: { customerType: "business" } } as RampContext, baseContext.connectedWalletAddress as string)).toMatchObject({ + customerType: "business" + }); + }); + 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..8d7dde886 100644 --- a/apps/frontend/src/machines/actors/registerAdditionalData.ts +++ b/apps/frontend/src/machines/actors/registerAdditionalData.ts @@ -34,14 +34,13 @@ 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 { + customerType: input.kybLink?.customerType === "business" ? "business" : "individual", 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..a4252dd5f --- /dev/null +++ b/apps/frontend/src/machines/moneriumKyc.machine.ts @@ -0,0 +1,26 @@ +import * as Sentry from "@sentry/react"; +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, + reportError: error => Sentry.captureException(error) +}); 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..2fad2db32 --- /dev/null +++ b/apps/frontend/src/machines/moneriumWallet.machine.test.ts @@ -0,0 +1,155 @@ +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", customerType: "individual", 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("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 }), + 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("reports an unexpected wallet-link failure once with its original error", async () => { + const failure = new Error("Monerium wallet HTTP 500"); + const client = api([status({ iban: "missing" })]).api; + client.linkWallet.mockRejectedValue(failure); + const reportError = vi.fn(); + const actor = createActor(createMoneriumWalletMachine(client, reportError), { input: input() }).start(); + + await waitFor(actor, snapshot => snapshot.matches("Failure")); + expect(reportError).toHaveBeenCalledExactlyOnceWith(failure); + expect(actor.getSnapshot().context.error).toBe(failure.message); + }); + + it("does not report a reconnect request as an unexpected failure", async () => { + const client = api([status(null, { code: "MONERIUM_REAUTHENTICATION_REQUIRED", message: "Reconnect Monerium" })]).api; + const reportError = vi.fn(); + const actor = createActor(createMoneriumWalletMachine(client, reportError), { input: input() }).start(); + await waitFor(actor, snapshot => snapshot.matches("Failure")); + expect(reportError).not.toHaveBeenCalled(); + }); + + 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..454dde4dc --- /dev/null +++ b/apps/frontend/src/machines/moneriumWallet.machine.ts @@ -0,0 +1,217 @@ +import * as Sentry from "@sentry/react"; +import { + MONERIUM_REAUTHENTICATION_REQUIRED, + MoneriumAuthorizationRequiredError, + type MoneriumCustomerType, + type MoneriumKycApi, + type MoneriumRampReadiness, + type 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; + /** Provisioning polls so far; the wait is bounded rather than spinning forever. */ + polls?: number; + 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; +/** 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 ( + 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, + reportError: (error: Error) => void = error => Sentry.captureException(error) +) { + return setup({ + actions: { + reportUnexpectedError: ({ event }) => { + const error = (event as { error?: unknown }).error; + if (error instanceof Error && !(error instanceof MoneriumAuthorizationRequiredError)) reportError(error); + }, + 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, + customerType: input.customerType, + 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, customerType: input.customerType }); + }), + readReadiness: fromPromise(async ({ input }: { input: MoneriumWalletContext }): Promise => { + const status = await api.getStatus(input.customerType); + if (status.rampError) { + if (status.rampError.code === MONERIUM_REAUTHENTICATION_REQUIRED) + throw new MoneriumAuthorizationRequiredError(status.rampError.message); + 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)), + 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 ( + 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", + 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" } + ], + onError: { + actions: [ + "reportUnexpectedError", + 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: [ + "reportUnexpectedError", + 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: ["reportUnexpectedError", 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: { + 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" } } + } + } + }); +} + +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/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..cd377f972 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,55 @@ export const PHASE_FLOWS = { "distributeFees", "destinationTransfer", "complete" + ] as RampPhase[], + + // Mirrors the API's MoneriumOnrampPolygonCrossChain flow (monerium-onramp-polygon-cross-chain.ts); + // the same-chain Polygon variant skips the Squid pay and final-settlement phases. + 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; +} 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/services/api/api-client.ts b/apps/frontend/src/services/api/api-client.ts index b8ee31e2f..93797d281 100644 --- a/apps/frontend/src/services/api/api-client.ts +++ b/apps/frontend/src/services/api/api-client.ts @@ -83,6 +83,7 @@ function normalizePath(path: string): string { const DOMAIN_BY_SEGMENT: Record = { alfredpay: SentryDomain.Kyc, brla: SentryDomain.Kyc, + monerium: SentryDomain.Kyc, mykobo: SentryDomain.Kyc, quotes: SentryDomain.Quote, ramp: SentryDomain.Ramp, 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 52c7f0a81..7829cc4be 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -587,6 +587,51 @@ }, "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" + }, + "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" + }, + "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": "Yes, move my IBAN", + "description": "Moving your Monerium IBAN to {{address}} changes the wallet that receives all future EUR 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.", + "title": "Do you want to change your IBAN's 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", @@ -1557,6 +1602,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 94628c322..7a817a73f 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -590,6 +590,51 @@ }, "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" + }, + "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" + }, + "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": "Sim, mover meu IBAN", + "description": "Mover seu IBAN Monerium para {{address}} muda a carteira que recebe todos os depósitos futuros em EUR nesse IBAN. Outros serviços que usam o mesmo IBAN, como o Gnosis Pay, podem depender da carteira atual. Verifique esses serviços antes de confirmar.", + "title": "Quer mudar a carteira do seu IBAN?" + }, + "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", @@ -1561,6 +1606,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.", 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/README.md b/docs/README.md index 9349e7b24..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,7 +29,7 @@ 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 | diff --git a/docs/adr-0006-monerium-dual-app.md b/docs/adr-0006-monerium-dual-app.md new file mode 100644 index 000000000..f3834e09b --- /dev/null +++ b/docs/adr-0006-monerium-dual-app.md @@ -0,0 +1,67 @@ +# 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. Without white-label credentials the backend uses the + OAuth token directly. 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/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/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..5cfca6ca7 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -181,14 +181,14 @@ Authenticated clients can request account limits with `POST /v1/limits`, passing ## EUR (SEPA) -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 uses the `"sepa"` rail identifier. New EUR BUY quotes use a Polygon source route and deliver to supported EVM destinations, Polygon included. AssetHub is not available as a destination 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. Supply `additionalData.customerType` (`"individual"` or `"business"`) to select the same legal profile used for onboarding and wallet linking. The SDK exposes this as optional `EurOnrampAdditionalData.customerType`; it becomes required when both legal types have Monerium profiles, otherwise the API returns `409 MONERIUM_CUSTOMER_TYPE_REQUIRED`. Profile UUID, address, and IBAN are still 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. Moving an existing IBAN to a new wallet requires a separate, informed confirmation because future deposits to that IBAN will mint to the new wallet and other services may rely on the old one. Those setup steps must be complete before registration. The owner permit expires one week after preparation (the swap presign deadline); late settlement can require manual resolution. --- diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index c9e64abf7..f2187e929 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; @@ -1519,6 +1519,7 @@ RecipientInviteeType: "business" | "individual" RegisterRampRequest: { additionalData?: { [key: string]: unknown; + customerType?: "business" | "individual"; destinationAddress?: string; email?: string; fiatAccountId?: string; @@ -2994,14 +2995,16 @@ AmountExceedsLimitError: class AmountExceedsLimitError { } AnyAdditionalData: { + customerType?: "business" | "individual"; destinationAddress: string; - email: string; - ipAddress: string; + email?: string; + ipAddress?: string; walletAddress: string; } | { destinationAddress: string; email: string; ipAddress: string; + walletAddress: string; } | { destinationAddress: string; fiatAccountId?: string; @@ -3645,9 +3648,20 @@ EurOfframpUpdateAdditionalData: { } EurOnrampAdditionalData: { + customerType?: "business" | "individual"; 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 +4304,35 @@ 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; +} + +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; @@ -4317,6 +4360,34 @@ 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; + 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; @@ -4714,9 +4785,11 @@ RegisterRampAdditionalData: UniSwap[uniswapSwap EURe to USDC] UniSwap --> MonDist[distributeFees on Polygon] MonDist --> MonSub[subsidizePostSwap on Polygon] - MonSub --> SquidSwap + MonSub --> MonDest{Destination = Polygon?} + MonDest -->|No| SquidSwap + MonDest -->|Yes, USDC| DestTransfer + MonDest -->|Yes, other token| MonSameChain[squidRouterSwap same-chain] + MonSameChain --> DestTransfer %% --- Destination routing (shared) --- SquidSwap --> Dest{Destination = Base USDC?} @@ -241,7 +246,7 @@ graph TD - [x] Backup presigned intents (`backupSquidRouterApprove`, `backupSquidRouterSwap`, `backupApprove`) are contingency payloads owned by `SquidRouterSwap`, not executable phases; executor-bijection validation correctly excludes them. **PASS** - [ ] No aggregate cross-ramp subsidy rate limiting — **ACCEPTED RISK RISK-001**; many concurrent ramps could drain the funding account. - [x] Active BRL corridors are end-to-end on Base. **PASS** — BRL↔AssetHub flow identities remain cataloged only for persisted schema/history compatibility; quote registration, start, and phase execution guard against their Moonbeam/Pendulum/XCM executors. -- [x] The active EUR BUY backend topology is cataloged and block-tested on Polygon plus the destination EVM chain, with no Pendulum involvement. **PASS** — the catalog maps SEPA/EUR BUY to `MoneriumOnrampPolygonCrossChain`; EUR SELL is rejected and Mykobo is persisted-recovery-only. Full fake-world corridor, SDK, and UI journeys remain missing and MUST NOT be described as end-to-end coverage. See `05-integrations/monerium.md` and `operations-testing.md`. +- [x] The active EUR BUY backend topology is cataloged and block-tested on Polygon plus the destination EVM chain, with no Pendulum involvement. **PASS** — the catalog maps SEPA/EUR BUY to `MoneriumOnrampPolygonCrossChain` or, for Polygon destinations, `MoneriumOnrampPolygonSameChain`; EUR SELL is rejected and Mykobo is persisted-recovery-only. Full fake-world corridor, SDK, and UI journeys remain missing and MUST NOT be described as end-to-end coverage. See `05-integrations/monerium.md` and `operations-testing.md`. - [x] Active Monerium EUR distributes fees after Polygon Uniswap produces USDC. Persisted Mykobo onramps/offramps retain their historical post-/pre-Nabla Base ordering. - [x] On the BRL/Base corridor, `distributeFees` is positioned **before** `nablaSwap` on offramp (USDC fees deducted pre-BRL-swap) and **after** `nablaSwap` on onramp (USDC fees deducted post-BRL→USDC swap). **PASS** — derived by `BrlOfframpBase` and the catalog-backed BRL onramp flows. - [x] EVM subsidy phases enforce USD-equivalent caps. **PASS** — the pre-swap subsidy and the post-swap actual-vs-quoted swap-output discrepancy component are each clamped to the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`) × quote output; the $1.00 floor keeps small quotes from being stuck below a workable subsidy allowance. `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` defaults to `0.05` and separately clamps the post-swap discount-derived component (no floor). Both fractions are env-overridable. Over-cap cases are intentionally recoverable retries: no transfer is submitted, and the ramp waits for operator intervention instead of moving to `failed`. diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index e4bd73e4f..5cd249055 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -57,7 +57,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p 8. **Ephemeral addresses submitted at `registerRamp` MUST be proven fresh on every chain their route signs on, before transactions are built** — Address format validation is insufficient. For each ephemeral type the client submits, the server MUST query the chains the quote's route actually signs on (`quoteToSigningNetworks`) and reject the registration if any check finds a non-fresh account. Freshness covers nonce **and** balance: Substrate `nonce === 0 && free === 0`; EVM `nonce === 0 && native balance === 0` (a nonce-0 EVM account can still hold funds). The route-derived set MUST be kept in sync with the route builders — under-listing a chain the ephemeral signs on silently reopens the freshness gap. Fail-closed on RPC errors. Without this, the server builds presigned transactions with assumed-fresh nonces, and execution halts mid-ramp on the first chain where the assumption breaks. See `02-signing-keys/ephemeral-accounts.md` invariant 7. 9. **Multi-account block preparation MUST preserve signer capabilities and nonce domains** — EVM and Substrate account metadata are supplied as typed capabilities to each phase. Nonces are allocated independently per `(network, signer)`. A transaction that consumes more than one nonce declares a positive `nonceSpan`. 10. **Client signatures MUST NOT expand platform gas liability beyond the server-issued envelope** — A raw EVM signature must preserve the unsigned gas limit exactly. `maxFeePerGas` and `maxPriorityFeePerGas` must be at least the server estimate but no greater than `PRESIGNED_EVM_FEE_MULTIPLIER` times that estimate (currently 3×, matching the shared production signer). Because viem decodes zero-valued EIP-1559 fee scalars as absent, an absent decoded fee is equivalent to zero only when the corresponding server estimate is zero; it remains invalid for every positive estimate. The same checks apply independently to every backup transaction. Any execution-time treasury calculation derived from a presign must re-bind it to the matching unsigned blueprint as defense in depth. -11. **Pinned AMM phases MUST validate route semantics in addition to the generic EVM envelope** — The Polygon Uniswap approval MUST authorize only the pinned router for exactly the quoted EURe input. Its swap MUST call only the pinned router's `exactInputSingle` with pinned EURe/USDC tokens and fee tier, exact input, persisted hard minimum and deadline, the ephemeral recipient, zero price limit, and zero native value. Execution MUST repeat this validation before broadcast. +11. **Pinned AMM phases MUST validate route semantics in addition to the generic EVM envelope** — The Polygon Uniswap approval MUST authorize only the pinned router for exactly the quoted EURe input. Its swap MUST call only the pinned router's `exactInput` with the pinned packed EURe → USDC.e → USDC path (tokens and fee tiers), exact input, persisted hard minimum and deadline, the ephemeral recipient, and zero native value. Execution MUST repeat this validation before broadcast. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 91d587e2c..4608a276f 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -12,12 +12,15 @@ 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 -flows remain executable only for persisted legacy quotes and ramps. New SEPA/EUR SELL quotes are +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` (other EVM +destinations) or `MoneriumOnrampPolygonSameChain` (Polygon destinations); 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. The Polygon onramp issues EURe to the profile-linked owner, funds the ephemeral, transfers the exact -quoted post-fee EURe from the owner, swaps it through the pinned Polygon Uniswap V3 EURe/USDC pool, +quoted post-fee EURe from the owner, swaps it through the pinned Polygon Uniswap V3 EURe → USDC.e → USDC path, distributes fees and applies any bounded post-swap subsidy in Polygon USDC, then uses Squid for destination settlement. Registration snapshots the owner's Polygon EURe balance before releasing the SEPA instruction. Execution advances when the balance reaches that baseline plus the quoted @@ -34,32 +37,40 @@ an internal route asset and remains outside the shared public token registry. The cataloged route is a parameterized flow factory with a canonical executor instance. Its Polygon conversion is pinned to EURe `0x18ec0A6E18E5bc3784fDd3a3634b31245ab704F6`, -native USDC `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359`, the 500-fee pool -`0x368A930B71326e3f640Df36378d931DbE3D03746`, factory +native USDC `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359`, bridged USDC.e +`0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`, the 3000-fee EURe/USDC.e pool +`0x7d4324293304797cB662C6EA1B904B6AF2b485F5`, the 100-fee USDC.e/USDC pool +`0xD36ec33c8bed5a9F7B6630855f1533455b98a418`, factory `0x1F98431c8aD98523631AE4a59f267346ea31F984`, router `0xE592427A0AEce92De3Edee1F18E0157C05861564`, and quoter `0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6`. ### 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 caller may select its +own legal profile by `customerType`; profile UUID, address, and IBAN are still server-derived. When +both legal types are bound, a missing selector fails with `MONERIUM_CUSTOMER_TYPE_REQUIRED`. 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. @@ -87,13 +98,16 @@ the active onramp; the first-party clients cannot yet do so. 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. -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. +14. New SEPA/EUR BUY quotes MUST resolve only to the Polygon Monerium flows. A direct EUR BUY quote for a destination they cannot serve (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. `EUR_ONRAMP_ENABLED=false` is the operational kill switch: direct and best-quote EUR BUY requests MUST return the public `503` `QuoteError.AnchorTemporarilyUnavailable`, while ramps already registered keep executing. +15. Production startup MUST fail without a Monerium auth-code client ID, exact callback URI, and explicit non-negative `MONERIUM_ISSUE_FEE_EUR`. The issue fee MUST NOT silently default to zero. The white-label credential pair is optional outside the B2B onramp; when it is absent, every Monerium read uses the user's OAuth token. 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 for the requested `customerType` (`individual` or `business`); the widget supplies its individual type and the dashboard supplies the selected account type. With one bound profile, legacy type-less calls may use it; with more than one, they MUST fail with a public `409` `MONERIUM_CUSTOMER_TYPE_REQUIRED`. When white-label credentials are configured, the resolver MUST read the selected profile through the white-label app first and, only when that app answers `403` or `404`, through the user's backend-held OAuth token (`resolveMoneriumIdentity`); without them it MUST use the OAuth token directly. 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. Registration MUST hold transaction-scoped profile and owner advisory locks through the active-ramp check and ramp insert. 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. +18. The Polygon conversion MUST verify each 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, through the pinned packed path, 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 select the same legal profile type as the status read, MUST link through the app that can read that profile, and MUST request its single IBAN only when it has none. `POST /v1/monerium/iban/move` MUST hold the same profile and current-owner advisory locks through the active-ramp check and provider mutation, and 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 for that legal type from the same list reads registration uses. OAuth-user API reads returning `401` at any point MUST surface `MONERIUM_REAUTHENTICATION_REQUIRED` so clients can reconnect. +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. First-party UIs MUST ask for informed confirmation before that request and explain that other services using the IBAN may depend on the old wallet. 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 @@ -108,8 +122,11 @@ the active onramp; the first-party clients cannot yet do so. | 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 | +| 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 @@ -120,15 +137,18 @@ the active onramp; the first-party clients cannot yet do so. - [x] IBAN and order methods preserve documented `304`/`202` semantics; signed SEPA messages and the EUR 15,000 evidence threshold are validated before submission. - [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] Production configuration requires the auth-code client ID, exact callback URI, and explicit non-negative issue fee; the white-label pair is optional and its absence routes reads through the OAuth token (`identity.test.ts`). `EUR_ONRAMP_ENABLED=false` stops new EUR BUY quotes with a public `503` (`eur-onramp-network.test.ts`). +- [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. -- [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] Polygon conversion verifies both pinned Uniswap V3 pools, quotes exact input over the packed path, prepares exact approval and `exactInput` transactions, validates their signed semantics, and reconciles allowance, receipt, and output thresholds. +- [x] The complete Polygon-to-destination topology is cataloged for supported EVM outputs, same-chain Polygon included; 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. -- [ ] 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. +- [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 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. @@ -139,9 +159,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 @@ -177,6 +197,7 @@ Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR rec | 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 | diff --git a/docs/security-spec/05-integrations/squid-router.md b/docs/security-spec/05-integrations/squid-router.md index c27d8538b..8258d429e 100644 --- a/docs/security-spec/05-integrations/squid-router.md +++ b/docs/security-spec/05-integrations/squid-router.md @@ -5,7 +5,7 @@ Squid Router is a cross-chain swap/routing protocol built on Axelar's General Message Passing (GMP). Vortex uses it for: - **BRL on-ramp**: Base USDC → user's destination EVM chain (any token). - **BRL off-ramp**: User's source EVM chain → Base USDC. -- **EUR on-ramp (Monerium on Polygon)**: Polygon USDC → user's non-Polygon destination EVM chain after the pinned EURe→USDC swap. +- **EUR on-ramp (Monerium on Polygon)**: Polygon USDC → user's destination EVM chain after the pinned EURe→USDC swap; Polygon destinations skip the bridge and use at most one same-chain swap. - **Persisted Mykobo EUR recovery**: Historical Base on/offramps retain their original Squid path, but no new quote selects them. - **Alfredpay on-ramp**: Polygon Alfredpay token → user's destination EVM chain/token, except for Polygon same-token passthrough. - **Off-ramp permit acquisition (Alfredpay)**: User source EVM → Polygon via the source-chain `TokenRelayer.execute()` with EIP-2612 permit. 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..0d68a7221 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -43,8 +43,9 @@ 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-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-023 | Accepted | High | Payments Platform + Operations | The active Monerium EUR onramp attributes settlement from the linked owner's EURe balance increasing by the quoted post-fee amount. It does not correlate a provider issue order or mint transaction to the ramp, so an unrelated, duplicate, late, concurrent, or replacement-ramp credit can satisfy the delta. | Registration snapshots the owner balance after resolving one approved profile/Polygon EOA/IBAN match and rejects a second live ramp for the same owner (which would share the permit nonce and race for one credit); IBAN moves are refused while a ramp waits on the current wallet; execution transfers only the exact quoted amount; excess stays with the owner; caller-controlled provider identity is rejected. | Implement deterministic provider-order or mint-transaction correlation before increasing Monerium volume, operating concurrent/replacement ramps for one owner, or claiming payment-level attribution. | +| RISK-024 | Accepted | High | Payments Platform + Product | The Monerium owner permit expires one week after preparation (the swap presign deadline) and can become stale if its nonce is consumed before SEPA settlement. There is no automatic reauthorization, refund, or recovery path, so late settlement can leave EURe in the owner's wallet and require manual resolution. | Payment instructions are withheld until the exact permit and downstream presigns validate; execution rechecks nonce, deadline, allowance, and balances and fails closed rather than broadening authorization. | Add a safe re-sign/recovery/refund flow and define the accepted SEPA settlement window before unattended operation at material volume. | +| RISK-025 | Accepted | Medium | Payments Platform + Product | Monerium profiles onboarded through the OAuth application are invisible to the white-label application, so their EUR readiness and ramp registration depend on an access/refresh token pair that exists only in backend memory. A backend restart, token revocation, or refresh failure makes such a user unregisterable until they reconnect Monerium. | Reads and registration fail closed with `MONERIUM_REAUTHENTICATION_REQUIRED`; the dashboard and widget prompt a reconnect; no token is persisted, so nothing at rest can be stolen. | Add an encrypted refresh-token store or migrate OAuth profiles into the white-label application before relying on unattended re-registration or observing reconnect prompts at material volume. | ## Review cadence diff --git a/packages/kyc/src/index.ts b/packages/kyc/src/index.ts index 5777ceeab..5e24a71d5 100644 --- a/packages/kyc/src/index.ts +++ b/packages/kyc/src/index.ts @@ -48,10 +48,18 @@ 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 { + MONERIUM_REAUTHENTICATION_REQUIRED, MoneriumAuthorizationRequiredError, type MoneriumCustomerType, type MoneriumKycContext, diff --git a/packages/kyc/src/monerium/api.ts b/packages/kyc/src/monerium/api.ts index feffa8c09..9e21a2353 100644 --- a/packages/kyc/src/monerium/api.ts +++ b/packages/kyc/src/monerium/api.ts @@ -1,12 +1,28 @@ -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; customerType?: MoneriumCustomerType }): Promise; } export interface MoneriumKycDeps { api: MoneriumKycApi; + /** Which registered callback the backend binds; defaults to the dashboard callback. */ + client?: MoneriumOAuthClient; openAuthorizationUrl: (url: string) => void; + /** The host reports unexpected actor failures through its own error monitor. */ + reportError?: (error: Error) => void; } diff --git a/packages/kyc/src/monerium/machine.test.ts b/packages/kyc/src/monerium/machine.test.ts index 6ad6c163c..f5e4e3591 100644 --- a/packages/kyc/src/monerium/machine.test.ts +++ b/packages/kyc/src/monerium/machine.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, mock } 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", @@ -71,7 +71,8 @@ describe("moneriumKycMachine", () => { }); it("surfaces a provider callback error without calling the API", async () => { - const machine = machineWith({} as MoneriumKycApi); + const reportError = mock(() => undefined); + const machine = createMoneriumKycMachine({ api: {} as MoneriumKycApi, openAuthorizationUrl: () => undefined, reportError }); const actor = createActor(machine, { input: { callback: { error: "access_denied", errorDescription: "The user declined access" }, @@ -81,6 +82,26 @@ describe("moneriumKycMachine", () => { await waitFor(actor, snapshot => snapshot.matches("Failure")); expect(actor.getSnapshot().context.error?.message).toBe("The user declined access"); + expect(reportError).not.toHaveBeenCalled(); + }); + + it("reports an unexpected status failure with the original error once", async () => { + const failure = new Error("Monerium status HTTP 500"); + const reportError = mock(() => undefined); + const machine = createMoneriumKycMachine({ + api: { + completeOAuth: async () => approved, + getStatus: async () => { throw failure; }, + startOAuth: async () => ({ authorizationUrl: "https://example.com/auth" }) + }, + openAuthorizationUrl: () => undefined, + reportError + }); + const actor = createActor(machine, { input: { customerType: "individual" } }).start(); + + await waitFor(actor, snapshot => snapshot.matches("Failure")); + expect(reportError).toHaveBeenCalledTimes(1); + expect(reportError).toHaveBeenCalledWith(failure); }); it("refreshes a pending profile to approved", async () => { @@ -110,4 +131,62 @@ 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"); + }); + + 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 fbe35f844..b2fa2d9e5 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"); @@ -11,16 +11,21 @@ function statusOutput(event: unknown): MoneriumStatusResponse { return (event as DoneActorEvent).output; } -export function createMoneriumKycMachine({ api, openAuthorizationUrl }: MoneriumKycDeps) { +export function createMoneriumKycMachine({ api, client, openAuthorizationUrl, reportError }: MoneriumKycDeps) { return setup({ actions: { openAuthorization: ({ context }) => { if (context.authorizationUrl) openAuthorizationUrl(context.authorizationUrl); }, + reportUnexpectedError: ({ context }) => { + if (context.error && !(context.callback && "error" in context.callback)) reportError?.(context.error); + }, storeStatus: assign({ 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,13 +35,16 @@ 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, 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: { @@ -58,6 +66,11 @@ export function createMoneriumKycMachine({ api, openAuthorizationUrl }: Monerium invoke: { input: ({ context }) => ({ customerType: context.customerType }), onDone: [ + { + actions: "storeStatus", + guard: "needsReauthentication", + target: "Ready" + }, { actions: "storeStatus", guard: "isApproved", @@ -112,6 +125,7 @@ export function createMoneriumKycMachine({ api, openAuthorizationUrl }: Monerium }, Done: { type: "final" }, Failure: { + entry: "reportUnexpectedError", on: { CLOSE: { target: "Done" }, RETRY: { target: "Ready" } } }, InReview: { @@ -121,7 +135,9 @@ export function createMoneriumKycMachine({ api, openAuthorizationUrl }: Monerium 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: { CLOSE: { target: "Done" }, REFRESH: { target: "CheckingStatus" } } }, Rejected: { on: { CLOSE: { target: "Done" }, RETRY: { target: "Ready" } } 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..ed892f641 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,21 @@ 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; + customerType?: MoneriumCustomerType; + }): 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..c12d1e168 100644 --- a/packages/kyc/src/monerium/types.ts +++ b/packages/kyc/src/monerium/types.ts @@ -1,11 +1,43 @@ 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. */ +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; + /** Selects the approved personal or corporate profile owned by this user. */ + customerType?: MoneriumCustomerType; + /** 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 +51,8 @@ export interface MoneriumKycContext extends MoneriumKycInput { authorizationUrl?: string; error?: Error; profileId?: string; + ramp?: MoneriumRampReadiness; + rampError?: { code: string; message: string }; status?: MoneriumKycStatus; statusExternal?: string; } @@ -26,8 +60,8 @@ export interface MoneriumKycContext extends MoneriumKycInput { export type MoneriumKycOutput = MoneriumKycContext; export class MoneriumAuthorizationRequiredError extends Error { - constructor() { - super("Monerium authorization is required"); + constructor(message = "Monerium authorization is required") { + super(message); this.name = "MoneriumAuthorizationRequiredError"; } } diff --git a/packages/sdk/ARCHITECTURE.md b/packages/sdk/ARCHITECTURE.md index c7e2aa675..572f045a3 100644 --- a/packages/sdk/ARCHITECTURE.md +++ b/packages/sdk/ARCHITECTURE.md @@ -13,10 +13,10 @@ state or a user's wallet. - `services/NetworkManager.ts` owns the RPC connections needed for ephemeral signing and initializes only the networks required for ephemeral signing. Quote and registration HTTP calls do not wait for chain WebSockets. -- `handlers/BrlHandler.ts`, `AlfredpayHandler.ts`, and `MykoboHandler.ts` adapt - corridor-specific registration and update data to the common lifecycle. `MykoboHandler` - remains the SDK's legacy SEPA adapter and is not compatible with the active backend - Monerium owner-permit flow. +- `handlers/BrlHandler.ts`, `DomesticHandler.ts`, and `EurHandler.ts` adapt + corridor-specific registration and update data to the common lifecycle. `EurHandler` + registers the Monerium onramp with the user's linked wallet and keeps the legacy Mykobo + SELL adapter for persisted flows. - `eip712.ts` classifies and attaches signatures for user-owned typed-data operations. - `storage.ts` optionally persists ephemeral recovery material for the caller. @@ -39,9 +39,8 @@ The SDK does not mint keys or complete KYC/KYB. transactions. The SDK signs only the ephemeral-owned set. For user-owned entries, the integrator supplies wallet callbacks to `submitUserTransactions`, or handles each entry through `getUserTransactionType`, `getTypedDataToSign`, and -`getTransactionToBroadcast`. The current EUR BUY handler is an explicit exception: it -drops the Monerium owner permit, so EUR BUY must use the direct API until that handler is -replaced. +`getTransactionToBroadcast`. EUR BUY follows the same rule: the Monerium owner permit is +returned as a user-owned typed-data transaction for the linked wallet to sign. ## State and custody diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a9088a3fe..4fdb4b0fb 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -201,13 +201,16 @@ Gets the current status of a ramp process. ##### `registerRamp(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..d39305649 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -24,10 +24,10 @@ 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 { 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,17 @@ 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); + 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}`); } @@ -178,7 +188,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 +212,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 +222,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..bae0837cf 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); @@ -253,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. @@ -452,6 +492,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 78% rename from packages/sdk/src/handlers/MykoboHandler.ts rename to packages/sdk/src/handlers/EurHandler.ts index aa365d4d2..a5320ca4d 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,20 @@ 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: { + ...(additionalData.customerType ? { customerType: additionalData.customerType } : {}), destinationAddress: additionalData.destinationAddress, - email: additionalData.email, - ipAddress: additionalData.ipAddress + walletAddress: additionalData.walletAddress }, quoteId, signingAccounts: accountMetas @@ -103,7 +111,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 +153,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..aa320c51b 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -149,9 +149,18 @@ export interface BrlOnrampAdditionalData { } export interface EurOnrampAdditionalData { + /** Required when the authenticated user owns both individual and business Monerium profiles. */ + customerType?: "individual" | "business"; 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 +206,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..3438f5237 100644 --- a/packages/sdk/test/errors.test.ts +++ b/packages/sdk/test/errors.test.ts @@ -1,6 +1,14 @@ import {describe, expect, test} from "bun:test"; import { DomesticOnrampKycRequiredError, + EurOnrampError, + MissingEurOnrampParametersError, + MissingMoneriumOfframpParametersError, + MissingMoneriumOnrampParametersError, + MissingMykoboOfframpParametersError, + MoneriumError, + MoneriumOnboardingRequiredError, + MoneriumReauthenticationRequiredError, BrlKycStatusError, MissingDomesticOfframpParametersError, MissingBrlOfframpParametersError, @@ -35,6 +43,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, @@ -124,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/eurHandler.test.ts b/packages/sdk/test/eurHandler.test.ts new file mode 100644 index 000000000..ffadfd317 --- /dev/null +++ b/packages/sdk/test/eurHandler.test.ts @@ -0,0 +1,138 @@ +// 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("passes the selected legal profile to the EUR registration request", async () => { + const { calls, handler } = setup(); + + await handler.registerEurOnramp("quote_eur", { + customerType: "business", + destinationAddress: DESTINATION, + walletAddress: OWNER + }); + + const reg = calls[0].payload as RegisterRampRequest; + expect(reg.additionalData).toEqual({ customerType: "business", destinationAddress: DESTINATION, walletAddress: OWNER }); + }); + + 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..b669c1d78 --- /dev/null +++ b/packages/sdk/test/vortexSdk.eurOnramp.test.ts @@ -0,0 +1,74 @@ +// 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("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"); + }); +}); 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/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index bb8cf2cda..4ae538ad1 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -179,6 +179,8 @@ export interface RegisterRampRequest { signingAccounts: AccountMeta[]; userId?: string; additionalData?: { + /** Selects the user's own individual or business Monerium profile for EUR BUY. */ + customerType?: "individual" | "business"; fiatAccountId?: string; // For determine the correct payment method for AlfredPay flows walletAddress?: string; destinationAddress?: string; diff --git a/packages/shared/src/helpers/networks.ts b/packages/shared/src/helpers/networks.ts index 55f70230a..2b4d22b96 100644 --- a/packages/shared/src/helpers/networks.ts +++ b/packages/shared/src/helpers/networks.ts @@ -155,6 +155,11 @@ export function isNetworkEVM(network: Networks): network is EvmNetworks { return getNetworkMetadata(network)?.isEVM ?? false; } +/** The EUR onramp mints EURe on Polygon and settles there or bridges onward, so it delivers to every EVM network. */ +export function doesNetworkSupportEurOnramp(network: Networks): network is EvmNetworks { + return isNetworkEVM(network); +} + export function isNetworkAssetHub(network: Networks): network is Networks.AssetHub { return getNetworkMetadata(network)?.id === ASSETHUB_CHAIN_ID; } 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); diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index bcf5098e9..987bc13f6 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -113,7 +113,10 @@ export const freeTokenConfig: Partial> = name: "Euro", symbol: "EUR" }, - maxBuyAmountRaw: "10000000000", + // EUR pay-ins swap EURe -> USDC.e -> USDC through two pinned Polygon Uniswap v3 pools that + // quoted 5,000 EUR at ~0.3% price impact and 10,000 at ~0.8% on 2026-09-17. The cap keeps a + // ramp well under the 5% execution slippage guard even with another one in flight. + maxBuyAmountRaw: "5000000000", maxSellAmountRaw: "10000000000", minBuyAmountRaw: "500000", minSellAmountRaw: "500000",