diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..2187cab4a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "contracts/monerium-forwarder/lib/forge-std"] + path = contracts/monerium-forwarder/lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index 9351b6628..0ca214336 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This is a Bun monorepo. | [`packages/kyc`](packages/kyc/) | Provider KYC/KYB state machines shared by the two web apps | | [`packages/sdk`](packages/sdk/) | Public `@vortexfi/sdk` integration package | | [`contracts/relayer`](contracts/relayer/) | Token relayer Solidity project | +| [`contracts/monerium-forwarder`](contracts/monerium-forwarder/) | Monerium B2B onramp forwarder Solidity project (Foundry) | See [`MAP.md`](MAP.md) for detailed wayfinding and [`docs/README.md`](docs/README.md) for the documentation structure. @@ -36,6 +37,11 @@ bun dev In a fresh Git worktree, run `bun bootstrap:worktree` instead of `bun install`; it also builds the shared and SDK workspaces required by the apps. +The Foundry-based contracts in `contracts/monerium-forwarder` use a git submodule +(`forge-std`). If you plan to work on those contracts, initialize it once with +`git submodule update --init` (or clone with `git clone --recurse-submodules`). +Everything else in the monorepo works without this step. + The default development command starts the shared package, API, and widget. Run other surfaces explicitly: @@ -68,6 +74,7 @@ bun test:frontend bun test:e2e bun test:e2e:dashboard bun test:contracts:relayer +bun test:contracts:monerium-forwarder ``` The root scripts in [`package.json`](package.json) are the canonical command list. diff --git a/apps/api/.env.example b/apps/api/.env.example index b314dd4cf..0d3c2b6af 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -159,6 +159,22 @@ 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 +# Server-to-server white-label access (shared client; also the Monerium B2B onramp +# credentials). Keep this backend-only. +MONERIUM_WHITELABEL_CLIENT_ID=your-monerium-whitelabel-client-id +MONERIUM_WHITELABEL_CLIENT_SECRET=your-monerium-whitelabel-client-secret + +# Monerium B2B onramp. The entire surface stays unmounted unless this is exactly true. +MONERIUM_B2B_ENABLED=false +# Required before enabling the feature. +MONERIUM_B2B_ATTESTOR_PRIVATE_KEY= +MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS= +MONERIUM_B2B_GUARDIAN_PRIVATE_KEY= +MONERIUM_B2B_KEEPER_PRIVATE_KEY= +MONERIUM_B2B_RPC_URL= +MONERIUM_B2B_WEBHOOK_SECRET= +# Required in production. Non-production environments may submit through the public RPC. +MONERIUM_B2B_PRIVATE_RPC_URL= # BRLA / Avenia # BRLA_BASE_URL= @@ -178,6 +194,20 @@ BRLA_PRIVATE_KEY=your-brla-private-key # ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID= # a KYC submission of that customer # AVENIA_CONTRACT_SUBACCOUNT_ID= # KYC-approved Avenia sandbox subaccount # AVENIA_CONTRACT_COMPANY_SUBACCOUNT_ID= # COMPANY sandbox subaccount with >=1 KYB attempt +# MONERIUM_CONTRACT_PROFILE_ID= # approved white-label sandbox profile +# MONERIUM_CONTRACT_ADDRESS= # address linked to that profile +# MONERIUM_CONTRACT_ADDRESS_CHAIN= # e.g. ethereum +# MONERIUM_CONTRACT_IBAN= # IBAN owned by that profile +# MONERIUM_CONTRACT_ORDER_ID= # existing sandbox order +# MONERIUM_CONTRACT_RUN_ADDRESS_FLOW=1 +# MONERIUM_CONTRACT_ADDRESS_SIGNATURE= # fresh EOA or combined off-chain EIP-1271 hex bytes +# MONERIUM_CONTRACT_RUN_IBAN_FLOW=1 +# MONERIUM_CONTRACT_RUN_ORDER_FLOW=1 +# MONERIUM_CONTRACT_ORDER_REQUEST_JSON= # freshly signed complete POST /orders body +# MONERIUM_CONTRACT_RUN_FILE_UPLOAD=1 +# MONERIUM_CONTRACT_RUN_WEBHOOK_FLOW=1 +# MONERIUM_CONTRACT_WEBHOOK_URL= # must acknowledge subscription.created with HTTP 200 +# MONERIUM_CONTRACT_WEBHOOK_SECRET= # whsec_ + base64-encoded 24-64 random bytes # Local manual flow testing only. Replaces BRLA and AlfredPay mints with an ephemeral # balance wait and pauses offramps before the anchor transfer. Development only. diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts new file mode 100644 index 000000000..db25244c6 --- /dev/null +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -0,0 +1,308 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import express from "express"; +import { config } from "../../../config/vars"; +import KycCase from "../../../models/kycCase.model"; +import ManagedProfile from "../../../models/managedProfile.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import User from "../../../models/user.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import moneriumB2bRoutes from "../../routes/v1/admin/monerium-b2b.route"; +import { forwarderConfigMismatch } from "../../services/monerium-b2b/account-provisioning"; + +const BASE_PATH = "/v1/admin/monerium-b2b"; +const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type": "application/json" }; + +const FORWARDER = "0x1111111111111111111111111111111111111111"; +const DESTINATION = "0x2222222222222222222222222222222222222222"; +const FALLBACK = "0x3333333333333333333333333333333333333333"; +const FACTORY = "0x4444444444444444444444444444444444444444"; + +describe("monerium b2b account mapping admin route", () => { + let server: ReturnType; + let baseUrl: string; + let originalRpcUrl: string | undefined; + + beforeAll(async () => { + originalRpcUrl = config.moneriumB2b.rpcUrl; + config.moneriumB2b.rpcUrl = undefined; + await setupTestDatabase(); + + const app = express(); + app.use(express.json()); + app.use(BASE_PATH, moneriumB2bRoutes); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not bind test server"); + baseUrl = `http://127.0.0.1:${address.port}${BASE_PATH}`; + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = originalRpcUrl; + server?.close(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + async function createManager(): Promise { + const profile = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: profile.id + }); + return profile.id; + } + + function post(body: unknown, headers: Record = ADMIN_HEADERS) { + return fetch(`${baseUrl}/accounts`, { body: JSON.stringify(body), headers, method: "POST" }); + } + + function validBody(managerProfileId: string, overrides: Record = {}) { + return { + contactEmail: "ops@client.example.com", + destination: DESTINATION, + externalSubjectId: "client-1", + fallbackAddress: FALLBACK, + forwarderAddress: FORWARDER, + managerProfileId, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e", + ...overrides + }; + } + + it("requires admin authentication", async () => { + const response = await post(validBody(crypto.randomUUID()), { "Content-Type": "application/json" }); + expect(response.status).toBe(401); + }); + + it("provisions the managed child, KYB mirror, and account", async () => { + const managerProfileId = await createManager(); + + const response = await post(validBody(managerProfileId)); + expect(response.status).toBe(201); + const { account } = await response.json(); + expect(account).toMatchObject({ + accountStatus: MoneriumAccountStatus.Onboarding, + created: true, + iban: null, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + + const child = await User.findByPk(account.profileId); + expect(child?.kind).toBe("managed"); + expect(child?.email).toBeNull(); + + const relationship = await ManagedProfile.findOne({ where: { profileId: account.profileId } }); + expect(relationship).toMatchObject({ + creationSource: "vortex", + externalSubjectId: "client-1", + managerProfileId, + status: "active" + }); + + const customer = await ProviderCustomer.findOne({ where: { customerEntityId: account.customerEntityId } }); + expect(customer).toMatchObject({ + customerType: "business", + provider: "monerium", + providerCustomerId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e", + rail: "eur", + status: VerificationStatus.Approved + }); + + const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } }); + expect(kycCase).toMatchObject({ status: VerificationStatus.Approved, type: "kyb" }); + expect(kycCase?.approvedAt).not.toBeNull(); + + const row = await MoneriumAccount.findByPk(account.accountId); + expect(row).toMatchObject({ + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: FORWARDER, + vortexProfileId: account.profileId + }); + }); + + it("is idempotent for an identical replay", async () => { + const managerProfileId = await createManager(); + + const first = await post(validBody(managerProfileId)); + expect(first.status).toBe(201); + const replay = await post(validBody(managerProfileId)); + expect(replay.status).toBe(200); + const { account } = await replay.json(); + expect(account.created).toBe(false); + + expect(await MoneriumAccount.count()).toBe(1); + expect(await ManagedProfile.count()).toBe(1); + expect(await ProviderCustomer.count()).toBe(1); + expect(await KycCase.count()).toBe(1); + }); + + it("adopts a pre-mapping account row that matches the deployed forwarder", async () => { + const managerProfileId = await createManager(); + await MoneriumAccount.create({ + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: FORWARDER, + profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + + const response = await post(validBody(managerProfileId)); + expect(response.status).toBe(200); + const { account } = await response.json(); + expect(account.created).toBe(false); + + const row = await MoneriumAccount.findByPk(account.accountId); + expect(row?.vortexProfileId).toBe(account.profileId); + }); + + it("rejects a divergent replay instead of overwriting", async () => { + const managerProfileId = await createManager(); + expect((await post(validBody(managerProfileId))).status).toBe(201); + + // Same Monerium profile, different forwarder. + const differentForwarder = await post( + validBody(managerProfileId, { forwarderAddress: "0x4444444444444444444444444444444444444444" }) + ); + expect(differentForwarder.status).toBe(409); + expect(await differentForwarder.json()).toMatchObject({ error: { code: "MONERIUM_B2B_ACCOUNT_CONFLICT" } }); + + // Same child, different Monerium profile. + const differentMonerium = await post( + validBody(managerProfileId, { moneriumProfileId: "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f" }) + ); + expect(differentMonerium.status).toBe(409); + + // Different subject claiming the same Monerium profile. + const differentSubject = await post( + validBody(managerProfileId, { + contactEmail: "other@client.example.com", + externalSubjectId: "client-2", + forwarderAddress: "0x5555555555555555555555555555555555555555" + }) + ); + expect(differentSubject.status).toBe(409); + + // Same everything, different feeBps: divergence, not a silent idempotent replay. + const differentFee = await post(validBody(managerProfileId, { feeBps: 25 })); + expect(differentFee.status).toBe(409); + + expect(await MoneriumAccount.count()).toBe(1); + expect(await ManagedProfile.count()).toBe(1); + expect(await ProviderCustomer.count()).toBe(1); + expect(await KycCase.count()).toBe(1); + expect(await User.count()).toBe(2); + }); + + it("compares submitted account data against the deployed clone config", () => { + const expected = { + destination: DESTINATION.toLowerCase(), + factory: FACTORY.toLowerCase(), + fallbackAddress: FALLBACK.toLowerCase(), + feeBps: 0 + }; + const matching = { destination: DESTINATION, factory: FACTORY, fallbackAddress: FALLBACK, feeBps: 0, isForwarder: true }; + + expect(forwarderConfigMismatch(expected, matching)).toBeNull(); + expect(forwarderConfigMismatch(expected, { ...matching, factory: FORWARDER })).toContain("trusted factory"); + expect(forwarderConfigMismatch(expected, { ...matching, isForwarder: false })).toContain("not a clone"); + expect(forwarderConfigMismatch(expected, { ...matching, destination: FALLBACK })).toContain("destination"); + expect(forwarderConfigMismatch(expected, { ...matching, fallbackAddress: DESTINATION })).toContain("fallbackAddress"); + expect(forwarderConfigMismatch(expected, { ...matching, feeBps: 30 })).toContain("feeBps"); + }); + + it("rejects invalid input and unknown managers", async () => { + const managerProfileId = await createManager(); + + for (const overrides of [ + { forwarderAddress: "not-an-address" }, + { destination: "0x12345" }, + { fallbackAddress: "" }, + { moneriumProfileId: "not-a-uuid" }, + { feeBps: 3.5 }, + { feeBps: -1 }, + { externalSubjectId: "" }, + { contactEmail: "not-an-email" } + ]) { + const response = await post(validBody(managerProfileId, overrides)); + expect(response.status).toBe(400); + } + expect(await MoneriumAccount.count()).toBe(0); + + const unknownManager = await post(validBody(crypto.randomUUID())); + expect(unknownManager.status).toBe(404); + expect(await unknownManager.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_MANAGER_NOT_FOUND" } }); + }); + + it("updates account status with the IBAN activation guard", async () => { + const managerProfileId = await createManager(); + const created = await post(validBody(managerProfileId)); + const { account } = await created.json(); + + function patchStatus(accountId: string, status: unknown) { + return fetch(`${baseUrl}/accounts/${accountId}/status`, { + body: JSON.stringify({ status }), + headers: ADMIN_HEADERS, + method: "PATCH" + }); + } + + // No IBAN yet: activation is refused, other transitions work. + const premature = await patchStatus(account.accountId, "active"); + expect(premature.status).toBe(409); + expect(await premature.json()).toMatchObject({ error: { code: "MONERIUM_B2B_ACCOUNT_NOT_READY" } }); + + await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516" }, { where: { id: account.accountId } }); + const activated = await patchStatus(account.accountId, "active"); + expect(activated.status).toBe(200); + expect(await activated.json()).toMatchObject({ account: { accountStatus: "active" } }); + + const suspended = await patchStatus(account.accountId, "suspended"); + expect(suspended.status).toBe(200); + + // Re-activation after a suspension keeps the IBAN guard satisfied. + const reactivated = await patchStatus(account.accountId, "active"); + expect(reactivated.status).toBe(200); + expect(await reactivated.json()).toMatchObject({ account: { accountStatus: "active" } }); + + const regressed = await patchStatus(account.accountId, "onboarding"); + expect(regressed.status).toBe(409); + expect(await regressed.json()).toMatchObject({ error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION" } }); + + const closed = await patchStatus(account.accountId, "closed"); + expect(closed.status).toBe(200); + expect((await patchStatus(account.accountId, "closed")).status).toBe(200); + for (const invalidStatus of ["active", "onboarding", "suspended"]) { + const reopened = await patchStatus(account.accountId, invalidStatus); + expect(reopened.status).toBe(409); + expect(await reopened.json()).toMatchObject({ error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION" } }); + } + expect((await MoneriumAccount.findByPk(account.accountId))?.status).toBe(MoneriumAccountStatus.Closed); + + expect((await patchStatus(account.accountId, "nonsense")).status).toBe(400); + expect((await patchStatus(crypto.randomUUID(), "active")).status).toBe(404); + }); + + it("refuses managers not allowed to provision business customers", async () => { + const profile = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["BR"], + allowedCustomerTypes: ["individual"], + isActive: true, + profileId: profile.id + }); + + const response = await post(validBody(profile.id)); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "MANAGED_PROFILE_INVALID_INPUT" } }); + expect(await MoneriumAccount.count()).toBe(0); + }); +}); diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts new file mode 100644 index 000000000..29804085a --- /dev/null +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -0,0 +1,152 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import { ManagedProfileProvisioningError } from "../../services/managed-profile-provisioning.service"; +import { MoneriumB2bProvisioningError, provisionMoneriumB2bAccount } from "../../services/monerium-b2b/account-provisioning"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function postMoneriumB2bAccount(req: Request, res: Response): Promise { + try { + const { + contactEmail, + destination, + externalSubjectId, + fallbackAddress, + feeBps, + forwarderAddress, + managerProfileId, + moneriumProfileId + } = req.body ?? {}; + if ( + typeof managerProfileId !== "string" || + !UUID_PATTERN.test(managerProfileId) || + typeof moneriumProfileId !== "string" || + typeof externalSubjectId !== "string" || + externalSubjectId.trim().length === 0 || + externalSubjectId.trim().length > 255 || + typeof contactEmail !== "string" || + typeof forwarderAddress !== "string" || + typeof destination !== "string" || + typeof fallbackAddress !== "string" || + (feeBps !== undefined && typeof feeBps !== "number") + ) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MONERIUM_B2B_INVALID_INPUT", + message: + "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; feeBps must be a number when present", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + const result = await provisionMoneriumB2bAccount({ + contactEmail, + destination, + externalSubjectId, + fallbackAddress, + feeBps, + forwarderAddress, + managerProfileId, + moneriumProfileId + }); + res.status(result.created ? httpStatus.CREATED : httpStatus.OK).json({ account: result }); + } catch (error) { + if (error instanceof MoneriumB2bProvisioningError) { + const status = error.code === "MONERIUM_B2B_INVALID_INPUT" ? httpStatus.BAD_REQUEST : httpStatus.CONFLICT; + res.status(status).json({ error: { code: error.code, message: error.message, status } }); + return; + } + if (error instanceof ManagedProfileProvisioningError) { + const status = + error.code === "MANAGED_PROFILE_CONFLICT" + ? httpStatus.CONFLICT + : error.code === "MANAGED_PROFILE_MANAGER_NOT_FOUND" + ? httpStatus.NOT_FOUND + : httpStatus.BAD_REQUEST; + res.status(status).json({ error: { code: error.code, message: error.message, status } }); + return; + } + + logger.error("Error provisioning Monerium B2B account:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to provision Monerium B2B account", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +const STATUS_VALUES = Object.values(MoneriumAccountStatus) as string[]; +const STATUS_TRANSITIONS: Record = { + [MoneriumAccountStatus.Onboarding]: [MoneriumAccountStatus.Active], + [MoneriumAccountStatus.Active]: [MoneriumAccountStatus.Suspended, MoneriumAccountStatus.Closed], + [MoneriumAccountStatus.Suspended]: [MoneriumAccountStatus.Active, MoneriumAccountStatus.Closed], + [MoneriumAccountStatus.Closed]: [] +}; + +export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: string }>, res: Response): Promise { + try { + const { status } = req.body ?? {}; + if (!UUID_PATTERN.test(req.params.accountId) || typeof status !== "string" || !STATUS_VALUES.includes(status)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MONERIUM_B2B_INVALID_INPUT", + message: `accountId must be a UUID and status must be one of ${STATUS_VALUES.join(", ")}`, + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + const account = await MoneriumAccount.findByPk(req.params.accountId); + if (!account) { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND", message: "Monerium account not found", status: httpStatus.NOT_FOUND } + }); + return; + } + const targetStatus = status as MoneriumAccountStatus; + if (targetStatus !== account.status && !STATUS_TRANSITIONS[account.status].includes(targetStatus)) { + res.status(httpStatus.CONFLICT).json({ + error: { + code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", + message: `Monerium account cannot transition from ${account.status} to ${targetStatus}`, + status: httpStatus.CONFLICT + } + }); + return; + } + // Activation requires the issued IBAN: the penny test (runbook §7) cannot have + // happened without it, and the association monitor needs the reference state. + if (status === MoneriumAccountStatus.Active && account.iban === null) { + res.status(httpStatus.CONFLICT).json({ + error: { + code: "MONERIUM_B2B_ACCOUNT_NOT_READY", + message: "The account has no issued IBAN yet and cannot be activated", + status: httpStatus.CONFLICT + } + }); + return; + } + + if (targetStatus !== account.status) { + await account.update({ status: targetStatus }); + } + res.status(httpStatus.OK).json({ account: { accountId: account.id, accountStatus: account.status } }); + } catch (error) { + logger.error("Error updating Monerium B2B account status:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update Monerium B2B account status", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts new file mode 100644 index 000000000..7edfe4737 --- /dev/null +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -0,0 +1,185 @@ +import { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import { Op } from "sequelize"; +import logger from "../../config/logger"; +import { config } from "../../config/vars"; +import MoneriumAccount from "../../models/moneriumAccount.model"; +import MoneriumConversionExecution from "../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../models/moneriumDepositAllocation.model"; +import MoneriumFiatDeposit from "../../models/moneriumFiatDeposit.model"; +import { APIError } from "../errors/api-error"; +import { getEffectiveUserId } from "../middlewares/effectiveUser"; +import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { UNATTRIBUTED_ORDER_PREFIX } from "../services/monerium-b2b/mint-watcher"; +import { + MONERIUM_ID_HEADER, + MONERIUM_SIGNATURE_HEADER, + MONERIUM_TIMESTAMP_HEADER, + recordWebhookEvent, + verifyWebhookSignature +} from "../services/monerium-b2b/webhook"; + +/** + * POST /v1/monerium-b2b/webhook — durable-inbox webhook receiver (plan §3, R06). + * Order of operations is load-bearing: HMAC over the RAW bytes first, then persist the + * delivery (dedup on event id), and only then 200. Processing happens asynchronously + * after the response — Monerium retries are absorbed by the inbox dedup. + */ +export const handleWebhook = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const secret = config.moneriumB2b.webhookSecret; + if (!secret) { + throw new APIError({ message: "Monerium B2B webhook secret is not configured", status: httpStatus.SERVICE_UNAVAILABLE }); + } + + // Raw bytes captured by the body-parser verify hook in config/express.ts. + const rawBody = (req as Request & { rawBody?: Buffer }).rawBody; + const webhookId = req.header(MONERIUM_ID_HEADER); + const webhookTimestamp = req.header(MONERIUM_TIMESTAMP_HEADER); + if ( + !rawBody || + !verifyWebhookSignature(rawBody, webhookId, webhookTimestamp, req.header(MONERIUM_SIGNATURE_HEADER), secret) + ) { + throw new APIError({ message: "Invalid webhook signature", status: httpStatus.UNAUTHORIZED }); + } + + let payload: unknown; + try { + payload = JSON.parse(rawBody.toString("utf8")); + } catch { + throw new APIError({ message: "Webhook payload is not valid JSON", status: httpStatus.BAD_REQUEST }); + } + + await recordWebhookEvent(webhookId as string, payload); + res.status(httpStatus.OK).json({ received: true }); + + setImmediate(() => { + processMoneriumWebhookInbox().catch(error => { + logger.error("monerium-b2b: async webhook inbox processing failed:", error); + }); + }); + } catch (error) { + next(error); + } +}; + +async function findAccountForEffectiveUser(req: Request): Promise { + const effectiveUserId = getEffectiveUserId(req); + if (!effectiveUserId) return null; + return MoneriumAccount.findOne({ where: { vortexProfileId: effectiveUserId } }); +} + +function accountNotFound(res: Response): void { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND", + message: "No Monerium account exists for the acting profile", + status: httpStatus.NOT_FOUND + } + }); +} + +/** + * GET /v1/monerium-b2b/account — the acting profile's onramp account. Scoped strictly + * to the effective user (manager delegation header or the child's own credential); no + * caller-supplied account or profile identifier is accepted. + */ +export const getMoneriumB2bAccount = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const account = await findAccountForEffectiveUser(req); + if (!account) { + accountNotFound(res); + return; + } + res.status(httpStatus.OK).json({ + account: { + accountId: account.id, + createdAt: account.createdAt, + destination: account.destination, + dormantSince: account.dormantSince, + fallbackAddress: account.fallbackAddress, + feeBps: account.feeBps, + forwarderAddress: account.forwarderAddress, + iban: account.iban, + status: account.status + } + }); + } catch (error) { + next(error); + } +}; + +const DEPOSIT_LIST_MAX_LIMIT = 100; + +/** + * GET /v1/monerium-b2b/deposits — the acting profile's EUR deposits, newest first, + * each with its allocated conversion execution once the swap has run. This is the + * polling surface for "payment received / converted". + */ +export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const account = await findAccountForEffectiveUser(req); + if (!account) { + accountNotFound(res); + return; + } + + const rawLimit = Number(req.query.limit ?? 20); + const rawOffset = Number(req.query.offset ?? 0); + const limit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, DEPOSIT_LIST_MAX_LIMIT) : 20; + const offset = Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0; + + const { count, rows } = await MoneriumFiatDeposit.findAndCountAll({ + limit, + offset, + order: [["created_at", "DESC"]], + // Unattributed inflows (R09 synthetic rows) are an ops concern, never a + // customer deposit claim — spec invariant, keep them out of the API. + where: { accountId: account.id, moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` } } + }); + + const allocations = rows.length + ? await MoneriumDepositAllocation.findAll({ + order: [["created_at", "ASC"]], + where: { depositId: rows.map(row => row.id) } + }) + : []; + const executionIds = [...new Set(allocations.map(allocation => allocation.executionId))]; + const executions = executionIds.length ? await MoneriumConversionExecution.findAll({ where: { id: executionIds } }) : []; + const executionById = new Map(executions.map(execution => [execution.id, execution])); + const allocationsByDeposit = new Map(); + for (const allocation of allocations) { + const grouped = allocationsByDeposit.get(allocation.depositId) ?? []; + grouped.push(allocation); + allocationsByDeposit.set(allocation.depositId, grouped); + } + + res.status(httpStatus.OK).json({ + deposits: rows.map(row => { + const depositAllocations = allocationsByDeposit.get(row.id) ?? []; + return { + amountRaw: row.amountRaw, + conversions: depositAllocations.map(allocation => { + const execution = executionById.get(allocation.executionId); + return { + eureInRaw: allocation.eureInRaw, + executionId: allocation.executionId, + status: execution?.status ?? "pending", + txHash: execution?.txHash ?? null, + usdcNetRaw: allocation.usdcNetRaw + }; + }), + createdAt: row.createdAt, + currency: row.currency, + depositId: row.id, + status: row.status, + txHash: row.txHash, + usdcNetRaw: depositAllocations.reduce((sum, allocation) => sum + BigInt(allocation.usdcNetRaw), 0n).toString() + }; + }), + pagination: { limit, offset, total: count } + }); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/api/controllers/webhook.controller.ts b/apps/api/src/api/controllers/webhook.controller.ts index da6af9d9e..735c7e227 100644 --- a/apps/api/src/api/controllers/webhook.controller.ts +++ b/apps/api/src/api/controllers/webhook.controller.ts @@ -46,13 +46,8 @@ export const registerWebhook = async ( }); } - if (!quoteId && !sessionId) { - throw new APIError({ - message: "Either quoteId or sessionId must be provided", - status: httpStatus.BAD_REQUEST - }); - } - + // quoteId/sessionId requirements are owned by the service: transaction-event + // webhooks need exactly one, deposit-event webhooks must have neither. const webhook = await webhookService.registerWebhook( { events, diff --git a/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts new file mode 100644 index 000000000..cf405f2c4 --- /dev/null +++ b/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { patchMoneriumB2bAccountStatus, postMoneriumB2bAccount } from "../../../controllers/admin/moneriumB2b.controller"; +import { adminAuth } from "../../../middlewares/adminAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(adminAuth); + +// Maps a Monerium-onboarded corporate to a managed profile and records its +// deployed forwarder as a B2B onramp account. Idempotent. +router.post("/accounts", postMoneriumB2bAccount); + +// Operator lifecycle transitions (activate after the penny test, suspend, close). +router.patch("/accounts/:accountId/status", patchMoneriumB2bAccountStatus); + +export default router; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 351c046fb..eaf46c128 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -1,10 +1,12 @@ import { Request, Response, Router } from "express"; +import { config } from "../../../config/vars"; import { sendStatusWithPk as sendMoonbeamStatusWithPk } from "../../controllers/moonbeam.controller"; import { sendStatusWithPk as sendPendulumStatusWithPk } from "../../controllers/pendulum.controller"; import { setAlfredpayCountryFromRoute } from "../../middlewares/alfredpay.middleware"; import apiClientEventsRoutes from "./admin/api-client-events.route"; import managedProfileManagersRoutes from "./admin/managed-profile-managers.route"; import adminManagedProfilesRoutes from "./admin/managed-profiles.route"; +import adminMoneriumB2bRoutes from "./admin/monerium-b2b.route"; import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; import partnerPricingConfigsRoutes from "./admin/partner-pricing-configs.route"; import profilePartnerAssignmentsRoutes from "./admin/profile-partner-assignments.route"; @@ -25,6 +27,7 @@ import maintenanceRoutes from "./maintenance.route"; import managedProfilesRoutes from "./managed-profiles.route"; import metricsRoutes from "./metrics.route"; import moneriumRoutes from "./monerium.route"; +import moneriumB2bRoutes from "./monerium-b2b.route"; import mykoboRoutes from "./mykobo.route"; import notificationsRoutes from "./notifications.route"; import onboardingRoutes from "./onboarding.route"; @@ -182,6 +185,17 @@ router.use("/mykobo", mykoboRoutes); */ router.use("/monerium", moneriumRoutes); +/** + * Monerium B2B whitelabel onramp. + * POST /v1/monerium-b2b/webhook — HMAC-authenticated durable-inbox webhook receiver. + * GET /v1/monerium-b2b/account — the acting profile's onramp account (manager + * delegation or child credential; EU/business policy). + * GET /v1/monerium-b2b/deposits — the acting profile's deposits with conversion status. + */ +if (config.moneriumB2b.enabled) { + router.use("/monerium-b2b", moneriumB2bRoutes); +} + /** * POST v1/webhook * DELETE v1/webhook @@ -269,6 +283,15 @@ router.use("/admin/profile-roles", profileRolesRoutes); router.use("/admin/managed-profile-managers", managedProfileManagersRoutes); router.use("/admin/managed-profiles", adminManagedProfilesRoutes); +/** + * Admin route mapping Monerium-onboarded corporates to managed profiles and their + * deployed forwarder accounts (idempotent). + * POST /v1/admin/monerium-b2b/accounts + */ +if (config.moneriumB2b.enabled) { + router.use("/admin/monerium-b2b", adminMoneriumB2bRoutes); +} + /** * Admin routes for API client observability dashboards * GET /v1/admin/api-client-events diff --git a/apps/api/src/api/routes/v1/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/monerium-b2b.route.ts new file mode 100644 index 000000000..8c8705438 --- /dev/null +++ b/apps/api/src/api/routes/v1/monerium-b2b.route.ts @@ -0,0 +1,19 @@ +import { Router } from "express"; +import * as moneriumB2bController from "../../controllers/monerium-b2b.controller"; +import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; +import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; + +const router = Router(); + +// Authenticated by HMAC signature over the raw body (no session/API-key auth). +router.post("/webhook", moneriumB2bController.handleWebhook); + +// Read surface for the account owner: the partner manager acting via +// X-Managed-Profile-Id, or the child's own credential. Corridor and customer-type +// policy match the B2B onramp scope (EU, business). +const accountAuth = [requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "EU", customerType: "business" })]; + +router.get("/account", ...accountAuth, moneriumB2bController.getMoneriumB2bAccount); +router.get("/deposits", ...accountAuth, moneriumB2bController.listMoneriumB2bDeposits); + +export default router; diff --git a/apps/api/src/api/services/managed-profile-provisioning.service.ts b/apps/api/src/api/services/managed-profile-provisioning.service.ts index c919506fe..b976f55d2 100644 --- a/apps/api/src/api/services/managed-profile-provisioning.service.ts +++ b/apps/api/src/api/services/managed-profile-provisioning.service.ts @@ -80,80 +80,95 @@ async function existingResult( }; } -export async function provisionManagedProfile(input: ProvisionManagedProfileInput): Promise { - const externalSubjectId = input.externalSubjectId.trim(); - const contactEmail = input.contactEmail.trim().toLowerCase(); - if (!externalSubjectId || Joi.string().email().max(255).required().validate(contactEmail).error) { +async function provisionManagedProfileInTransaction( + input: ProvisionManagedProfileInput, + externalSubjectId: string, + contactEmail: string, + transaction: Transaction +): Promise { + const manager = await ManagedProfileManager.findByPk(input.managerProfileId, { + lock: Transaction.LOCK.UPDATE, + transaction + }); + if (!manager) { + throw new ManagedProfileProvisioningError("MANAGED_PROFILE_MANAGER_NOT_FOUND", "Managed profile manager not found"); + } + if (!manager.isActive) { + throw new ManagedProfileProvisioningError("MANAGED_PROFILE_MANAGER_INACTIVE", "Managed profile manager is inactive"); + } + // Operations are narrowed at request time too, but a child the manager could never operate + // is only a dead record, so refuse it at the point of creation. + if (manager.allowedCustomerTypes !== null && !manager.allowedCustomerTypes.includes(input.customerType)) { throw new ManagedProfileProvisioningError( "MANAGED_PROFILE_INVALID_INPUT", - "externalSubjectId must be non-empty and contactEmail must be a valid email address" + "The manager is not allowed to provision this customer type" ); } - return sequelize.transaction(async transaction => { - const manager = await ManagedProfileManager.findByPk(input.managerProfileId, { - lock: Transaction.LOCK.UPDATE, - transaction - }); - if (!manager) { - throw new ManagedProfileProvisioningError("MANAGED_PROFILE_MANAGER_NOT_FOUND", "Managed profile manager not found"); - } - if (!manager.isActive) { - throw new ManagedProfileProvisioningError("MANAGED_PROFILE_MANAGER_INACTIVE", "Managed profile manager is inactive"); - } - // Operations are narrowed at request time too, but a child the manager could never operate - // is only a dead record, so refuse it at the point of creation. - if (manager.allowedCustomerTypes !== null && !manager.allowedCustomerTypes.includes(input.customerType)) { - throw new ManagedProfileProvisioningError( - "MANAGED_PROFILE_INVALID_INPUT", - "The manager is not allowed to provision this customer type" - ); - } + const existing = await ManagedProfile.findOne({ + transaction, + where: { externalSubjectId, managerProfileId: input.managerProfileId } + }); + if (existing) return existingResult(existing, contactEmail, input.customerType, transaction); - const existing = await ManagedProfile.findOne({ - transaction, - where: { externalSubjectId, managerProfileId: input.managerProfileId } - }); - if (existing) return existingResult(existing, contactEmail, input.customerType, transaction); + const existingContactEmail = await ManagedProfile.findOne({ + transaction, + where: { contactEmail, managerProfileId: input.managerProfileId } + }); + if (existingContactEmail) { + throw new ManagedProfileProvisioningError( + "MANAGED_PROFILE_CONFLICT", + "The contact email is already associated with another managed profile" + ); + } - const existingContactEmail = await ManagedProfile.findOne({ - transaction, - where: { contactEmail, managerProfileId: input.managerProfileId } - }); - if (existingContactEmail) { - throw new ManagedProfileProvisioningError( - "MANAGED_PROFILE_CONFLICT", - "The contact email is already associated with another managed profile" - ); - } + const profile = await User.create({ email: null, id: crypto.randomUUID(), kind: "managed" }, { transaction }); + const customerEntity = await CustomerEntity.create( + { profileId: profile.id, status: "active", type: input.customerType }, + { transaction } + ); + await profile.update({ activeCustomerEntityId: customerEntity.id }, { transaction }); + const relationship = await ManagedProfile.create( + { + contactEmail, + creationSource: input.creationSource, + externalSubjectId, + managerProfileId: input.managerProfileId, + profileId: profile.id + }, + { transaction } + ); - const profile = await User.create({ email: null, id: crypto.randomUUID(), kind: "managed" }, { transaction }); - const customerEntity = await CustomerEntity.create( - { profileId: profile.id, status: "active", type: input.customerType }, - { transaction } - ); - await profile.update({ activeCustomerEntityId: customerEntity.id }, { transaction }); - const relationship = await ManagedProfile.create( - { - contactEmail, - creationSource: input.creationSource, - externalSubjectId, - managerProfileId: input.managerProfileId, - profileId: profile.id - }, - { transaction } + return { + contactEmail, + created: true, + creationSource: relationship.creationSource, + customerEntityId: customerEntity.id, + customerType: customerEntity.type, + externalSubjectId: relationship.externalSubjectId, + id: relationship.id, + managerProfileId: relationship.managerProfileId, + profileId: relationship.profileId + }; +} + +export async function provisionManagedProfile( + input: ProvisionManagedProfileInput, + transaction?: Transaction +): Promise { + const externalSubjectId = input.externalSubjectId.trim(); + const contactEmail = input.contactEmail.trim().toLowerCase(); + if (!externalSubjectId || Joi.string().email().max(255).required().validate(contactEmail).error) { + throw new ManagedProfileProvisioningError( + "MANAGED_PROFILE_INVALID_INPUT", + "externalSubjectId must be non-empty and contactEmail must be a valid email address" ); + } - return { - contactEmail, - created: true, - creationSource: relationship.creationSource, - customerEntityId: customerEntity.id, - customerType: customerEntity.type, - externalSubjectId: relationship.externalSubjectId, - id: relationship.id, - managerProfileId: relationship.managerProfileId, - profileId: relationship.profileId - }; - }); + if (transaction) { + return provisionManagedProfileInTransaction(input, externalSubjectId, contactEmail, transaction); + } + return sequelize.transaction(innerTransaction => + provisionManagedProfileInTransaction(input, externalSubjectId, contactEmail, innerTransaction) + ); } diff --git a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts new file mode 100644 index 000000000..93d3d8a36 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -0,0 +1,350 @@ +import { Transaction, UniqueConstraintError } from "sequelize"; +import { type Address, parseAbi } from "viem"; +import sequelize from "../../../config/database"; +import { config } from "../../../config/vars"; +import KycCase from "../../../models/kycCase.model"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import { type ProvisionManagedProfileResult, provisionManagedProfile } from "../managed-profile-provisioning.service"; +import { getPublicClient } from "./chain"; + +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/i; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export class MoneriumB2bProvisioningError extends Error { + constructor( + readonly code: "MONERIUM_B2B_ACCOUNT_CONFLICT" | "MONERIUM_B2B_INVALID_INPUT", + message: string + ) { + super(message); + this.name = this.constructor.name; + } +} + +export interface ProvisionMoneriumB2bAccountInput { + contactEmail: string; + destination: string; + externalSubjectId: string; + fallbackAddress: string; + feeBps?: number; + forwarderAddress: string; + managerProfileId: string; + moneriumProfileId: string; +} + +export interface ProvisionMoneriumB2bAccountResult { + accountId: string; + accountStatus: MoneriumAccountStatus; + created: boolean; + customerEntityId: string; + iban: string | null; + moneriumProfileId: string; + profileId: string; +} + +function normalizeAddress(value: string, name: string): string { + if (typeof value !== "string" || !ADDRESS_PATTERN.test(value.trim())) { + throw new MoneriumB2bProvisioningError("MONERIUM_B2B_INVALID_INPUT", `${name} must be a 0x-prefixed EVM address`); + } + return value.trim().toLowerCase(); +} + +const forwarderConfigAbi = parseAbi([ + "function destination() view returns (address)", + "function fallbackAddress() view returns (address)", + "function feeBps() view returns (uint16)", + "function FACTORY() view returns (address)" +]); +const factoryRegistryAbi = parseAbi(["function isForwarder(address forwarder) view returns (bool)"]); + +/** Pure comparison of the submitted account data against the deployed clone's config. */ +export function forwarderConfigMismatch( + expected: { destination: string; factory: string; fallbackAddress: string; feeBps: number }, + onchain: { destination: string; factory: string; fallbackAddress: string; feeBps: number; isForwarder: boolean } +): string | null { + if (onchain.factory.toLowerCase() !== expected.factory.toLowerCase()) { + return `on-chain factory ${onchain.factory} differs from the trusted factory`; + } + if (!onchain.isForwarder) { + return "the address is not a clone registered by the trusted factory"; + } + if (onchain.destination.toLowerCase() !== expected.destination) { + return `on-chain destination ${onchain.destination} differs from the submitted value`; + } + if (onchain.fallbackAddress.toLowerCase() !== expected.fallbackAddress) { + return `on-chain fallbackAddress ${onchain.fallbackAddress} differs from the submitted value`; + } + if (onchain.feeBps !== expected.feeBps) { + return `on-chain feeBps ${onchain.feeBps} differs from the submitted ${expected.feeBps}`; + } + return null; +} + +/** + * Verifies the operator-submitted forwarder against the chain before anything is + * persisted: a mistyped or wrong clone address would otherwise be linked to the + * client's Monerium profile within a keeper cycle, and the R07 monitor would adopt + * the wrong clone's destination as owner-authorized. Skipped when no read RPC is + * configured (sandbox / pre-chain environments — the association and config monitors + * remain the detective controls there). + */ +async function verifyForwarderOnChain( + forwarderAddress: string, + destination: string, + fallbackAddress: string, + feeBps: number +): Promise { + if (!config.moneriumB2b.rpcUrl) { + return; + } + const trustedFactory = config.moneriumB2b.forwarderFactoryAddress; + if (!trustedFactory) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS is not configured" + ); + } + const client = getPublicClient(); + const address = forwarderAddress as Address; + let onchain: { destination: string; factory: string; fallbackAddress: string; feeBps: number; isForwarder: boolean }; + try { + const [onchainDestination, onchainFallback, onchainFeeBps, factory] = await Promise.all([ + client.readContract({ abi: forwarderConfigAbi, address, functionName: "destination" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "fallbackAddress" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "feeBps" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "FACTORY" }) + ]); + const isForwarder = await client.readContract({ + abi: factoryRegistryAbi, + address: trustedFactory as Address, + args: [address], + functionName: "isForwarder" + }); + onchain = { + destination: onchainDestination, + factory, + fallbackAddress: onchainFallback, + feeBps: onchainFeeBps, + isForwarder + }; + } catch (error) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + `Could not verify the forwarder on chain (is the address a deployed clone? retry if the RPC was unavailable): ${ + error instanceof Error ? error.message.slice(0, 200) : String(error) + }` + ); + } + const mismatch = forwarderConfigMismatch({ destination, factory: trustedFactory, fallbackAddress, feeBps }, onchain); + if (mismatch) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + `Deployed forwarder verification failed: ${mismatch}` + ); + } +} + +// Mirrors the whitelabel KYB outcome for a reliance-onboarded corporate: these +// profiles are onboarded and approved on Monerium's side before they are mapped +// here, so the local provider records are imported directly as approved +// (docs/operations-monerium-interface.md, profile lifecycle). +async function mirrorApprovedKyb(customerEntityId: string, moneriumProfileId: string, transaction: Transaction): Promise { + const boundElsewhere = await ProviderCustomer.findOne({ + transaction, + where: { provider: "monerium", providerCustomerId: moneriumProfileId } + }); + if (boundElsewhere && boundElsewhere.customerEntityId !== customerEntityId) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The Monerium profile is already bound to a different customer" + ); + } + + const [customer] = await ProviderCustomer.findOrCreate({ + defaults: { + customerEntityId, + customerType: "business", + provider: "monerium", + providerCustomerId: moneriumProfileId, + rail: "eur", + status: VerificationStatus.Approved, + statusExternal: "approved" + }, + transaction, + where: { customerEntityId, customerType: "business", provider: "monerium", rail: "eur" } + }); + if (customer.providerCustomerId && customer.providerCustomerId !== moneriumProfileId) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The customer entity is already bound to a different Monerium profile" + ); + } + if (customer.providerCustomerId !== moneriumProfileId || customer.status !== VerificationStatus.Approved) { + await customer.update( + { providerCustomerId: moneriumProfileId, status: VerificationStatus.Approved, statusExternal: "approved" }, + { transaction } + ); + } + + const existingCase = await KycCase.findOne({ transaction, where: { providerCustomerId: customer.id } }); + if (existingCase) { + if (existingCase.status !== VerificationStatus.Approved) { + await existingCase.update( + { + approvedAt: existingCase.approvedAt ?? new Date(), + providerCaseId: moneriumProfileId, + rejectedAt: null, + status: VerificationStatus.Approved, + statusExternal: "approved" + }, + { transaction } + ); + } + } else { + await KycCase.create( + { + approvedAt: new Date(), + customerEntityId, + provider: "monerium", + providerCaseId: moneriumProfileId, + providerCustomerId: customer.id, + status: VerificationStatus.Approved, + statusExternal: "approved", + submittedAt: new Date(), + type: "kyb" + }, + { transaction } + ); + } +} + +function accountMatchesInput( + account: MoneriumAccount, + childProfileId: string, + forwarderAddress: string, + destination: string, + fallbackAddress: string, + feeBps: number +): boolean { + return ( + account.forwarderAddress.toLowerCase() === forwarderAddress && + account.destination.toLowerCase() === destination && + account.fallbackAddress.toLowerCase() === fallbackAddress && + account.feeBps === feeBps && + (account.vortexProfileId === null || account.vortexProfileId === childProfileId) + ); +} + +/** + * Maps a corporate that Monerium onboarded to the whitelabel app onto a Vortex + * managed profile and its B2B onramp account. Idempotent: replaying the same + * input returns the existing records; any divergence is a conflict, never an + * overwrite. The forwarder clone must already be deployed (operator runbook); + * this only records it. + */ +export async function provisionMoneriumB2bAccount( + input: ProvisionMoneriumB2bAccountInput +): Promise { + const moneriumProfileId = input.moneriumProfileId.trim().toLowerCase(); + if (!UUID_PATTERN.test(moneriumProfileId)) { + throw new MoneriumB2bProvisioningError("MONERIUM_B2B_INVALID_INPUT", "moneriumProfileId must be a UUID"); + } + const forwarderAddress = normalizeAddress(input.forwarderAddress, "forwarderAddress"); + const destination = normalizeAddress(input.destination, "destination"); + const fallbackAddress = normalizeAddress(input.fallbackAddress, "fallbackAddress"); + const feeBps = input.feeBps ?? 0; + if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 10000) { + throw new MoneriumB2bProvisioningError("MONERIUM_B2B_INVALID_INPUT", "feeBps must be an integer between 0 and 10000"); + } + + // Before any persistence: a wrong clone address must fail here, not become a mapped + // account whose config the monitors later legitimize. + await verifyForwarderOnChain(forwarderAddress, destination, fallbackAddress, feeBps); + + let result: { account: { created: boolean; row: MoneriumAccount }; managedProfile: ProvisionManagedProfileResult }; + try { + result = await sequelize.transaction(async transaction => { + // The pilot reliance scope is KYB'd corporates only, so the child is always a + // business entity. Every local row is created in this transaction so a late + // account conflict cannot leave an orphaned approved identity behind. + const managedProfile = await provisionManagedProfile( + { + contactEmail: input.contactEmail, + creationSource: "vortex", + customerType: "business", + externalSubjectId: input.externalSubjectId, + managerProfileId: input.managerProfileId + }, + transaction + ); + + await mirrorApprovedKyb(managedProfile.customerEntityId, moneriumProfileId, transaction); + + const existing = await MoneriumAccount.findOne({ transaction, where: { profileId: moneriumProfileId } }); + if (existing) { + if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, fallbackAddress, feeBps)) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The Monerium profile is already mapped with different account data" + ); + } + if (existing.vortexProfileId === null) { + await existing.update({ vortexProfileId: managedProfile.profileId }, { transaction }); + } + return { account: { created: false, row: existing }, managedProfile }; + } + + const boundToProfile = await MoneriumAccount.findOne({ + transaction, + where: { vortexProfileId: managedProfile.profileId } + }); + if (boundToProfile) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The managed profile already has a Monerium account for a different Monerium profile" + ); + } + const forwarderTaken = await MoneriumAccount.findOne({ transaction, where: { forwarderAddress } }); + if (forwarderTaken) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The forwarder address is already bound to another account" + ); + } + + const row = await MoneriumAccount.create( + { + destination, + fallbackAddress, + feeBps, + forwarderAddress, + profileId: moneriumProfileId, + status: MoneriumAccountStatus.Onboarding, + vortexProfileId: managedProfile.profileId + }, + { transaction } + ); + return { account: { created: true, row }, managedProfile }; + }); + } catch (error) { + if (error instanceof UniqueConstraintError) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_ACCOUNT_CONFLICT", + "The Monerium account mapping conflicts with an existing record" + ); + } + throw error; + } + + const { account, managedProfile } = result; + + return { + accountId: account.row.id, + accountStatus: account.row.status, + created: account.created, + customerEntityId: managedProfile.customerEntityId, + iban: account.row.iban, + moneriumProfileId, + profileId: managedProfile.profileId + }; +} diff --git a/apps/api/src/api/services/monerium-b2b/attestor.test.ts b/apps/api/src/api/services/monerium-b2b/attestor.test.ts new file mode 100644 index 000000000..3f8a6801a --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/attestor.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Address, encodePacked, hashMessage, Hex, hexToBigInt, hexToNumber, keccak256, recoverAddress, slice } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { config } from "../../../config/vars"; +import { attestationBoundHash, attestorAddress, LINK_MESSAGE, linkMessageHash, signLinkAttestation } from "./attestor"; + +// Well-known test key (Foundry/Anvil account #0) — never a real attestor key. +const TEST_KEY: Hex = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const FORWARDER: Address = "0x1111111111111111111111111111111111111111"; +const CHAIN_ID = 11155111n; // Sepolia, matching the G0 sandbox validation +// Malleability bound from VortexForwarder.isValidSignature (secp256k1 n/2). +const HALF_ORDER = hexToBigInt("0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0"); + +let originalKey: string | undefined; + +beforeAll(() => { + originalKey = config.moneriumB2b.attestorPrivateKey; + config.moneriumB2b.attestorPrivateKey = TEST_KEY; +}); + +afterAll(() => { + config.moneriumB2b.attestorPrivateKey = originalKey; +}); + +describe("monerium-b2b attestor", () => { + it("derives LINK_HASH_191 exactly as the forwarder immutable", () => { + // The Solidity constant hardcodes "\x19Ethereum Signed Message:\n45" — LINK_MESSAGE must be 45 bytes. + expect(Buffer.byteLength(LINK_MESSAGE, "utf8")).toBe(45); + // Independent derivation via viem's EIP-191 implementation. + expect(linkMessageHash()).toBe(hashMessage(LINK_MESSAGE)); + }); + + it("binds the signature to chainid + forwarder address exactly like isValidSignature", async () => { + const attestation = await signLinkAttestation(CHAIN_ID, FORWARDER); + // bound = keccak256(abi.encodePacked(block.chainid, address(this), hash)) — contract-side recomputation. + const expectedBound = keccak256( + encodePacked(["uint256", "address", "bytes32"], [CHAIN_ID, FORWARDER, hashMessage(LINK_MESSAGE)]) + ); + expect(attestation.boundHash).toBe(expectedBound); + expect(attestation.linkHash).toBe(hashMessage(LINK_MESSAGE)); + expect(attestation.message).toBe(LINK_MESSAGE); + // ecrecover(bound, v, r, s) must yield the ATTESTOR. + const signer = await recoverAddress({ hash: expectedBound, signature: attestation.signature }); + expect(signer).toBe(privateKeyToAccount(TEST_KEY).address); + expect(signer).toBe(attestorAddress()); + }); + + it("emits a 65-byte low-s signature with v in 27/28", async () => { + const { boundHash, linkHash, signature } = await signLinkAttestation(CHAIN_ID, FORWARDER); + expect(signature.length).toBe(2 + 65 * 2); + const v = hexToNumber(slice(signature, 64, 65)); + expect([27, 28]).toContain(v); + const s = hexToBigInt(slice(signature, 32, 64)); + expect(s <= HALF_ORDER).toBe(true); + expect(boundHash).toBe(attestationBoundHash(CHAIN_ID, FORWARDER, linkHash)); + expect(await recoverAddress({ hash: boundHash, signature })).toBe(privateKeyToAccount(TEST_KEY).address); + }); + + it("does not verify against a different forwarder address or chain", async () => { + const { signature } = await signLinkAttestation(CHAIN_ID, FORWARDER); + const otherAddress = attestationBoundHash(CHAIN_ID, "0x2222222222222222222222222222222222222222", linkMessageHash()); + expect(await recoverAddress({ hash: otherAddress, signature })).not.toBe(privateKeyToAccount(TEST_KEY).address); + // Cross-chain replay: same address, different chainid must not recover the attestor. + const otherChain = attestationBoundHash(1n, FORWARDER, linkMessageHash()); + expect(await recoverAddress({ hash: otherChain, signature })).not.toBe(privateKeyToAccount(TEST_KEY).address); + }); + + it("refuses to sign when the attestor key is not configured", async () => { + config.moneriumB2b.attestorPrivateKey = undefined; + try { + await expect(signLinkAttestation(CHAIN_ID, FORWARDER)).rejects.toThrow("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY"); + } finally { + config.moneriumB2b.attestorPrivateKey = TEST_KEY; + } + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/attestor.ts b/apps/api/src/api/services/monerium-b2b/attestor.ts new file mode 100644 index 000000000..ff1c14242 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/attestor.ts @@ -0,0 +1,74 @@ +import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE } from "@vortexfi/shared"; +import { Address, encodePacked, Hex, keccak256, serializeSignature, stringToBytes } from "viem"; +import { privateKeyToAccount, sign } from "viem/accounts"; +import { config } from "../../../config/vars"; + +/** + * Attestor signature construction for VortexForwarder address linking. + * + * Mirrors contracts/monerium-forwarder/src/VortexForwarder.sol `isValidSignature`: + * the forwarder returns the EIP-1271 magic value iff the presented hash is the EIP-191 + * link hash (the raw-keccak variant was removed after the G0 sandbox validation on + * 2026-07-17 confirmed Monerium presents EIP-191) and the 65-byte (r,s,v; v in 27/28; + * low-s) signature recovers the ATTESTOR over + * `keccak256(abi.encodePacked(block.chainid, address(this), hash))` — chainid is part + * of the binding to prevent cross-chain replay (review r1 P2). + * + * The attestor key authorizes NOTHING beyond this fixed link statement — it can never + * move funds (security-spec/05-integrations/monerium-b2b.md). + */ + +// The shared white-label client sends this exact message with POST /addresses; the +// signature below must cover the same bytes, so both come from the one shared constant. +export const LINK_MESSAGE = MONERIUM_ADDRESS_OWNERSHIP_MESSAGE; + +export interface LinkAttestation { + boundHash: Hex; + linkHash: Hex; + message: string; + signature: Hex; +} + +/** LINK_HASH_191 from the forwarder immutables (EIP-191 personal-message hash). */ +export function linkMessageHash(): Hex { + // hashMessage would work too; spelled out to match the Solidity constant byte for byte + // (LINK_MESSAGE is 45 bytes, hence the fixed "\x19Ethereum Signed Message:\n45"). + return keccak256(stringToBytes(`\x19Ethereum Signed Message:\n45${LINK_MESSAGE}`)); +} + +/** `bound = keccak256(abi.encodePacked(block.chainid, forwarderAddress, hash))`. */ +export function attestationBoundHash(chainId: bigint, forwarderAddress: Address, hash: Hex): Hex { + return keccak256(encodePacked(["uint256", "address", "bytes32"], [chainId, forwarderAddress, hash])); +} + +function attestorPrivateKey(): Hex { + const key = config.moneriumB2b.attestorPrivateKey; + if (!key) { + // Never include key material in errors or logs. + throw new Error("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY is not configured"); + } + return key as Hex; +} + +export function attestorAddress(): Address { + return privateKeyToAccount(attestorPrivateKey()).address; +} + +/** + * Builds the attestor signature submitted with Monerium's POST /addresses link call. + * Signs the bound digest directly (no extra EIP-191 prefix — the contract ecrecovers + * the bound hash as-is). viem's signer emits canonical low-s signatures, so the + * forwarder's malleability check passes. + */ +export async function signLinkAttestation(chainId: bigint, forwarderAddress: Address): Promise { + const linkHash = linkMessageHash(); + const boundHash = attestationBoundHash(chainId, forwarderAddress, linkHash); + const signature = await sign({ hash: boundHash, privateKey: attestorPrivateKey() }); + return { + boundHash, + linkHash, + message: LINK_MESSAGE, + // 65-byte r ‖ s ‖ v serialization with v in 27/28, as isValidSignature expects. + signature: serializeSignature(signature) + }; +} diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts new file mode 100644 index 000000000..e59eb8467 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -0,0 +1,234 @@ +import type { MoneriumChain } from "@vortexfi/shared"; +import { + Account, + Address, + createPublicClient, + createWalletClient, + Hex, + http, + PublicClient, + parseAbiItem, + Transport, + WalletClient +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; + +/** + * viem clients + minimal hand-written ABI surface for the B2B keeper + * (docs/architecture-monerium-b2b-onramp.md §3, "Keeper"). + * + * Key separation is an invariant (security-spec/05-integrations/monerium-b2b.md): + * keeper key (swap submission) != guardian key (protective pause) != attestor key + * (address linking). Reads go through the public RPC; keeper/guardian WRITES go + * through a separate submission transport for private orderflow. + */ + +/** Suggested private-orderflow endpoint for mainnet (MONERIUM_B2B_PRIVATE_RPC_URL). */ +export const DEFAULT_PRIVATE_RPC_URL = "https://rpc.flashbots.net"; + +const MONERIUM_CHAIN_NAMES: Record = { + 1: "ethereum", + 11155111: "sepolia" +}; + +export function moneriumChainForChainId(chainId: number): MoneriumChain | null { + return MONERIUM_CHAIN_NAMES[chainId] ?? null; +} + +/** + * Client notification confirmation depth in blocks — registry P9 + * (docs/adr-0005-monerium-b2b-onramp.md). Not consumed by the keeper itself + * (execution finality is handled via receipt + reorg-safe deposit identity); reserved + * for the notification job (plan §3, "Notifications"). + */ +export const NOTIFY_CONFIRMATION_DEPTH = 32; + +// ------------------------------------------------------------------ ABI surface + +// Hand-written minimal ABIs (no codegen) mirroring +// contracts/monerium-forwarder/src/VortexForwarder.sol + VortexForwarderFactory.sol. + +export const eureTransferEvent = parseAbiItem("event Transfer(address indexed from, address indexed to, uint256 value)"); + +// SwapExecuted as a standalone event item for getLogs-based crash recovery (must stay +// in sync with the entry in forwarderAbi below). +export const swapExecutedEvent = parseAbiItem( + "event SwapExecuted(address indexed caller, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded)" +); + +export const erc20Abi = [ + { + inputs: [{ name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + } +] as const; + +export const forwarderAbi = [ + { inputs: [], name: "poke", outputs: [], stateMutability: "nonpayable", type: "function" }, + { inputs: [], name: "swapAndForward", outputs: [], stateMutability: "nonpayable", type: "function" }, + { + inputs: [{ name: "paused", type: "bool" }], + name: "setGuardianPaused", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, + { inputs: [], name: "strandedSince", outputs: [{ name: "", type: "uint64" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "guardianPaused", outputs: [{ name: "", type: "bool" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "EURE", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "FACTORY", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { + anonymous: false, + inputs: [{ indexed: false, name: "strandedSince", type: "uint64" }], + name: "Poked", + type: "event" + }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "caller", type: "address" }, + { indexed: false, name: "eureIn", type: "uint256" }, + { indexed: false, name: "usdcOut", type: "uint256" }, + { indexed: false, name: "fee", type: "uint256" }, + { indexed: false, name: "forwarded", type: "uint256" } + ], + name: "SwapExecuted", + type: "event" + }, + { + anonymous: false, + inputs: [{ indexed: false, name: "paused", type: "bool" }], + name: "GuardianPausedSet", + type: "event" + } +] as const; + +export const factoryAbi = [ + { inputs: [], name: "minSwapAmount", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "perSwapCap", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "MIN_SWAP_FLOOR", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" } +] as const; + +// ------------------------------------------------------------------ clients + +export type KeeperWalletClient = WalletClient; + +let publicClientCache: PublicClient | null = null; +let keeperClientCache: KeeperWalletClient | null = null; +let guardianClientCache: KeeperWalletClient | null = null; +let privateRpcWarned = false; + +export function isKeeperChainConfigured(): boolean { + return Boolean(config.moneriumB2b.rpcUrl && config.moneriumB2b.keeperPrivateKey); +} + +/** Read/receipt client on the public RPC (MONERIUM_B2B_RPC_URL). */ +export function getPublicClient(): PublicClient { + if (!publicClientCache) { + const { rpcUrl } = config.moneriumB2b; + if (!rpcUrl) { + throw new Error("MONERIUM_B2B_RPC_URL is not configured"); + } + publicClientCache = createPublicClient({ transport: http(rpcUrl) }); + } + return publicClientCache; +} + +/** + * Submission endpoint for keeper/guardian transactions. Prefers the dedicated private + * orderflow RPC; falls back to the public RPC with a warning when unset (fine on + * sandbox/testnet where DEFAULT_PRIVATE_RPC_URL, a mainnet endpoint, does not apply). + */ +function submissionRpcUrl(): string { + const { privateRpcUrl, rpcUrl } = config.moneriumB2b; + if (privateRpcUrl) { + return privateRpcUrl; + } + if (!rpcUrl) { + throw new Error("MONERIUM_B2B_RPC_URL is not configured"); + } + if (!privateRpcWarned) { + privateRpcWarned = true; + logger.warn( + "monerium-b2b: MONERIUM_B2B_PRIVATE_RPC_URL is not set — keeper transactions will be submitted via the public RPC " + + `without private orderflow protection. Set it (e.g. ${DEFAULT_PRIVATE_RPC_URL}) for mainnet.` + ); + } + return rpcUrl; +} + +/** Keeper wallet client (MONERIUM_B2B_KEEPER_PRIVATE_KEY) on the submission transport. */ +export function getKeeperWalletClient(): KeeperWalletClient { + if (!keeperClientCache) { + const key = config.moneriumB2b.keeperPrivateKey; + if (!key) { + // Never include key material in errors or logs. + throw new Error("MONERIUM_B2B_KEEPER_PRIVATE_KEY is not configured"); + } + keeperClientCache = createWalletClient({ + account: privateKeyToAccount(key as Hex), + transport: http(submissionRpcUrl()) + }); + } + return keeperClientCache; +} + +/** + * Guardian wallet client (MONERIUM_B2B_GUARDIAN_PRIVATE_KEY) for the dormancy-gate + * pause. Returns null when the key is unset — the dormancy gate then runs in log-only + * mode. The guardian key is deliberately separate from the keeper key: it can only + * pause (protective-only invariant, plan §2.2), never move funds. + */ +export function getGuardianWalletClient(): KeeperWalletClient | null { + if (!config.moneriumB2b.guardianPrivateKey) { + return null; + } + if (!guardianClientCache) { + guardianClientCache = createWalletClient({ + account: privateKeyToAccount(config.moneriumB2b.guardianPrivateKey as Hex), + transport: http(submissionRpcUrl()) + }); + } + return guardianClientCache; +} + +// ------------------------------------------------------------------ cached chain lookups + +let chainIdCache: number | null = null; + +export async function getChainId(): Promise { + if (chainIdCache === null) { + chainIdCache = await getPublicClient().getChainId(); + } + return chainIdCache; +} + +interface ForwarderImmutables { + eure: Address; + factory: Address; +} + +// EURE/FACTORY are implementation-level immutables shared by every clone, so one +// lookup per forwarder address is enough for the process lifetime. +const forwarderImmutablesCache = new Map(); + +export async function getForwarderImmutables(forwarderAddress: Address): Promise { + const key = forwarderAddress.toLowerCase(); + const cached = forwarderImmutablesCache.get(key); + if (cached) { + return cached; + } + const client = getPublicClient(); + const [eure, factory] = await Promise.all([ + client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName: "EURE" }), + client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName: "FACTORY" }) + ]); + const immutables = { eure, factory }; + forwarderImmutablesCache.set(key, immutables); + return immutables; +} diff --git a/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts new file mode 100644 index 000000000..5257a2b2a --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts @@ -0,0 +1,75 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumChainCursor from "../../../models/moneriumChainCursor.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { reconcileConfirmedExecutionAllocations } from "./conversion-executor"; + +describe("confirmed Monerium conversion allocation", () => { + beforeAll(setupTestDatabase); + beforeEach(resetTestDatabase); + + it("waits for the mint cursor and uses the swap log as the exact snapshot boundary", async () => { + const account = await MoneriumAccount.create({ + destination: "0x2222222222222222222222222222222222222222", + fallbackAddress: "0x3333333333333333333333333333333333333333", + feeBps: 0, + forwarderAddress: "0x1111111111111111111111111111111111111111", + profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 100, + destination: account.destination, + eureInRaw: "60000000000000000000", + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 10, + txHash: "0xswap", + usdcNetRaw: "64800000" + }); + const included = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw: "60000000000000000000", + blockNumber: 100, + chainId: 1, + currency: "eur", + logIndex: 9, + moneriumOrderId: "included-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint-before" + }); + await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw: "10000000000000000000", + blockNumber: 100, + chainId: 1, + currency: "eur", + logIndex: 11, + moneriumOrderId: "later-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint-after" + }); + const cursor = await MoneriumChainCursor.create({ lastBlock: "99", name: "eure-mints:1" }); + const deps = { getChainId: async () => 1 }; + + expect(await reconcileConfirmedExecutionAllocations(deps)).toBe(0); + expect(await MoneriumDepositAllocation.count()).toBe(0); + + await cursor.update({ lastBlock: "100" }); + expect(await reconcileConfirmedExecutionAllocations(deps)).toBe(1); + expect(await reconcileConfirmedExecutionAllocations(deps)).toBe(0); + + const allocations = await MoneriumDepositAllocation.findAll(); + expect(allocations).toHaveLength(1); + expect(allocations[0]).toMatchObject({ + depositId: included.id, + eureInRaw: execution.eureInRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw + }); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts new file mode 100644 index 000000000..514c97c84 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from "bun:test"; +import { FindOptions, Transaction } from "sequelize"; +import { encodeFunctionData } from "viem"; +import sequelize from "../../../config/database"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import { + AllocatableDeposit, + allocateUsdcProRata, + broadcastSwapSequence, + classifyHashlessPending, + conversionAmountsFromSwapEvent, + isExpectedSwapTransaction, + recoveryBlockRanges, + runConversionExecutor, + selectDepositsForExecution +} from "./conversion-executor"; +import { forwarderAbi } from "./chain"; + +// R04 attribution (docs/architecture-monerium-b2b-onramp.md §3): pro-rata by +// amount_raw against eureInRaw, floor division, remainder to the largest deposit. +// No chain or database involved — pure math. + +const EUR = 10n ** 18n; +const USDC = 10n ** 6n; + +function deposit(id: string, amountRaw: bigint): AllocatableDeposit { + return { amountRaw, id }; +} + +describe("selectDepositsForExecution", () => { + it("selects all deposits when they fit within eureInRaw", () => { + const deposits = [deposit("a", 100n * EUR), deposit("b", 50n * EUR)]; + expect(selectDepositsForExecution(deposits, 150n * EUR)).toEqual(deposits); + }); + + it("splits a deposit at the per-swap cap cut", () => { + const deposits = [deposit("a", 50n * EUR), deposit("b", 30n * EUR)]; + expect(selectDepositsForExecution(deposits, 60n * EUR)).toEqual([ + deposits[0], + deposit("b", 10n * EUR) + ]); + }); + + it("allocates only the converted portion of an oversized deposit", () => { + expect(selectDepositsForExecution([deposit("a", 100n * EUR)], 60n * EUR)).toEqual([deposit("a", 60n * EUR)]); + }); + + it("allocates a remaining deposit portion before younger deposits", () => { + const outstanding = deposit("big", 20n * EUR); + const younger = deposit("small", 5n * EUR); + expect(selectDepositsForExecution([outstanding, younger], 25n * EUR)).toEqual([outstanding, younger]); + }); + + it("handles an exact fit and an empty list", () => { + const deposits = [deposit("a", 25n * EUR), deposit("b", 75n * EUR)]; + expect(selectDepositsForExecution(deposits, 100n * EUR)).toEqual(deposits); + expect(selectDepositsForExecution([], 100n * EUR)).toEqual([]); + }); +}); + +describe("allocateUsdcProRata", () => { + it("gives a single deposit covering the full eureIn the entire net USDC", () => { + const shares = allocateUsdcProRata([deposit("a", 100n * EUR)], 100n * EUR, 108n * USDC); + expect(shares.get("a")).toBe(108n * USDC); + }); + + it("splits proportionally when amounts divide evenly", () => { + const shares = allocateUsdcProRata([deposit("a", 75n * EUR), deposit("b", 25n * EUR)], 100n * EUR, 100n * USDC); + expect(shares.get("a")).toBe(75n * USDC); + expect(shares.get("b")).toBe(25n * USDC); + }); + + it("floors each share and gives the division remainder to the largest deposit", () => { + // 100 USDC over three equal thirds: floor gives 33.333333 each, 1 raw unit of dust + // remains and goes to the largest (tie -> earliest). + const shares = allocateUsdcProRata( + [deposit("a", 1n * EUR), deposit("b", 1n * EUR), deposit("c", 1n * EUR)], + 3n * EUR, + 100n * USDC + ); + expect(shares.get("a")).toBe(33333334n); + expect(shares.get("b")).toBe(33333333n); + expect(shares.get("c")).toBe(33333333n); + expect([...shares.values()].reduce((sum, share) => sum + share, 0n)).toBe(100n * USDC); + }); + + it("gives the remainder to the largest deposit, not the first", () => { + const shares = allocateUsdcProRata([deposit("small", 1n * EUR), deposit("big", 2n * EUR)], 3n * EUR, 100n * USDC); + expect(shares.get("small")).toBe(33333333n); + expect(shares.get("big")).toBe(66666667n); + }); + + it("handles a dust deposit whose floor share is zero", () => { + // 1 raw-unit deposit against 100 EUR in: floor share is 0; the sum invariant holds + // because the remainder lands on the large deposit. + const shares = allocateUsdcProRata([deposit("dust", 1n), deposit("big", 100n * EUR - 1n)], 100n * EUR, 100n * USDC); + expect(shares.get("dust")).toBe(0n); + expect(shares.get("big")).toBe(100n * USDC); + }); + + it("conserves the total exactly whenever the selection covers eureInRaw", () => { + const deposits = [deposit("a", 7n * EUR), deposit("b", 13n * EUR), deposit("c", 17n * EUR)]; + const usdcNet = 39_876_543n; + const shares = allocateUsdcProRata(deposits, 37n * EUR, usdcNet); + expect([...shares.values()].reduce((sum, share) => sum + share, 0n)).toBe(usdcNet); + }); + + it("returns an empty allocation for an empty selection or non-positive eureIn", () => { + expect(allocateUsdcProRata([], 100n * EUR, 100n * USDC).size).toBe(0); + expect(allocateUsdcProRata([deposit("a", 1n * EUR)], 0n, 100n * USDC).size).toBe(0); + }); + + it("clamps an oversized sole deposit to the swapped amount and conserves the total", () => { + const shares = allocateUsdcProRata([deposit("big", 100n * EUR)], 100n * EUR, 108n * USDC); + expect(shares.get("big")).toBe(108n * USDC); + }); + + it("does not assign output for an unindexed portion of an execution", () => { + const shares = allocateUsdcProRata([deposit("known", 60n * EUR)], 100n * EUR, 100n * USDC); + expect(shares.get("known")).toBe(60n * USDC); + }); +}); + +describe("conversionAmountsFromSwapEvent", () => { + it("excludes unsolicited USDC swept alongside this swap", () => { + expect( + conversionAmountsFromSwapEvent({ fee: 8n * USDC, forwarded: 208n * USDC, usdcOut: 108n * USDC }) + ).toEqual({ feeRaw: "8000000", usdcGrossRaw: "108000000", usdcNetRaw: "100000000" }); + }); + + it("refuses an impossible event whose fee exceeds this swap's output", () => { + expect(() => conversionAmountsFromSwapEvent({ fee: 2n, forwarded: 0n, usdcOut: 1n })).toThrow("fee exceeds"); + }); +}); + +describe("classifyHashlessPending", () => { + it("fails a row whose send phase was never reached (no persisted nonce)", () => { + expect( + classifyHashlessPending({ latestNonceCount: 0, matchingSwapTxHashes: [], nonce: null, scanComplete: true }) + ).toEqual({ kind: "fail", reason: "crashed before the transaction was sent" }); + }); + + it("adopts the unclaimed SwapExecuted hash when the nonce was consumed", () => { + expect( + classifyHashlessPending({ latestNonceCount: 8, matchingSwapTxHashes: ["0xlost"], nonce: 7, scanComplete: true }) + ).toEqual({ kind: "adopt", txHash: "0xlost" }); + }); + + it("fails a consumed nonce with no SwapExecuted (reverted or replaced)", () => { + const result = classifyHashlessPending({ + latestNonceCount: 8, + matchingSwapTxHashes: [], + nonce: 7, + scanComplete: true + }); + expect(result.kind).toBe("fail"); + }); + + it("waits while the broadcast may still be in the mempool", () => { + expect( + classifyHashlessPending({ latestNonceCount: 7, matchingSwapTxHashes: [], nonce: 7, scanComplete: true }) + ).toEqual({ kind: "in-flight", reason: "the persisted nonce has not been consumed" }); + }); + + it("remains fail-closed when a persisted nonce is not visible in the mempool", () => { + const result = classifyHashlessPending({ + latestNonceCount: 7, + matchingSwapTxHashes: [], + nonce: 7, + scanComplete: true + }); + expect(result.kind).toBe("in-flight"); + }); + + it("remains pending when recovery is incomplete or ambiguous", () => { + expect( + classifyHashlessPending({ latestNonceCount: 8, matchingSwapTxHashes: [], nonce: 7, scanComplete: false }).kind + ).toBe("in-flight"); + expect( + classifyHashlessPending({ + latestNonceCount: 8, + matchingSwapTxHashes: ["0xone", "0xtwo"], + nonce: 7, + scanComplete: true + }).kind + ).toBe("in-flight"); + }); +}); + +describe("isExpectedSwapTransaction", () => { + const keeper = "0x1111111111111111111111111111111111111111"; + const forwarder = "0x2222222222222222222222222222222222222222"; + const expected = { + from: keeper, + input: encodeFunctionData({ abi: forwarderAbi, functionName: "swapAndForward" }), + nonce: 7, + to: forwarder + }; + + it("requires the exact keeper, nonce, forwarder, and no-arg calldata", () => { + expect(isExpectedSwapTransaction(expected, keeper, forwarder, 7)).toBe(true); + expect(isExpectedSwapTransaction({ ...expected, from: forwarder }, keeper, forwarder, 7)).toBe(false); + expect(isExpectedSwapTransaction({ ...expected, nonce: 8 }, keeper, forwarder, 7)).toBe(false); + expect(isExpectedSwapTransaction({ ...expected, to: keeper }, keeper, forwarder, 7)).toBe(false); + expect(isExpectedSwapTransaction({ ...expected, input: "0x" }, keeper, forwarder, 7)).toBe(false); + }); +}); + +describe("recoveryBlockRanges", () => { + it("covers long recovery intervals with bounded inclusive pages", () => { + expect(recoveryBlockRanges(1n, 4500n)).toEqual([ + { fromBlock: 1n, toBlock: 2000n }, + { fromBlock: 2001n, toBlock: 4000n }, + { fromBlock: 4001n, toBlock: 4500n } + ]); + expect(recoveryBlockRanges(10n, 9n)).toEqual([]); + }); +}); + +describe("broadcastSwapSequence", () => { + it("never reserves or sends a swap when the preceding poke fails", async () => { + const actions: string[] = []; + await expect( + broadcastSwapSequence({ + broadcastBlockNumber: 100, + pendingNonce: 7, + pokeNeeded: true, + reserveSwap: async () => { + actions.push("reserve"); + return true; + }, + sendPoke: async nonce => { + actions.push(`poke:${nonce}`); + throw new Error("poke rejected"); + }, + sendSwap: async nonce => { + actions.push(`swap:${nonce}`); + return "0xswap"; + } + }) + ).rejects.toThrow("poke rejected"); + expect(actions).toEqual(["poke:7"]); + }); + + it("durably reserves the exact swap nonce after poke and before broadcast", async () => { + const actions: string[] = []; + const hash = await broadcastSwapSequence({ + broadcastBlockNumber: 100, + pendingNonce: 7, + pokeNeeded: true, + reserveSwap: async (nonce, blockNumber) => { + actions.push(`reserve:${nonce}:${blockNumber}`); + return true; + }, + sendPoke: async nonce => { + actions.push(`poke:${nonce}`); + }, + sendSwap: async nonce => { + actions.push(`swap:${nonce}`); + return "0xswap"; + } + }); + + expect(hash).toBe("0xswap"); + expect(actions).toEqual(["poke:7", "reserve:8:100", "swap:8"]); + }); +}); + +describe("runConversionExecutor recovery ordering", () => { + it("does not expire another executor's fresh pre-send reservation", async () => { + const originalTransaction = sequelize.transaction; + const originalQuery = sequelize.query; + const originalFindAccount = MoneriumAccount.findByPk; + const originalFindExecution = MoneriumConversionExecution.findOne; + const originalFindExecutions = MoneriumConversionExecution.findAll; + + const account = { + dormantSince: null, + forwarderAddress: "0x1111111111111111111111111111111111111111", + id: "account-1", + status: MoneriumAccountStatus.Closed + } as MoneriumAccount; + const pending = { + accountId: account.id, + createdAt: new Date(), + id: "execution-1", + nonce: null, + status: MoneriumConversionExecutionStatus.Pending, + txHash: null, + updatedAt: new Date(), + async update(values: Partial) { + Object.assign(this, values, { updatedAt: new Date() }); + } + } as unknown as MoneriumConversionExecution; + + try { + sequelize.transaction = (async (...args: unknown[]) => { + const callback = args.at(-1) as (transaction: Transaction) => Promise; + return callback({} as Transaction); + }) as typeof sequelize.transaction; + sequelize.query = (async () => [[], 0]) as unknown as typeof sequelize.query; + MoneriumAccount.findByPk = (async () => account) as typeof MoneriumAccount.findByPk; + MoneriumConversionExecution.findOne = (async (options?: FindOptions) => { + const status = (options?.where as { status?: MoneriumConversionExecutionStatus } | undefined)?.status; + return status === MoneriumConversionExecutionStatus.Pending ? pending : null; + }) as typeof MoneriumConversionExecution.findOne; + MoneriumConversionExecution.findAll = (async (options?: FindOptions) => { + const status = (options?.where as { status?: MoneriumConversionExecutionStatus } | undefined)?.status; + return status === MoneriumConversionExecutionStatus.Pending || status === MoneriumConversionExecutionStatus.Failed + ? [pending] + : []; + }) as typeof MoneriumConversionExecution.findAll; + + await runConversionExecutor(account.id); + + expect(pending.status).toBe(MoneriumConversionExecutionStatus.Pending); + expect(pending.nonce).toBeNull(); + } finally { + sequelize.transaction = originalTransaction; + sequelize.query = originalQuery; + MoneriumAccount.findByPk = originalFindAccount; + MoneriumConversionExecution.findOne = originalFindExecution; + MoneriumConversionExecution.findAll = originalFindExecutions; + } + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts new file mode 100644 index 000000000..94b30ef80 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -0,0 +1,771 @@ +import { Op, Transaction } from "sequelize"; +import { Address, encodeFunctionData, Hex, parseEventLogs, TransactionReceipt, TransactionReceiptNotFoundError } from "viem"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumChainCursor from "../../../models/moneriumChainCursor.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { + erc20Abi, + factoryAbi, + forwarderAbi, + getChainId, + getForwarderImmutables, + getKeeperWalletClient, + getPublicClient, + swapExecutedEvent +} from "./chain"; +import { withForwarderLock } from "./deposit-processor"; + +/** + * Per-account conversion executor (plan §3, "Keeper" + "Attribution (R04)"): + * balance >= minSwapAmount -> poke() (stranding marker, R03) + swapAndForward() via the + * private submission transport, with an execution record created and committed BEFORE + * anything is sent. Snapshot-based deposit attribution is deferred until the mint + * cursor covers the confirmed swap's exact block/log boundary. + * + * Serialization: every database mutation runs inside the per-forwarder advisory lock + * (withForwarderLock). The chain send/wait itself deliberately happens OUTSIDE a lock — + * holding a transaction open across RPC waits would pin a connection for minutes, and + * crash-safety requires the pending execution row to be durably COMMITTED before the + * transaction is broadcast (a row inside an open transaction would roll back on crash). + * Double-send is instead prevented by the "any pending execution -> skip" check, which + * runs under the lock. + */ + +/** Retry backoff for failed executions: base * 2^attempts, capped. Kept deliberately minimal. */ +const RETRY_BASE_MS = 60_000; +const RETRY_MAX_MS = 60 * 60_000; + +/** How long one cycle waits for the swap receipt before deferring to the next cycle. */ +const RECEIPT_TIMEOUT_MS = 3 * 60_000; + +/** + * A nonce-less pending row is a live pre-send reservation until this deadline. The + * executor compare-and-sets the row before broadcasting, so a stalled owner cannot + * resume and send after another process expires the reservation. + */ +const PRE_SEND_RESERVATION_MS = 5 * 60_000; + +/** Keep recovery log requests below common RPC block-range limits. */ +const RECOVERY_LOG_BLOCK_RANGE = 2000n; + +/** + * Serializes nonce derivation and the broadcasts that consume it across every process + * sharing the database: two concurrent senders would otherwise derive the same pending + * nonce for the single keeper account. Distinct from the per-forwarder lock, which + * scopes per-account database state, not the keeper's global nonce sequence. + */ +async function withKeeperSendLock(fn: () => Promise): Promise { + return sequelize.transaction(async transaction => { + await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { + replacements: { key: "monerium-b2b:keeper-sends" }, + transaction + }); + return fn(); + }); +} + +interface SwapBroadcastSequence { + broadcastBlockNumber: number; + pendingNonce: number; + pokeNeeded: boolean; + reserveSwap(nonce: number, broadcastBlockNumber: number): Promise; + sendPoke(nonce: number): Promise; + sendSwap(nonce: number): Promise; +} + +/** Safety-critical ordering: harmless poke, durable swap identity, value-moving send. */ +export async function broadcastSwapSequence(input: SwapBroadcastSequence): Promise { + let swapNonce = input.pendingNonce; + if (input.pokeNeeded) { + await input.sendPoke(swapNonce); + swapNonce += 1; + } + if (!(await input.reserveSwap(swapNonce, input.broadcastBlockNumber))) { + throw new Error("execution lost its pre-send reservation"); + } + return input.sendSwap(swapNonce); +} + +/** Maps SwapExecuted into accounting values; `forwarded` may include pre-existing USDC. */ +export function conversionAmountsFromSwapEvent(event: { fee: bigint; forwarded: bigint; usdcOut: bigint }): { + feeRaw: string; + usdcGrossRaw: string; + usdcNetRaw: string; +} { + if (event.fee > event.usdcOut) { + throw new Error("SwapExecuted fee exceeds this swap's USDC output"); + } + return { + feeRaw: event.fee.toString(), + usdcGrossRaw: event.usdcOut.toString(), + usdcNetRaw: (event.usdcOut - event.fee).toString() + }; +} + +// ------------------------------------------------------------------ R04 allocation math + +export interface AllocatableDeposit { + id: string; + amountRaw: bigint; +} + +/** + * Allocates an execution across oldest outstanding deposit balances. A cap-cut deposit + * is split: its remainder remains available for the next execution. This is what makes + * both one-execution-to-many-deposits and one-deposit-to-many-executions representable. + */ +export function selectDepositsForExecution(deposits: AllocatableDeposit[], eureInRaw: bigint): AllocatableDeposit[] { + const selected: AllocatableDeposit[] = []; + let remaining = eureInRaw; + for (const deposit of deposits) { + if (remaining <= 0n) break; + const amountRaw = deposit.amountRaw > remaining ? remaining : deposit.amountRaw; + if (amountRaw <= 0n) continue; + selected.push({ amountRaw, id: deposit.id }); + remaining -= amountRaw; + } + return selected; +} + +/** + * R04 pro-rata attribution of the execution's net USDC: each deposit gets + * floor(usdcNetRaw * effectiveAmount / eureInRaw), where effectiveAmount is the + * allocated EURe amount / eureInRaw. When allocations cover the execution exactly, + * floor dust goes to the largest allocation (ties: earliest). If indexed deposits do + * not cover the execution, unknown value remains unattributed instead of inflating a + * known customer's share. + */ +export function allocateUsdcProRata( + deposits: AllocatableDeposit[], + eureInRaw: bigint, + usdcNetRaw: bigint +): Map { + const shares = new Map(); + if (deposits.length === 0 || eureInRaw <= 0n) { + return shares; + } + let allocated = 0n; + let largest = deposits[0]; + for (const deposit of deposits) { + const share = (usdcNetRaw * deposit.amountRaw) / eureInRaw; + shares.set(deposit.id, share); + allocated += share; + if (deposit.amountRaw > largest.amountRaw) { + largest = deposit; + } + } + const coveredEure = deposits.reduce((sum, deposit) => sum + deposit.amountRaw, 0n); + const remainder = usdcNetRaw - allocated; + if (coveredEure === eureInRaw && remainder > 0n) { + shares.set(largest.id, (shares.get(largest.id) as bigint) + remainder); + } + return shares; +} + +// ------------------------------------------------------------------ finalization + attribution + +function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 500); +} + +async function allocateDeposits(execution: MoneriumConversionExecution, transaction: Transaction): Promise { + if (execution.blockNumber === null || execution.swapLogIndex === null) { + return 0; + } + // R04 snapshot: outstanding portions of minted deposits before the execution's exact + // block/log position, oldest mint first. Unattributed inflows participate because + // their EURe was part of the swapped balance, but never surface as customer claims. + const deposits = await MoneriumFiatDeposit.findAll({ + order: [ + ["block_number", "ASC"], + ["log_index", "ASC"] + ], + transaction, + where: { + accountId: execution.accountId, + [Op.or]: [ + { blockNumber: { [Op.lt]: execution.blockNumber } }, + { blockNumber: execution.blockNumber, logIndex: { [Op.lt]: execution.swapLogIndex } } + ], + status: MoneriumFiatDepositStatus.Minted + } + }); + const existingAllocations = deposits.length + ? await MoneriumDepositAllocation.findAll({ transaction, where: { depositId: deposits.map(deposit => deposit.id) } }) + : []; + const allocatedByDeposit = new Map(); + for (const allocation of existingAllocations) { + allocatedByDeposit.set( + allocation.depositId, + (allocatedByDeposit.get(allocation.depositId) ?? 0n) + BigInt(allocation.eureInRaw) + ); + } + const eureInRaw = BigInt(execution.eureInRaw); + const selected = selectDepositsForExecution( + deposits + .map(deposit => ({ + amountRaw: BigInt(deposit.amountRaw) - (allocatedByDeposit.get(deposit.id) ?? 0n), + id: deposit.id + })) + .filter(deposit => deposit.amountRaw > 0n), + eureInRaw + ); + if (selected.length === 0) { + return 0; + } + const shares = allocateUsdcProRata(selected, eureInRaw, BigInt(execution.usdcNetRaw ?? "0")); + await MoneriumDepositAllocation.bulkCreate( + selected.map(deposit => ({ + depositId: deposit.id, + eureInRaw: deposit.amountRaw.toString(), + executionId: execution.id, + usdcNetRaw: (shares.get(deposit.id) ?? 0n).toString() + })), + { transaction } + ); + const coveredEure = selected.reduce((sum, deposit) => sum + deposit.amountRaw, 0n); + if (coveredEure !== eureInRaw) { + logger.error( + `monerium-b2b: execution ${execution.id} converted ${eureInRaw.toString()} raw EURe but only ` + + `${coveredEure.toString()} was covered by indexed deposit allocations` + ); + } + logger.info( + `monerium-b2b: execution ${execution.id} allocated ${selected.length} deposit portion(s): ` + + selected + .map(deposit => `${deposit.id}:eure=${deposit.amountRaw.toString()},usdc=${(shares.get(deposit.id) ?? 0n).toString()}`) + .join(", ") + ); + return selected.length; +} + +/** + * Allocates confirmed swaps only after the mint cursor has scanned through their + * block. This closes the normal head-lag race and also includes a mint that landed + * between the executor's balance read and the swap transaction. + */ +export async function reconcileConfirmedExecutionAllocations( + deps: { getChainId(): Promise } = { getChainId } +): Promise { + const chainId = await deps.getChainId(); + const cursor = await MoneriumChainCursor.findByPk(`eure-mints:${chainId}`); + if (!cursor) return 0; + + const executions = await MoneriumConversionExecution.findAll({ + order: [ + ["block_number", "ASC"], + ["swap_log_index", "ASC"] + ], + where: { + blockNumber: { [Op.lte]: Number(cursor.lastBlock) }, + id: { [Op.notIn]: sequelize.literal("(SELECT execution_id FROM monerium_deposit_allocations)") }, + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: { [Op.ne]: null } + } + }); + let allocated = 0; + for (const execution of executions) { + const account = await MoneriumAccount.findByPk(execution.accountId); + if (!account) continue; + allocated += await withForwarderLock(account.forwarderAddress, async transaction => { + if (await MoneriumDepositAllocation.count({ transaction, where: { executionId: execution.id } })) { + return 0; + } + const current = await MoneriumConversionExecution.findByPk(execution.id, { transaction }); + if (!current || current.status !== MoneriumConversionExecutionStatus.Confirmed) { + return 0; + } + return allocateDeposits(current, transaction); + }); + } + return allocated; +} + +/** Applies a mined receipt to a pending execution: confirmed + event amounts, or failed on revert. */ +async function finalizeExecution( + execution: MoneriumConversionExecution, + receipt: TransactionReceipt, + forwarderAddress: string, + transaction: Transaction +): Promise { + if (receipt.status !== "success") { + await execution.update( + { + blockNumber: Number(receipt.blockNumber), + error: "swapAndForward reverted", + status: MoneriumConversionExecutionStatus.Failed + }, + { transaction } + ); + return; + } + const swapEvents = parseEventLogs({ abi: forwarderAbi, eventName: "SwapExecuted", logs: receipt.logs }).filter( + log => log.address.toLowerCase() === forwarderAddress.toLowerCase() + ); + if (swapEvents.length === 0) { + // A successful swapAndForward always emits SwapExecuted; treat absence as failure. + await execution.update( + { + blockNumber: Number(receipt.blockNumber), + error: "receipt succeeded but no SwapExecuted event was emitted by the forwarder", + status: MoneriumConversionExecutionStatus.Failed + }, + { transaction } + ); + return; + } + const swapEvent = swapEvents[0]; + const { eureIn } = swapEvent.args; + const conversionAmounts = conversionAmountsFromSwapEvent(swapEvent.args); + await execution.update( + { + blockNumber: Number(receipt.blockNumber), + error: null, + // The event's amountIn is authoritative (min(balance, cap) at execution time). + eureInRaw: eureIn.toString(), + ...conversionAmounts, + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: swapEvent.logIndex, + txHash: receipt.transactionHash + }, + { transaction } + ); +} + +// ------------------------------------------------------------------ pending resolution + backoff + +type PreparationResult = { kind: "proceed"; attempt: number } | { kind: "skip"; reason: string }; + +export type HashlessPendingClassification = + | { kind: "fail"; reason: string } + | { kind: "in-flight"; reason: string } + | { kind: "adopt"; txHash: string }; + +export interface RecoveryTransactionIdentity { + from: string; + input: string; + nonce: number; + to: string | null; +} + +const SWAP_AND_FORWARD_CALLDATA = encodeFunctionData({ abi: forwarderAbi, functionName: "swapAndForward" }); + +/** Exact transaction identity required before a lost hash may be adopted. */ +export function isExpectedSwapTransaction( + transaction: RecoveryTransactionIdentity, + keeperAddress: string, + forwarderAddress: string, + nonce: number +): boolean { + return ( + transaction.from.toLowerCase() === keeperAddress.toLowerCase() && + transaction.nonce === nonce && + transaction.to?.toLowerCase() === forwarderAddress.toLowerCase() && + transaction.input.toLowerCase() === SWAP_AND_FORWARD_CALLDATA.toLowerCase() + ); +} + +/** + * Decides what happened to a pending execution whose tx hash was never persisted (a + * crash or DB error between broadcast and the hash update). Inputs are pure chain + * observations. A nonce that has not been consumed remains uncertain indefinitely; + * once consumed, only one exact sender+nonce+target+calldata match may be adopted. + */ +export function classifyHashlessPending(input: { + nonce: number | null; + latestNonceCount: number; + matchingSwapTxHashes: string[]; + scanComplete: boolean; +}): HashlessPendingClassification { + if (input.nonce === null) { + // The nonce is persisted before any broadcast, so no nonce means the send phase + // was never reached — nothing can be in flight. + return { kind: "fail", reason: "crashed before the transaction was sent" }; + } + if (input.latestNonceCount <= input.nonce) { + return { kind: "in-flight", reason: "the persisted nonce has not been consumed" }; + } + if (!input.scanComplete) { + return { kind: "in-flight", reason: "an exact recovery scan could not be completed" }; + } + if (input.matchingSwapTxHashes.length === 1) { + return { kind: "adopt", txHash: input.matchingSwapTxHashes[0] }; + } + if (input.matchingSwapTxHashes.length > 1) { + return { kind: "in-flight", reason: "multiple exact recovery candidates were found" }; + } + return { kind: "fail", reason: "nonce consumed without the expected swap transaction" }; +} + +/** Inclusive, non-overlapping block ranges for a complete bounded recovery scan. */ +export function recoveryBlockRanges(fromBlock: bigint, toBlock: bigint): Array<{ fromBlock: bigint; toBlock: bigint }> { + const ranges: Array<{ fromBlock: bigint; toBlock: bigint }> = []; + for (let start = fromBlock; start <= toBlock; start += RECOVERY_LOG_BLOCK_RANGE) { + const end = start + RECOVERY_LOG_BLOCK_RANGE - 1n; + ranges.push({ fromBlock: start, toBlock: end < toBlock ? end : toBlock }); + } + return ranges; +} + +/** + * Scans every block since the pre-broadcast head and returns only unclaimed + * SwapExecuted transactions with the exact keeper identity persisted on the row. + */ +async function findMatchingSwapTxHashes( + pending: MoneriumConversionExecution, + account: MoneriumAccount, + transaction: Transaction +): Promise<{ matchingSwapTxHashes: string[]; scanComplete: boolean }> { + if (pending.nonce === null || pending.broadcastBlockNumber === null) { + return { matchingSwapTxHashes: [], scanComplete: false }; + } + const client = getPublicClient(); + const latestBlock = await client.getBlockNumber(); + const loggedHashes = new Set(); + for (const range of recoveryBlockRanges(BigInt(pending.broadcastBlockNumber), latestBlock)) { + const logs = await client.getLogs({ + address: account.forwarderAddress as Address, + event: swapExecutedEvent, + ...range + }); + for (const log of logs) { + loggedHashes.add(log.transactionHash.toLowerCase() as Hex); + } + } + if (loggedHashes.size === 0) { + return { matchingSwapTxHashes: [], scanComplete: true }; + } + const known = await MoneriumConversionExecution.findAll({ + attributes: ["txHash"], + transaction, + where: { id: { [Op.ne]: pending.id }, txHash: { [Op.ne]: null } } + }); + const claimed = new Set(known.map(row => (row.txHash as string).toLowerCase())); + const hashes = [...loggedHashes]; + const keeperAddress = getKeeperWalletClient().account.address; + const matchingSwapTxHashes: string[] = []; + let claimedExactMatch = false; + for (const hash of hashes) { + const candidate = await client.getTransaction({ hash }); + if (!isExpectedSwapTransaction(candidate, keeperAddress, account.forwarderAddress, pending.nonce)) { + continue; + } + if (claimed.has(hash.toLowerCase())) { + claimedExactMatch = true; + } else { + matchingSwapTxHashes.push(hash); + } + } + return { matchingSwapTxHashes, scanComplete: !claimedExactMatch }; +} + +/** + * Under the forwarder lock: resolve leftover pending executions (crash/timeout + * recovery), then decide whether a new execution may start (retry backoff). + */ +async function prepareExecutionSlot(account: MoneriumAccount, transaction: Transaction): Promise { + const pendings = await MoneriumConversionExecution.findAll({ + order: [["created_at", "ASC"]], + transaction, + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Pending } + }); + for (const pending of pendings) { + const client = getPublicClient(); + if (pending.txHash) { + try { + const receipt = await client.getTransactionReceipt({ hash: pending.txHash as Hex }); + await finalizeExecution(pending, receipt, account.forwarderAddress, transaction); + continue; + } catch (error) { + if (!(error instanceof TransactionReceiptNotFoundError)) { + return { kind: "skip", reason: `receipt lookup failed for ${pending.txHash}: ${errorText(error)}` }; + } + } + } + + if (pending.nonce === null) { + if (pending.txHash) { + return { kind: "skip", reason: `execution ${pending.id} has a hash but no recovery nonce` }; + } + if (Date.now() - pending.createdAt.getTime() < PRE_SEND_RESERVATION_MS) { + return { kind: "skip", reason: `execution ${pending.id} is preparing its transaction` }; + } + const [expired] = await MoneriumConversionExecution.update( + { error: "crashed before the transaction was sent", status: MoneriumConversionExecutionStatus.Failed }, + { + transaction, + where: { id: pending.id, nonce: null, status: MoneriumConversionExecutionStatus.Pending } + } + ); + if (expired === 0) { + return { kind: "skip", reason: `execution ${pending.id} changed while its pre-send reservation was expiring` }; + } + continue; + } + + try { + const keeperAddress = getKeeperWalletClient().account.address; + const latestNonceCount = await client.getTransactionCount({ address: keeperAddress, blockTag: "latest" }); + const recovery = + latestNonceCount > pending.nonce + ? await findMatchingSwapTxHashes(pending, account, transaction) + : { matchingSwapTxHashes: [], scanComplete: true }; + const classification = classifyHashlessPending({ latestNonceCount, nonce: pending.nonce, ...recovery }); + if (classification.kind === "in-flight") { + return { kind: "skip", reason: `execution ${pending.id} remains pending: ${classification.reason}` }; + } + if (classification.kind === "fail") { + await pending.update( + { error: classification.reason, status: MoneriumConversionExecutionStatus.Failed }, + { transaction } + ); + continue; + } + logger.warn( + `monerium-b2b: recovered exact tx hash ${classification.txHash} for execution ${pending.id} via nonce ${pending.nonce}` + ); + await pending.update({ txHash: classification.txHash }, { transaction }); + const receipt = await client.getTransactionReceipt({ hash: classification.txHash as Hex }); + await finalizeExecution(pending, receipt, account.forwarderAddress, transaction); + } catch (error) { + return { kind: "skip", reason: `recovery lookup failed for execution ${pending.id}: ${errorText(error)}` }; + } + } + + // Backoff over consecutive failures since the last confirmed execution. + const lastConfirmed = await MoneriumConversionExecution.findOne({ + order: [["created_at", "DESC"]], + transaction, + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Confirmed } + }); + const failedSince: MoneriumConversionExecution[] = await MoneriumConversionExecution.findAll({ + order: [["created_at", "DESC"]], + transaction, + where: { + accountId: account.id, + status: MoneriumConversionExecutionStatus.Failed, + ...(lastConfirmed ? { createdAt: { [Op.gt]: lastConfirmed.createdAt } } : {}) + } + }); + if (failedSince.length > 0) { + const backoffMs = Math.min(RETRY_BASE_MS * 2 ** (failedSince.length - 1), RETRY_MAX_MS); + const nextAttemptAt = failedSince[0].updatedAt.getTime() + backoffMs; + if (Date.now() < nextAttemptAt) { + return { kind: "skip", reason: `retry backoff until ${new Date(nextAttemptAt).toISOString()}` }; + } + } + return { attempt: failedSince.length + 1, kind: "proceed" }; +} + +// ------------------------------------------------------------------ executor + +/** + * Runs one conversion cycle for an account. Safe to call for accounts with nothing to + * do (cheap chain reads, then returns). + */ +export async function runConversionExecutor(accountId: string): Promise { + const account = await MoneriumAccount.findByPk(accountId); + if (!account) { + return; + } + + // Recover an earlier broadcast before current account state or balance can make this + // cycle return. A successful swap commonly drains the balance below the minimum. + const existingPending = await MoneriumConversionExecution.findOne({ + attributes: ["id"], + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Pending } + }); + if (existingPending) { + const recovery = await withForwarderLock(account.forwarderAddress, transaction => + prepareExecutionSlot(account, transaction) + ); + if (recovery.kind === "skip") { + logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${recovery.reason}`); + return; + } + } + + // Suspended/closed/dormant accounts never swap (dormancy is guardian-paused — + // swapAndForward would revert Paused()), but the stranding marker MUST still arm for + // them: the un-pausable dead-man sweep is the client's escape hatch for exactly the + // accounts nobody is operating any more, and poke() is pause-immune by design. + const convertible = + account.status !== MoneriumAccountStatus.Suspended && + account.status !== MoneriumAccountStatus.Closed && + !account.dormantSince; + + const client = getPublicClient(); + const forwarder = account.forwarderAddress as Address; + const { eure, factory } = await getForwarderImmutables(forwarder); + if ( + !config.moneriumB2b.forwarderFactoryAddress || + factory.toLowerCase() !== config.moneriumB2b.forwarderFactoryAddress.toLowerCase() + ) { + throw new Error(`Forwarder ${forwarder} is not bound to the configured trusted factory`); + } + const [balance, strandedSince, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) + ]); + + // R03: arm the stranding marker whenever funds cross the immutable floor, even below + // the (guardian-tunable) minSwapAmount — the dead-man timers must start regardless of + // whether a swap is currently possible. + const pokeNeeded = strandedSince === 0n && balance >= minSwapFloor; + + if (!convertible || balance < minSwapAmount) { + if (pokeNeeded) { + await sendPoke(forwarder); + } + return; + } + + // Pending-check and execution-row create under ONE lock acquisition: split across two + // transactions, two concurrent executors could both pass the check and both broadcast. + const slot = await withForwarderLock(account.forwarderAddress, async transaction => { + const preparation = await prepareExecutionSlot(account, transaction); + if (preparation.kind === "skip") { + return preparation; + } + // Execution-before-send record (plan §3): committed before any broadcast so a crash + // leaves an auditable pending row, never an untracked on-chain swap. + const execution = await MoneriumConversionExecution.create( + { + accountId: account.id, + destination: account.destination, + eureInRaw: (balance > perSwapCap ? perSwapCap : balance).toString() + }, + { transaction } + ); + return { attempt: preparation.attempt, execution, kind: "proceed" as const }; + }); + if (slot.kind === "skip") { + logger.info(`monerium-b2b: skipping conversion for account ${account.id}: ${slot.reason}`); + return; + } + const { attempt, execution } = slot; + + try { + const keeper = getKeeperWalletClient(); + + // Simulations run before the send phase so a plain revert fails the row + // immediately (no nonce persisted yet -> the catch below marks it Failed). + if (pokeNeeded) { + await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); + } + await client.simulateContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + functionName: "swapAndForward" + }); + + // Send phase, serialized across processes: explicit nonces because poke + swap go + // back-to-back through the private transport, which may not expose a coherent + // pending pool for derivation. Poke is harmless and may fail before the value-moving + // send is attempted; persist the swap nonce only after poke succeeds, immediately + // before swapAndForward is broadcast. + const txHash = await withKeeperSendLock(async () => { + const [pendingNonce, broadcastBlock] = await Promise.all([ + client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" }), + client.getBlockNumber() + ]); + const broadcastBlockNumber = Number(broadcastBlock); + return broadcastSwapSequence({ + broadcastBlockNumber, + pendingNonce, + pokeNeeded, + reserveSwap: async nonce => { + const [reserved] = await MoneriumConversionExecution.update( + { broadcastBlockNumber, nonce }, + { where: { id: execution.id, nonce: null, status: MoneriumConversionExecutionStatus.Pending } } + ); + if (reserved === 1) { + execution.set({ broadcastBlockNumber, nonce }); + } + return reserved === 1; + }, + sendPoke: async nonce => { + await keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "poke", + nonce + }); + }, + sendSwap: nonce => + keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "swapAndForward", + nonce + }) + }); + }); + await execution.update({ txHash }); + + const receipt = await client.waitForTransactionReceipt({ hash: txHash, timeout: RECEIPT_TIMEOUT_MS }); + await withForwarderLock(account.forwarderAddress, transaction => + finalizeExecution(execution, receipt, account.forwarderAddress, transaction) + ); + } catch (error) { + if (execution.txHash) { + // The transaction is (or may be) in flight; leave the row pending — the next + // cycle resolves it by receipt or exact nonce-bound recovery. Time alone is + // never evidence that it is safe to send another value-moving transaction. + logger.warn(`monerium-b2b: execution ${execution.id} awaiting receipt after error: ${errorText(error)}`); + return; + } + if (execution.nonce !== null) { + // The send phase was reached but the outcome (or the hash persist) is unknown; + // leave the row pending — recovery resolves it via nonce consumption. + logger.warn( + `monerium-b2b: execution ${execution.id} broadcast outcome unknown, recovering via nonce: ${errorText(error)}` + ); + return; + } + await execution.update({ + error: `attempt ${attempt}: ${errorText(error)}`, + status: MoneriumConversionExecutionStatus.Failed + }); + logger.error(`monerium-b2b: conversion for account ${account.id} failed (attempt ${attempt}):`, error); + } +} + +/** Standalone stranding-marker poke for balances between the floor and minSwapAmount. */ +async function sendPoke(forwarder: Address): Promise { + try { + const client = getPublicClient(); + const keeper = getKeeperWalletClient(); + await client.simulateContract({ abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "poke" }); + // Implicit nonce, so the send still serializes with the swap path's derivation. + const hash = await withKeeperSendLock(() => + keeper.writeContract({ + abi: forwarderAbi, + account: keeper.account, + address: forwarder, + chain: null, + functionName: "poke" + }) + ); + logger.info(`monerium-b2b: poked forwarder ${forwarder} (${hash})`); + } catch (error) { + // Best-effort: poke is also permissionless on-chain, so a missed poke only delays + // the stranding timers until the next cycle. + logger.warn(`monerium-b2b: poke for forwarder ${forwarder} failed: ${errorText(error)}`); + } +} diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts new file mode 100644 index 000000000..c8bde0fae --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts @@ -0,0 +1,498 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { + isForwardTransition, + mapOrderStateToDepositStatus, + parseIbanEvent, + parseOrderEvent, + processMoneriumWebhookInbox +} from "./deposit-processor"; + +const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; +const PROFILE_ID = "11111111-1111-4111-8111-111111111111"; +const ORDER_ID = "22222222-2222-4222-8222-222222222222"; +const PROCESSOR_DEPS = { getChainId: async () => 11155111 }; + +describe("forward-only deposit status transitions", () => { + it("allows pending to progress to minted, held, or returned", () => { + expect(isForwardTransition(Pending, Minted)).toBe(true); + expect(isForwardTransition(Pending, Held)).toBe(true); + expect(isForwardTransition(Pending, Returned)).toBe(true); + }); + + it("allows a hold to resolve to minted or returned but never back to pending", () => { + expect(isForwardTransition(Held, Minted)).toBe(true); + expect(isForwardTransition(Held, Returned)).toBe(true); + expect(isForwardTransition(Held, Pending)).toBe(false); + }); + + it("treats minted and returned as terminal", () => { + for (const to of [Pending, Held, Returned]) { + expect(isForwardTransition(Minted, to)).toBe(false); + } + for (const to of [Pending, Held, Minted]) { + expect(isForwardTransition(Returned, to)).toBe(false); + } + }); + + it("never allows a self-transition write", () => { + for (const status of [Pending, Held, Minted, Returned]) { + expect(isForwardTransition(status, status)).toBe(false); + } + }); +}); + +describe("mapOrderStateToDepositStatus", () => { + it("maps documented Monerium order states", () => { + expect(mapOrderStateToDepositStatus("placed")).toBe(Pending); + expect(mapOrderStateToDepositStatus("pending")).toBe(Pending); + expect(mapOrderStateToDepositStatus("processed")).toBe(Minted); + expect(mapOrderStateToDepositStatus("rejected")).toBe(Returned); + expect(mapOrderStateToDepositStatus("held")).toBe(Held); + }); + + it("normalizes case and whitespace, and returns null for unknown states", () => { + expect(mapOrderStateToDepositStatus(" Processed ")).toBe(Minted); + expect(mapOrderStateToDepositStatus("something-new")).toBeNull(); + expect(mapOrderStateToDepositStatus("")).toBeNull(); + }); +}); + +describe("parseOrderEvent", () => { + const validPayload = { + data: { + address: "0x1111111111111111111111111111111111111111", + amount: "100.5", + chain: "sepolia", + counterpart: { + identifier: { + address: "0x1111111111111111111111111111111111111111", + chain: "sepolia", + standard: "chain" + } + }, + currency: "eur", + id: ORDER_ID, + kind: "issue", + memo: "", + meta: { placedAt: "2026-07-17T00:00:00Z", txHashes: ["0xabc"] }, + profile: PROFILE_ID, + state: "processed" + }, + timestamp: "2026-07-17T00:00:00Z", + type: "order.updated" + }; + + it("extracts the issue-order fields", () => { + expect(parseOrderEvent(validPayload)).toEqual({ + amount: "100.5", + chain: "sepolia", + currency: "eur", + forwarderAddress: "0x1111111111111111111111111111111111111111", + orderId: ORDER_ID, + profileId: PROFILE_ID, + state: "processed", + txHash: "0xabc" + }); + }); + + it("ignores redeem orders, non-order events, and malformed payloads", () => { + expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, kind: "redeem" } })).toBeNull(); + expect(parseOrderEvent({ ...validPayload, type: "profile.updated" })).toBeNull(); + expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, id: undefined } })).toBeNull(); + expect(parseOrderEvent({ ...validPayload, data: { ...validPayload.data, amount: 100.5 } })).toBeNull(); + expect(parseOrderEvent(null)).toBeNull(); + expect(parseOrderEvent("junk")).toBeNull(); + }); +}); + +describe("parseIbanEvent", () => { + const validPayload = { + data: { + address: "0x1111111111111111111111111111111111111111", + chain: "ethereum", + iban: "EE08 7224 5745 6244 9516", + profile: PROFILE_ID + }, + timestamp: "2026-07-17T00:00:00Z", + type: "iban.updated" + }; + + it("extracts the IBAN and its linked address", () => { + expect(parseIbanEvent(validPayload)).toEqual({ + address: "0x1111111111111111111111111111111111111111", + chain: "ethereum", + iban: "EE08 7224 5745 6244 9516", + profileId: PROFILE_ID + }); + }); + + it("ignores non-iban events and payloads missing the IBAN or address", () => { + expect(parseIbanEvent({ ...validPayload, type: "order.updated" })).toBeNull(); + expect(parseIbanEvent({ ...validPayload, data: { ...validPayload.data, iban: "" } })).toBeNull(); + expect(parseIbanEvent({ ...validPayload, data: { ...validPayload.data, address: undefined } })).toBeNull(); + expect(parseIbanEvent(null)).toBeNull(); + expect(parseIbanEvent("junk")).toBeNull(); + }); +}); + +describe("order-event inbox processing (end to end)", () => { + const FORWARDER = "0x1111111111111111111111111111111111111111"; + + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + function orderEvent(state: string, overrides: Record = {}) { + return { + data: { + address: FORWARDER, + amount: "100.5", + chain: "sepolia", + counterpart: { + identifier: { address: FORWARDER, chain: "sepolia", standard: "chain" } + }, + currency: "eur", + id: ORDER_ID, + kind: "issue", + memo: "", + meta: { placedAt: "2026-08-26T00:00:00Z" }, + profile: PROFILE_ID, + state, + ...overrides + }, + timestamp: "2026-08-26T00:00:00Z", + type: "order.updated" + }; + } + + async function createAccount(): Promise { + return MoneriumAccount.create({ + destination: "0x2222222222222222222222222222222222222222", + fallbackAddress: "0x3333333333333333333333333333333333333333", + feeBps: 0, + forwarderAddress: FORWARDER, + profileId: PROFILE_ID + }); + } + + it("creates the deposit, advances it forward-only, and dedups deliveries", async () => { + const account = await createAccount(); + + await MoneriumWebhookEvent.create({ eventId: "evt-1", payload: orderEvent("placed") }); + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1); + + const created = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: ORDER_ID } }); + expect(created).toMatchObject({ + accountId: account.id, + amountRaw: (1005n * 10n ** 17n).toString(), + status: MoneriumFiatDepositStatus.Pending + }); + + // processed advances to minted and records the mint hash from meta. + await MoneriumWebhookEvent.create({ + eventId: "evt-2", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + await created?.reload(); + expect(created?.status).toBe(MoneriumFiatDepositStatus.Minted); + expect(created?.txHash).toBe("0xmint"); + + // A delayed older state must never regress the row. + await MoneriumWebhookEvent.create({ eventId: "evt-3", payload: orderEvent("pending") }); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + await created?.reload(); + expect(created?.status).toBe(MoneriumFiatDepositStatus.Minted); + + // Replayed deliveries of the same order never create a second row. + await MoneriumWebhookEvent.create({ eventId: "evt-4", payload: orderEvent("processed") }); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + expect(await MoneriumFiatDeposit.count()).toBe(1); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + + await MoneriumWebhookEvent.create({ eventId: "evt-divergent-amount", payload: orderEvent("processed", { amount: "101" }) }); + await MoneriumWebhookEvent.create({ + eventId: "evt-divergent-hash", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xother"] } }) + }); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + await created?.reload(); + expect(created?.amountRaw).toBe((1005n * 10n ** 17n).toString()); + expect(created?.txHash).toBe("0xmint"); + }); + + it("acks order events for unknown forwarders without creating deposits", async () => { + await MoneriumWebhookEvent.create({ eventId: "evt-5", payload: orderEvent("placed") }); + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1); + expect(await MoneriumFiatDeposit.count()).toBe(0); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + }); + + it("adopts an unattributed mint when its matching order webhook arrives late", async () => { + const account = await createAccount(); + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 101, + destination: account.destination, + eureInRaw: (1005n * 10n ** 17n).toString(), + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 4, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const unattributed = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw: (1005n * 10n ** 17n).toString(), + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 3, + moneriumOrderId: "unattr:late-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + const allocation = await MoneriumDepositAllocation.create({ + depositId: unattributed.id, + eureInRaw: unattributed.amountRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw as string + }); + await MoneriumWebhookEvent.create({ + eventId: "evt-late-order", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1); + expect(await MoneriumFiatDeposit.count()).toBe(1); + await unattributed.reload(); + await allocation.reload(); + expect(unattributed).toMatchObject({ + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + logIndex: 3, + moneriumOrderId: ORDER_ID, + txHash: "0xmint" + }); + expect(allocation.depositId).toBe(unattributed.id); + }); + + it("merges an unattributed mint when a tx hash resolves equal-amount order ambiguity", async () => { + const account = await createAccount(); + const otherOrderId = "33333333-3333-4333-8333-333333333333"; + await MoneriumWebhookEvent.bulkCreate([ + { eventId: "evt-ambiguous-order-a", payload: orderEvent("pending") }, + { eventId: "evt-ambiguous-order-b", payload: orderEvent("pending", { id: otherOrderId }) } + ]); + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + const providerDeposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: ORDER_ID } }); + const otherDeposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: otherOrderId } }); + expect(providerDeposit).not.toBeNull(); + expect(otherDeposit).not.toBeNull(); + if (!providerDeposit || !otherDeposit) throw new Error("expected both provider deposits"); + + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 101, + destination: account.destination, + eureInRaw: (1005n * 10n ** 17n).toString(), + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 4, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const unattributed = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw: (1005n * 10n ** 17n).toString(), + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 3, + moneriumOrderId: "unattr:ambiguous-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + const allocation = await MoneriumDepositAllocation.create({ + depositId: unattributed.id, + eureInRaw: unattributed.amountRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw as string + }); + await MoneriumWebhookEvent.create({ + eventId: "evt-ambiguous-order-resolved", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + await providerDeposit.reload(); + await otherDeposit.reload(); + await allocation.reload(); + expect(await MoneriumFiatDeposit.count()).toBe(2); + expect(providerDeposit).toMatchObject({ + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + logIndex: 3, + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + expect(otherDeposit).toMatchObject({ blockNumber: null, status: MoneriumFiatDepositStatus.Pending, txHash: null }); + expect(allocation.depositId).toBe(providerDeposit.id); + }); + + it("never merges a quarantined mint into a terminal returned order", async () => { + const account = await createAccount(); + const amountRaw = (1005n * 10n ** 17n).toString(); + const providerDeposit = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw, + currency: "eur", + moneriumOrderId: ORDER_ID, + status: MoneriumFiatDepositStatus.Returned + }); + const unattributed = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw, + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 3, + moneriumOrderId: "unattr:returned-order", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 101, + destination: account.destination, + eureInRaw: amountRaw, + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 4, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const allocation = await MoneriumDepositAllocation.create({ + depositId: unattributed.id, + eureInRaw: amountRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw as string + }); + await MoneriumWebhookEvent.create({ + eventId: "evt-returned-order-mint", + payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + await providerDeposit.reload(); + await unattributed.reload(); + await allocation.reload(); + expect(providerDeposit).toMatchObject({ + blockHash: null, + blockNumber: null, + chainId: null, + logIndex: null, + status: MoneriumFiatDepositStatus.Returned, + txHash: null + }); + expect(unattributed.txHash).toBe("0xmint"); + expect(allocation.depositId).toBe(unattributed.id); + }); + + it("does not adopt an unattributed mint for a first-seen returned order", async () => { + const account = await createAccount(); + const amountRaw = (1005n * 10n ** 17n).toString(); + const unattributed = await MoneriumFiatDeposit.create({ + accountId: account.id, + amountRaw, + blockHash: "0xblock", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 3, + moneriumOrderId: "unattr:first-seen-returned", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + const execution = await MoneriumConversionExecution.create({ + accountId: account.id, + blockNumber: 101, + destination: account.destination, + eureInRaw: amountRaw, + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: 4, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const allocation = await MoneriumDepositAllocation.create({ + depositId: unattributed.id, + eureInRaw: amountRaw, + executionId: execution.id, + usdcNetRaw: execution.usdcNetRaw as string + }); + await MoneriumWebhookEvent.create({ + eventId: "evt-first-seen-returned", + payload: orderEvent("rejected", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + }); + + await processMoneriumWebhookInbox(PROCESSOR_DEPS); + const providerDeposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: ORDER_ID } }); + await unattributed.reload(); + await allocation.reload(); + expect(await MoneriumFiatDeposit.count()).toBe(2); + expect(providerDeposit).toMatchObject({ + blockNumber: null, + status: MoneriumFiatDepositStatus.Returned, + txHash: "0xmint" + }); + expect(unattributed.moneriumOrderId).toBe("unattr:first-seen-returned"); + expect(allocation.depositId).toBe(unattributed.id); + }); + + it("discards wrong-currency, wrong-chain, and foreign-profile orders", async () => { + await createAccount(); + const cases = [ + { currency: "usd", id: "33333333-3333-4333-8333-333333333333" }, + { chain: "ethereum", id: "44444444-4444-4444-8444-444444444444" }, + { id: "55555555-5555-4555-8555-555555555555", profile: "66666666-6666-4666-8666-666666666666" } + ]; + for (const [index, overrides] of cases.entries()) { + await MoneriumWebhookEvent.create({ eventId: `evt-scope-${index}`, payload: orderEvent("placed", overrides) }); + } + + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(cases.length); + expect(await MoneriumFiatDeposit.count()).toBe(0); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + }); + + it("terminally discards malformed amounts without delaying later orders", async () => { + await createAccount(); + await MoneriumWebhookEvent.create({ + eventId: "evt-invalid-amount", + payload: orderEvent("placed", { + amount: "1.0000000000000000001", + id: "77777777-7777-4777-8777-777777777777" + }) + }); + await MoneriumWebhookEvent.create({ eventId: "evt-valid-after-invalid", payload: orderEvent("placed") }); + + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(2); + expect(await MoneriumFiatDeposit.count()).toBe(1); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts new file mode 100644 index 000000000..87b2c53f5 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -0,0 +1,402 @@ +import { + type MoneriumChain, + type MoneriumWebhookEvent as MoneriumWebhookPayload, + moneriumWebhookEventSchema +} from "@vortexfi/shared"; +import { Op, Transaction } from "sequelize"; +import { parseUnits } from "viem"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; +import { getChainId, moneriumChainForChainId } from "./chain"; + +/** + * Asynchronous processor for the durable webhook inbox (plan §3): upserts + * MoneriumFiatDeposit rows by monerium_order_id with forward-only status transitions, + * serialized per forwarder address via a Postgres transaction-scoped advisory lock. + */ + +const EURE_DECIMALS = 18; + +/** + * Runs `fn` inside a transaction holding the per-forwarder advisory lock (plan §3): + * the lock is transaction-scoped, so concurrent processors (multiple instances, + * webhook-triggered + scheduled runs, mint watcher, conversion executor) apply writes + * for one account strictly one at a time. Shared serialization point for the whole + * monerium-b2b module. + */ +export async function withForwarderLock(forwarderAddress: string, fn: (transaction: Transaction) => Promise): Promise { + const forwarderKey = forwarderAddress.toLowerCase(); + return sequelize.transaction(async transaction => { + await sequelize.query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { + replacements: { key: `monerium-b2b:${forwarderKey}` }, + transaction + }); + return fn(transaction); + }); +} + +// Forward-only lattice (plan §3): pending → minted/held/returned; a compliance hold can +// still resolve to minted or returned; minted/returned are terminal. +const FORWARD_TRANSITIONS: Record = { + [MoneriumFiatDepositStatus.Pending]: [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Held, + MoneriumFiatDepositStatus.Returned + ], + [MoneriumFiatDepositStatus.Held]: [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Returned], + [MoneriumFiatDepositStatus.Minted]: [], + [MoneriumFiatDepositStatus.Returned]: [] +}; + +export function isForwardTransition(from: MoneriumFiatDepositStatus, to: MoneriumFiatDepositStatus): boolean { + return FORWARD_TRANSITIONS[from].includes(to); +} + +/** + * Maps a Monerium issue-order state to a deposit status, or null for states we do not + * (yet) recognize. TODO(sandbox): pin the exact upstream state vocabulary — "processed" + * and "rejected" are documented; the compliance-hold value is a sandbox-verification item. + */ +export function mapOrderStateToDepositStatus(state: string): MoneriumFiatDepositStatus | null { + switch (state.trim().toLowerCase()) { + case "placed": + case "pending": + return MoneriumFiatDepositStatus.Pending; + case "processed": + return MoneriumFiatDepositStatus.Minted; + case "held": + case "on_hold": + return MoneriumFiatDepositStatus.Held; + case "rejected": + case "returned": + return MoneriumFiatDepositStatus.Returned; + default: + return null; + } +} + +interface ParsedOrderEvent { + orderId: string; + forwarderAddress: string; + amount: string; + chain: MoneriumChain; + currency: "eur"; + profileId: string; + state: string; + txHash: string | null; +} + +export interface ParsedIbanEvent { + address: string; + chain: MoneriumChain; + iban: string; + profileId: string; +} + +export interface DepositProcessorDeps { + getChainId(): Promise; +} + +const defaultDeps: DepositProcessorDeps = { getChainId }; + +function parseWebhookPayload(payload: unknown): MoneriumWebhookPayload | null { + const parsed = moneriumWebhookEventSchema.safeParse(payload); + return parsed.success ? parsed.data : null; +} + +/** + * Extracts the fields of an iban.updated delivery ({ type, timestamp, data }): the + * asynchronous completion of the onboarding IBAN request. Returns null for anything + * that is not an IBAN event with both the IBAN and its linked address. + */ +export function parseIbanEvent(payload: unknown): ParsedIbanEvent | null { + const event = parseWebhookPayload(payload); + if (!event || event.type !== "iban.updated") return null; + return { + address: event.data.address, + chain: event.data.chain, + iban: event.data.iban.trim(), + profileId: event.data.profile + }; +} + +async function processIbanEvent( + row: MoneriumWebhookEvent, + event: ParsedIbanEvent, + expectedChain: MoneriumChain +): Promise { + await withForwarderLock(event.address, async transaction => { + const account = await MoneriumAccount.findOne({ + transaction, + where: sequelize.where(sequelize.fn("lower", sequelize.col("forwarder_address")), event.address.toLowerCase()) + }); + if (!account) { + logger.warn("monerium-b2b: iban.updated references an unknown forwarder address, skipping"); + } else if (event.chain !== expectedChain || event.profileId !== account.profileId) { + logger.error(`monerium-b2b: iban.updated scope mismatch for account ${account.id}, skipping`); + } else if (account.iban === null) { + await account.update({ iban: event.iban }, { transaction }); + } else if (account.iban !== event.iban) { + // Never overwrite: an IBAN change on a live account is the association + // monitor's alert condition (PATCH /ibans detective control), not routine data. + logger.error( + `monerium-b2b: iban.updated reports a different IBAN for account ${account.id} — possible IBAN move, not overwriting` + ); + } + await row.update({ processedAt: new Date() }, { transaction }); + }); +} + +/** + * Extracts the issue-order fields this processor acts on from a delivery payload + * (documented shape: { type, timestamp, data }). Returns null for deliveries that are + * not EURe issue orders — those are acked and marked processed without a deposit write. + */ +export function parseOrderEvent(payload: unknown): ParsedOrderEvent | null { + const event = parseWebhookPayload(payload); + if (!event || (event.type !== "order.created" && event.type !== "order.updated")) return null; + const data = event.data; + if (data.kind !== "issue" || data.currency !== "eur") return null; + return { + amount: data.amount, + chain: data.chain, + currency: data.currency, + forwarderAddress: data.address, + orderId: data.id, + profileId: data.profile, + state: data.state, + txHash: data.meta.txHashes?.length === 1 ? data.meta.txHashes[0] : null + }; +} + +async function processInboxRow(row: MoneriumWebhookEvent, deps: DepositProcessorDeps): Promise { + const parsedPayload = parseWebhookPayload(row.payload); + if (!parsedPayload) { + logger.error(`monerium-b2b: authenticated webhook ${row.eventId} has an invalid payload, discarding`); + await row.update({ processedAt: new Date() }); + return; + } + + if ( + parsedPayload.type !== "iban.updated" && + parsedPayload.type !== "order.created" && + parsedPayload.type !== "order.updated" + ) { + await row.update({ processedAt: new Date() }); + return; + } + + const numericChainId = await deps.getChainId(); + const expectedChain = moneriumChainForChainId(numericChainId); + if (!expectedChain) { + throw new Error(`No Monerium chain name is configured for chain id ${numericChainId}`); + } + + if (parsedPayload.type === "iban.updated") { + await processIbanEvent(row, parseIbanEvent(parsedPayload) as ParsedIbanEvent, expectedChain); + return; + } + + const event = parseOrderEvent(parsedPayload); + if (!event) { + await row.update({ processedAt: new Date() }); + return; + } + + let amountRaw: string; + try { + const parsedAmount = parseUnits(event.amount, EURE_DECIMALS); + if (parsedAmount <= 0n) throw new Error("amount must be positive"); + amountRaw = parsedAmount.toString(); + } catch { + logger.error(`monerium-b2b: webhook order ${event.orderId} has an invalid EUR amount, discarding`); + await row.update({ processedAt: new Date() }); + return; + } + + const forwarderKey = event.forwarderAddress.toLowerCase(); + await withForwarderLock(forwarderKey, async transaction => { + const account = await MoneriumAccount.findOne({ + transaction, + where: sequelize.where(sequelize.fn("lower", sequelize.col("forwarder_address")), forwarderKey) + }); + if (!account) { + logger.warn(`monerium-b2b: webhook order ${event.orderId} references unknown forwarder address, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + if (event.chain !== expectedChain || event.profileId !== account.profileId) { + logger.error(`monerium-b2b: webhook order ${event.orderId} scope mismatch for account ${account.id}, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + + const targetStatus = mapOrderStateToDepositStatus(event.state); + let existing = await MoneriumFiatDeposit.findOne({ transaction, where: { moneriumOrderId: event.orderId } }); + if (!existing && event.txHash && targetStatus === MoneriumFiatDepositStatus.Minted) { + const unattributed = await MoneriumFiatDeposit.findAll({ + limit: 2, + transaction, + where: { + [Op.and]: [sequelize.where(sequelize.fn("lower", sequelize.col("tx_hash")), event.txHash.toLowerCase())], + accountId: account.id, + amountRaw, + chainId: numericChainId, + moneriumOrderId: { [Op.like]: "unattr:%" }, + status: MoneriumFiatDepositStatus.Minted + } + }); + if (unattributed.length > 1) { + logger.error(`monerium-b2b: webhook order ${event.orderId} matches multiple unattributed mint rows, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + if (unattributed.length === 1) { + existing = unattributed[0]; + await existing.update({ moneriumOrderId: event.orderId }, { transaction }); + logger.info(`monerium-b2b: reconciled late order ${event.orderId} to mint ${event.txHash}`); + } + } + if (existing && existing.accountId !== account.id) { + logger.error(`monerium-b2b: webhook order ${event.orderId} is already bound to a different account, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + if (existing && existing.amountRaw !== amountRaw) { + logger.error(`monerium-b2b: webhook order ${event.orderId} changed amount, refusing divergent replay`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + if (existing?.txHash && event.txHash && existing.txHash.toLowerCase() !== event.txHash.toLowerCase()) { + logger.error(`monerium-b2b: webhook order ${event.orderId} changed mint transaction hash, refusing divergence`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + const canAcceptMint = + existing && + (existing.status === MoneriumFiatDepositStatus.Minted || + isForwardTransition(existing.status, MoneriumFiatDepositStatus.Minted)); + if ( + existing && + canAcceptMint && + event.txHash && + targetStatus === MoneriumFiatDepositStatus.Minted && + existing.chainId === null && + existing.blockHash === null && + existing.blockNumber === null && + existing.logIndex === null + ) { + const unattributed = await MoneriumFiatDeposit.findAll({ + limit: 2, + transaction, + where: { + [Op.and]: [sequelize.where(sequelize.fn("lower", sequelize.col("tx_hash")), event.txHash.toLowerCase())], + accountId: account.id, + amountRaw, + chainId: numericChainId, + id: { [Op.ne]: existing.id }, + moneriumOrderId: { [Op.like]: "unattr:%" }, + status: MoneriumFiatDepositStatus.Minted + } + }); + if (unattributed.length > 1) { + logger.error(`monerium-b2b: webhook order ${event.orderId} matches multiple unattributed mint rows, skipping`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + if (unattributed.length === 1) { + const mint = unattributed[0]; + if (await MoneriumDepositAllocation.count({ transaction, where: { depositId: existing.id } })) { + logger.error(`monerium-b2b: webhook order ${event.orderId} already has allocations, refusing identity merge`); + await row.update({ processedAt: new Date() }, { transaction }); + return; + } + await MoneriumDepositAllocation.update({ depositId: existing.id }, { transaction, where: { depositId: mint.id } }); + await mint.destroy({ transaction }); + await existing.update( + { + blockHash: mint.blockHash, + blockNumber: mint.blockNumber, + chainId: mint.chainId, + logIndex: mint.logIndex, + txHash: mint.txHash + }, + { transaction } + ); + logger.info(`monerium-b2b: merged late order ${event.orderId} with mint ${event.txHash}`); + } + } + if (!existing) { + await MoneriumFiatDeposit.create( + { + accountId: account.id, + amountRaw, + currency: event.currency, + moneriumOrderId: event.orderId, + status: targetStatus ?? MoneriumFiatDepositStatus.Pending, + txHash: event.txHash + }, + { transaction } + ); + } else { + const updates: { status?: MoneriumFiatDepositStatus; txHash?: string } = {}; + if (targetStatus && targetStatus !== existing.status) { + if (isForwardTransition(existing.status, targetStatus)) { + updates.status = targetStatus; + } else { + logger.warn( + `monerium-b2b: ignoring backward status transition ${existing.status} -> ${targetStatus} for order ${event.orderId}` + ); + } + } + if (targetStatus === MoneriumFiatDepositStatus.Minted && event.txHash && !existing.txHash && canAcceptMint) { + updates.txHash = event.txHash; + } + if (Object.keys(updates).length > 0) { + await existing.update(updates, { transaction }); + } + } + + await row.update({ processedAt: new Date() }, { transaction }); + }); +} + +/** + * Processes all unprocessed inbox rows oldest-first. A row that fails stays + * unprocessed and is retried on the next run; rows we recognize but choose to skip are + * marked processed so they cannot poison the loop. + */ +export async function processMoneriumWebhookInbox(deps: DepositProcessorDeps = defaultDeps): Promise { + const rows = await MoneriumWebhookEvent.findAll({ + order: [["created_at", "ASC"]], + where: { processedAt: null } + }); + let processed = 0; + for (const row of rows) { + try { + await processInboxRow(row, deps); + processed += 1; + } catch (error) { + logger.error(`monerium-b2b: failed to process webhook inbox row ${row.eventId}:`, error); + } + } + return processed; +} + +/** Processed inbox rows older than this are pruned; dedup only needs the retry horizon. */ +const PROCESSED_INBOX_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +/** Deletes long-processed inbox rows so the durable inbox stays bounded. */ +export async function pruneProcessedWebhookEvents(): Promise { + const count = await MoneriumWebhookEvent.destroy({ + where: { processedAt: { [Op.lt]: new Date(Date.now() - PROCESSED_INBOX_RETENTION_MS) } } + }); + if (count > 0) { + logger.info(`monerium-b2b: pruned ${count} processed webhook inbox row(s)`); + } + return count; +} diff --git a/apps/api/src/api/services/monerium-b2b/dormancy.test.ts b/apps/api/src/api/services/monerium-b2b/dormancy.test.ts new file mode 100644 index 000000000..f5f6a07bf --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/dormancy.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import { DORMANCY_WINDOW_MS, isDormancyCandidate } from "./dormancy"; + +// Dormancy selection (plan §3, R05; window = registry P5, 60 days). Pure predicate — +// runDormancyGate applies it per active account with its latest confirmed execution. + +const NOW = new Date("2026-07-17T12:00:00Z"); + +function daysAgo(days: number): Date { + return new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000); +} + +function account(overrides: Partial[0]> = {}) { + return { + createdAt: daysAgo(365), + dormantSince: null, + status: MoneriumAccountStatus.Active, + ...overrides + }; +} + +describe("isDormancyCandidate", () => { + it("uses a 60-day window (registry P5)", () => { + expect(DORMANCY_WINDOW_MS).toBe(60 * 24 * 60 * 60 * 1000); + }); + + it("flags an active account whose last confirmed conversion is older than the window", () => { + expect(isDormancyCandidate(account(), daysAgo(61), NOW)).toBe(true); + }); + + it("does not flag an account with a recent confirmed conversion", () => { + expect(isDormancyCandidate(account(), daysAgo(59), NOW)).toBe(false); + }); + + it("treats exactly-at-the-window as dormant (inclusive boundary)", () => { + expect(isDormancyCandidate(account(), daysAgo(60), NOW)).toBe(true); + }); + + it("anchors never-converted accounts on their creation date", () => { + expect(isDormancyCandidate(account({ createdAt: daysAgo(61) }), null, NOW)).toBe(true); + expect(isDormancyCandidate(account({ createdAt: daysAgo(10) }), null, NOW)).toBe(false); + }); + + it("never re-flags an account already marked dormant", () => { + expect(isDormancyCandidate(account({ dormantSince: daysAgo(5) }), daysAgo(90), NOW)).toBe(false); + }); + + it("only applies to active accounts (status itself is not changed by the gate)", () => { + for (const status of [ + MoneriumAccountStatus.Onboarding, + MoneriumAccountStatus.Suspended, + MoneriumAccountStatus.Closed + ]) { + expect(isDormancyCandidate(account({ status }), daysAgo(90), NOW)).toBe(false); + } + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/dormancy.ts b/apps/api/src/api/services/monerium-b2b/dormancy.ts new file mode 100644 index 000000000..7ddc10955 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/dormancy.ts @@ -0,0 +1,92 @@ +import { Address } from "viem"; +import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import { forwarderAbi, getGuardianWalletClient, getPublicClient } from "./chain"; + +/** + * Dormancy gate (plan §3, R05): accounts with no successful forward for the dormancy + * window get a protective per-clone guardian pause. The pause can never move funds or + * block the client's fallback paths (contract invariant, plan §2.2); account status + * stays `active` — the pause lives on-chain, `dormant_since` records the detection. + * + * Un-pause is MANUAL for now: guardian ops call setGuardianPaused(false) after the + * partner re-confirms the client relationship — re-confirmation mechanics are a + * partner-agreement item (adr-0005 registry B5). + */ + +/** Dormancy pause window — registry P5 (docs/adr-0005-monerium-b2b-onramp.md). */ +export const DORMANCY_WINDOW_MS = 60 * 24 * 60 * 60 * 1000; + +export interface DormancyAccountFields { + createdAt: Date; + dormantSince: Date | null; + status: MoneriumAccountStatus; +} + +/** + * An account is a dormancy candidate iff it is active, not already flagged, and its + * last confirmed conversion (or, for never-converted accounts, its creation) is at + * least the dormancy window in the past. + */ +export function isDormancyCandidate( + account: DormancyAccountFields, + lastConfirmedAt: Date | null, + now: Date = new Date() +): boolean { + if (account.status !== MoneriumAccountStatus.Active || account.dormantSince !== null) { + return false; + } + const anchor = lastConfirmedAt ?? account.createdAt; + return now.getTime() - anchor.getTime() >= DORMANCY_WINDOW_MS; +} + +async function pauseDormantAccount(account: MoneriumAccount, now: Date): Promise { + const guardian = getGuardianWalletClient(); + if (!guardian) { + // Log-only mode (MONERIUM_B2B_GUARDIAN_PRIVATE_KEY unset): record the detection so + // it is not re-alerted every cycle, but state clearly that no on-chain pause exists. + logger.warn( + `monerium-b2b: account ${account.id} (forwarder ${account.forwarderAddress}) is dormant; ` + + "guardian key not configured — NOT pausing on-chain (log-only mode)" + ); + await account.update({ dormantSince: now }); + return; + } + + const client = getPublicClient(); + const forwarder = account.forwarderAddress as Address; + const { request } = await client.simulateContract({ + abi: forwarderAbi, + account: guardian.account, + address: forwarder, + args: [true], + functionName: "setGuardianPaused" + }); + const hash = await guardian.writeContract({ ...request, chain: null }); + await client.waitForTransactionReceipt({ hash }); + await account.update({ dormantSince: now }); + logger.info(`monerium-b2b: dormancy pause set for account ${account.id} (forwarder ${forwarder}, tx ${hash})`); +} + +/** Runs one dormancy-gate pass over all active, not-yet-flagged accounts. */ +export async function runDormancyGate(now: Date = new Date()): Promise { + const accounts = await MoneriumAccount.findAll({ + where: { dormantSince: null, status: MoneriumAccountStatus.Active } + }); + for (const account of accounts) { + try { + const lastConfirmed = await MoneriumConversionExecution.findOne({ + order: [["created_at", "DESC"]], + where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Confirmed } + }); + if (isDormancyCandidate(account, lastConfirmed?.createdAt ?? null, now)) { + await pauseDormantAccount(account, now); + } + } catch (error) { + logger.error(`monerium-b2b: dormancy gate failed for account ${account.id}:`, error); + } + } +} diff --git a/apps/api/src/api/services/monerium-b2b/feature-gate.test.ts b/apps/api/src/api/services/monerium-b2b/feature-gate.test.ts new file mode 100644 index 000000000..b7c0b2667 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/feature-gate.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import os from "node:os"; +import { shouldStartMoneriumB2bWorker } from "./feature"; + +const expressModuleUrl = new URL("../../../config/express.ts", import.meta.url).href; +const routesModuleUrl = new URL("../../routes/v1/index.ts", import.meta.url).href; + +describe("Monerium B2B feature gate", () => { + it("starts the keeper only for an enabled mykobo process", () => { + expect(shouldStartMoneriumB2bWorker("mykobo", true)).toBe(true); + expect(shouldStartMoneriumB2bWorker("mykobo", false)).toBe(false); + expect(shouldStartMoneriumB2bWorker("monerium", true)).toBe(false); + }); + + it("does not mount the parser, public routes, or admin routes when disabled", async () => { + const script = ` + const { default: app } = await import(${JSON.stringify(expressModuleUrl)}); + const { default: routes } = await import(${JSON.stringify(routesModuleUrl)}); + const matches = path => routes.stack.some(layer => layer.matchers.some(matcher => matcher(path))); + console.log(JSON.stringify({ + adminMounted: matches("/admin/monerium-b2b/accounts"), + moneriumJsonParsers: app.router.stack.filter(layer => layer.name === "jsonParser").length - 1, + publicMounted: matches("/monerium-b2b/account") + })); + `; + const proc = Bun.spawn({ + cmd: [Bun.argv[0], "-e", script], + cwd: os.tmpdir(), + env: { + ADMIN_SECRET: "test-admin-secret", + FLOW_VARIANT: "mykobo", + MONERIUM_B2B_ENABLED: "false", + NODE_ENV: "test", + PATH: process.env.PATH ?? "", + SUPABASE_ANON_KEY: "test-anon-key", + SUPABASE_SERVICE_KEY: "test-service-key", + SUPABASE_URL: "https://example.supabase.co", + WEBHOOK_PRIVATE_KEY: "test-webhook-private-key" + }, + stderr: "pipe", + stdout: "pipe" + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text() + ]); + + const lastLine = stdout.trim().split("\n").at(-1); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(lastLine ? JSON.parse(lastLine) : null).toEqual({ + adminMounted: false, + moneriumJsonParsers: 0, + publicMounted: false + }); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/feature.ts b/apps/api/src/api/services/monerium-b2b/feature.ts new file mode 100644 index 000000000..3c0e232a0 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/feature.ts @@ -0,0 +1,4 @@ +/** Pure startup decision kept testable without importing the side-effectful entrypoint. */ +export function shouldStartMoneriumB2bWorker(flowVariant: string, enabled: boolean): boolean { + return flowVariant === "mykobo" && enabled; +} diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts new file mode 100644 index 000000000..6c20fa588 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts @@ -0,0 +1,270 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { WebhookEventType } from "@vortexfi/shared"; +import { config } from "../../../config/vars"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import Webhook from "../../../models/webhook.model"; +import WebhookDelivery from "../../../models/webhookDelivery.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import { provisionMoneriumB2bAccount } from "./account-provisioning"; +import { NOTIFY_CONFIRMATION_DEPTH } from "./chain"; +import { emitMoneriumDepositEvents } from "./manager-events"; + +const FORWARDER = "0x1111111111111111111111111111111111111111"; +const DESTINATION = "0x2222222222222222222222222222222222222222"; +const FALLBACK = "0x3333333333333333333333333333333333333333"; +const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; + +describe("monerium b2b manager events", () => { + let originalRpcUrl: string | undefined; + + beforeAll(async () => { + originalRpcUrl = config.moneriumB2b.rpcUrl; + config.moneriumB2b.rpcUrl = undefined; + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = originalRpcUrl; + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + async function setupAccountWithWebhook(events: WebhookEventType[]) { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const mapped = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: DESTINATION, + externalSubjectId: "client-1", + fallbackAddress: FALLBACK, + forwarderAddress: FORWARDER, + managerProfileId: manager.id, + moneriumProfileId: MONERIUM_PROFILE + }); + const webhook = + events.length > 0 + ? await Webhook.create({ + events, + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: "https://manager.example.com/hook", + userId: manager.id + }) + : null; + return { managerId: manager.id, mapped, webhook }; + } + + function depsAtBlock(block: bigint | null) { + return { getBlockNumber: async () => block }; + } + + it("emits DEPOSIT_RECEIVED once per minted deposit and never for unattributed rows", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "1000000000000000000", + currency: "eur", + moneriumOrderId: "unattr:1:0xdead:0", + status: MoneriumFiatDepositStatus.Minted + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await emitMoneriumDepositEvents(depsAtBlock(null)); + + const deliveries = await WebhookDelivery.findAll(); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]).toMatchObject({ + eventId: `deposit-received:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_RECEIVED, + webhookId: webhook?.id + }); + expect(deliveries[0].payload).toMatchObject({ + eventType: WebhookEventType.DEPOSIT_RECEIVED, + payload: { + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + depositId: deposit.id, + profileId: mapped.profileId, + status: "minted", + txHash: "0xmint" + } + }); + + await deposit.reload(); + expect(deposit.receivedEventAt).not.toBeNull(); + }); + + it("marks pending events emitted even without subscribers so history never replays", async () => { + const { mapped } = await setupAccountWithWebhook([]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await deposit.reload(); + expect(deposit.receivedEventAt).not.toBeNull(); + expect(await WebhookDelivery.count()).toBe(0); + }); + + it("does not emit DEPOSIT_RECEIVED from a provider claim without chain identity", async () => { + const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + currency: "eur", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xprovider-claim" + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await deposit.reload(); + expect(deposit.receivedEventAt).toBeNull(); + expect(await WebhookDelivery.count()).toBe(0); + }); + + it("emits one aggregate DEPOSIT_CONVERTED only after every allocation reaches confirmation depth", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_CONVERTED]); + const firstExecution = await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1000, + destination: DESTINATION, + eureInRaw: "60000000000000000000", + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xswap1", + usdcNetRaw: "64800000" + }); + const secondExecution = await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1001, + destination: DESTINATION, + eureInRaw: "40000000000000000000", + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xswap2", + usdcNetRaw: "43200000" + }); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 999, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + receivedEventAt: new Date(), + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + await MoneriumDepositAllocation.create({ + depositId: deposit.id, + eureInRaw: "60000000000000000000", + executionId: firstExecution.id, + usdcNetRaw: "64800000" + }); + + // A partially converted deposit must not produce a misleading final event. + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1000 + NOTIFY_CONFIRMATION_DEPTH))); + expect(await WebhookDelivery.count()).toBe(0); + + await MoneriumDepositAllocation.create({ + depositId: deposit.id, + eureInRaw: "40000000000000000000", + executionId: secondExecution.id, + usdcNetRaw: "43200000" + }); + + // One block short of the depth: nothing emitted, marker untouched. + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH - 1))); + expect(await WebhookDelivery.count()).toBe(0); + await deposit.reload(); + expect(deposit.convertedEventAt).toBeNull(); + + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH))); + const deliveries = await WebhookDelivery.findAll(); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]).toMatchObject({ + eventId: `deposit-converted:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_CONVERTED, + webhookId: webhook?.id + }); + expect(deliveries[0].payload).toMatchObject({ + payload: { + conversions: [ + { eureInRaw: "60000000000000000000", executionId: firstExecution.id, txHash: "0xswap1", usdcNetRaw: "64800000" }, + { eureInRaw: "40000000000000000000", executionId: secondExecution.id, txHash: "0xswap2", usdcNetRaw: "43200000" } + ], + depositId: deposit.id, + usdcNetRaw: "108000000" + } + }); + await deposit.reload(); + expect(deposit.convertedEventAt).not.toBeNull(); + + // Replay is a no-op. + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH))); + expect(await WebhookDelivery.count()).toBe(1); + }); + + it("only enqueues to the controlling manager's webhooks", async () => { + const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); + const otherManager = await createTestUser(); + await Webhook.create({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: "https://other.example.com/hook", + userId: otherManager.id + }); + await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + + const deliveries = await WebhookDelivery.findAll({ include: [{ as: "webhook", model: Webhook }] }); + expect(deliveries).toHaveLength(1); + expect((deliveries[0] as WebhookDelivery & { webhook: Webhook }).webhook.url).toBe("https://manager.example.com/hook"); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.ts b/apps/api/src/api/services/monerium-b2b/manager-events.ts new file mode 100644 index 000000000..d50782c8c --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -0,0 +1,195 @@ +import { DepositStatus, type DepositWebhookPayloadBase, WebhookEventType, type WebhookPayload } from "@vortexfi/shared"; +import { Op } from "sequelize"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import ManagedProfile from "../../../models/managedProfile.model"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import webhookService from "../webhook/webhook.service"; +import { enqueueWebhookDeliveries } from "../webhook/webhook-outbox.service"; +import { getPublicClient, NOTIFY_CONFIRMATION_DEPTH } from "./chain"; +import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; + +const BATCH_LIMIT = 100; + +export interface ManagerEventDeps { + /** Current chain head, or null when no read RPC is configured. */ + getBlockNumber(): Promise; +} + +const defaultDeps: ManagerEventDeps = { + async getBlockNumber() { + if (!config.moneriumB2b.rpcUrl) return null; + return getPublicClient().getBlockNumber(); + } +}; + +function depositPayloadBase(deposit: MoneriumFiatDeposit, account: MoneriumAccount): DepositWebhookPayloadBase { + return { + accountId: account.id, + amountRaw: deposit.amountRaw, + currency: deposit.currency, + depositId: deposit.id, + profileId: account.vortexProfileId as string, + status: deposit.status as unknown as DepositStatus, + txHash: deposit.txHash + }; +} + +/** + * Resolves the controlling manager for an account's deposit events. Returns null when + * the account is unmapped or the managed relationship is gone — the event is then + * marked emitted with no deliveries, so history is never replayed to late subscribers. + */ +async function resolveManagerProfileId(account: MoneriumAccount): Promise { + if (!account.vortexProfileId) return null; + const relationship = await ManagedProfile.findOne({ + where: { profileId: account.vortexProfileId, status: "active" } + }); + return relationship?.managerProfileId ?? null; +} + +async function enqueueForManager( + eventType: WebhookEventType, + managerProfileId: string | null, + payload: WebhookPayload +): Promise { + if (!managerProfileId) return 0; + const webhooks = await webhookService.findAccountEventWebhooks(eventType, managerProfileId); + await enqueueWebhookDeliveries(webhooks, payload); + return webhooks.length; +} + +async function emitReceivedEvents(): Promise { + const deposits = await MoneriumFiatDeposit.findAll({ + limit: BATCH_LIMIT, + order: [["created_at", "ASC"]], + where: { + blockNumber: { [Op.ne]: null }, + chainId: { [Op.ne]: null }, + logIndex: { [Op.ne]: null }, + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + receivedEventAt: null, + status: MoneriumFiatDepositStatus.Minted, + txHash: { [Op.ne]: null } + } + }); + + for (const deposit of deposits) { + try { + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) continue; + const managerProfileId = await resolveManagerProfileId(account); + const payload: WebhookPayload = { + eventId: `deposit-received:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_RECEIVED, + payload: depositPayloadBase(deposit, account), + timestamp: new Date().toISOString() + }; + await enqueueForManager(WebhookEventType.DEPOSIT_RECEIVED, managerProfileId, payload); + // Marked emitted even with zero subscribers: webhooks are forward-looking, a + // later registration must not receive the whole history. A crash between the + // enqueue and this marker is absorbed by the outbox (webhook_id, event_id) dedup. + await deposit.update({ receivedEventAt: new Date() }); + } catch (error) { + // Per-deposit isolation: one failing deposit must not block its siblings. + logger.error(`monerium-b2b: DEPOSIT_RECEIVED emission failed for deposit ${deposit.id}:`, error); + } + } +} + +async function emitConvertedEvents(deps: ManagerEventDeps): Promise { + const deposits = await MoneriumFiatDeposit.findAll({ + limit: BATCH_LIMIT, + order: [["created_at", "ASC"]], + where: { + convertedEventAt: null, + id: { [Op.in]: sequelize.literal("(SELECT deposit_id FROM monerium_deposit_allocations)") }, + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + status: MoneriumFiatDepositStatus.Minted + } + }); + if (deposits.length === 0) return; + + const head = await deps.getBlockNumber(); + if (head === null) return; // no read RPC: emit once the chain is configured + + for (const deposit of deposits) { + try { + await emitConvertedEventForDeposit(deposit, head); + } catch (error) { + logger.error(`monerium-b2b: DEPOSIT_CONVERTED emission failed for deposit ${deposit.id}:`, error); + } + } +} + +async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: bigint): Promise { + const allocations = await MoneriumDepositAllocation.findAll({ + order: [["created_at", "ASC"]], + where: { depositId: deposit.id } + }); + if (allocations.length === 0) return; + const allocatedEure = allocations.reduce((sum, allocation) => sum + BigInt(allocation.eureInRaw), 0n); + if (allocatedEure !== BigInt(deposit.amountRaw)) return; + + const executions = await MoneriumConversionExecution.findAll({ + where: { id: allocations.map(allocation => allocation.executionId) } + }); + const executionById = new Map(executions.map(execution => [execution.id, execution])); + if (executions.length !== allocations.length) return; + if (executions.some(execution => execution.status !== MoneriumConversionExecutionStatus.Confirmed)) return; + // Confirmation-depth gate (plan §3, registry P9): only notify once the execution + // blocks are NOTIFY_CONFIRMATION_DEPTH below the head, so a shallow reorg cannot + // produce a delivered-then-vanished aggregate conversion event. + if ( + executions.some( + execution => execution.blockNumber === null || head < BigInt(execution.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH) + ) + ) { + return; + } + + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) return; + const managerProfileId = await resolveManagerProfileId(account); + const payload: WebhookPayload = { + eventId: `deposit-converted:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_CONVERTED, + payload: { + ...depositPayloadBase(deposit, account), + conversions: allocations.map(allocation => { + const execution = executionById.get(allocation.executionId) as MoneriumConversionExecution; + return { + eureInRaw: allocation.eureInRaw, + executionId: execution.id, + txHash: execution.txHash, + usdcNetRaw: allocation.usdcNetRaw + }; + }), + usdcNetRaw: allocations.reduce((sum, allocation) => sum + BigInt(allocation.usdcNetRaw), 0n).toString() + }, + timestamp: new Date().toISOString() + }; + await enqueueForManager(WebhookEventType.DEPOSIT_CONVERTED, managerProfileId, payload); + await deposit.update({ convertedEventAt: new Date() }); +} + +/** + * Emits the manager-facing deposit events into the durable webhook outbox: + * DEPOSIT_RECEIVED once a deposit is minted, DEPOSIT_CONVERTED once every portion is + * allocated and all of its executions are confirmed at notification depth. Emission + * markers make each event fire exactly once regardless of the advancing component. + */ +export async function emitMoneriumDepositEvents(deps: ManagerEventDeps = defaultDeps): Promise { + try { + await emitReceivedEvents(); + await emitConvertedEvents(deps); + } catch (error) { + logger.error("monerium-b2b: manager event emission failed:", error); + } +} diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts new file mode 100644 index 000000000..8578b9f50 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "bun:test"; +import { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { + MatchableDeposit, + matchMintLogToDeposit, + syntheticUnattributedOrderId, + UNATTRIBUTED_ORDER_PREFIX +} from "./mint-watcher"; + +// Mint-log -> deposit matching (plan §3, "mint detection"). Pure decision logic; the +// database write path shares the advisory-locked transaction with the webhook processor. + +const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; + +const TX_A = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +function candidate(overrides: Partial & { id: string }): MatchableDeposit { + return { + amountRaw: (100n * 10n ** 18n).toString(), + logIndex: null, + status: Pending, + txHash: null, + ...overrides + } as MatchableDeposit; +} + +describe("matchMintLogToDeposit", () => { + it("matches by tx hash when the webhook already recorded the mint hash (case-insensitive)", () => { + const amount = 5n; + const deposits = [ + candidate({ id: "other" }), + candidate({ amountRaw: amount.toString(), id: "hash-match", status: Minted, txHash: TX_A.toLowerCase() }) + ]; + const match = matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits); + expect(match?.id).toBe("hash-match"); + }); + + it("rejects a hash match whose on-chain amount disagrees", () => { + const deposits = [candidate({ amountRaw: "5", id: "poisoned", status: Minted, txHash: TX_A })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: 6n }, deposits)).toBeNull(); + }); + + it("quarantines an ambiguous amount match instead of guessing an order", () => { + const amount = 250n * 10n ** 18n; + const deposits = [ + candidate({ amountRaw: (100n * 10n ** 18n).toString(), id: "wrong-amount" }), + candidate({ amountRaw: amount.toString(), id: "older" }), + candidate({ amountRaw: amount.toString(), id: "newer" }) + ]; + const match = matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits); + expect(match).toBeNull(); + }); + + it("returns null when nothing matches (unattributed fallback)", () => { + const deposits = [candidate({ amountRaw: "100", id: "a" })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: 999n }, deposits)).toBeNull(); + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: 999n }, [])).toBeNull(); + }); + + it("never matches deposits that already carry mint fields", () => { + const amount = 100n * 10n ** 18n; + const deposits = [candidate({ amountRaw: amount.toString(), id: "already-recorded", logIndex: 3 })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); + }); + + it("never matches held or returned orders (they have not minted)", () => { + const amount = 100n * 10n ** 18n; + const deposits = [ + candidate({ amountRaw: amount.toString(), id: "held", status: Held }), + candidate({ amountRaw: amount.toString(), id: "returned", status: Returned }) + ]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); + }); + + it("amount-matches a webhook-minted deposit whose order carried no tx hash", () => { + // Monerium can deliver order state "processed" without meta.txHash before the + // watcher reaches the mint block. Requiring Pending here stranded such rows + // without chain identity and recorded the real mint as an unattributed duplicate. + const amount = 100n * 10n ** 18n; + const deposits = [candidate({ amountRaw: amount.toString(), id: "minted-no-hash", status: Minted })]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)?.id).toBe("minted-no-hash"); + }); + + it("never amount-matches a deposit that already carries a different tx hash", () => { + const amount = 100n * 10n ** 18n; + const deposits = [ + candidate({ amountRaw: amount.toString(), id: "other-tx", status: Minted, txHash: "0xBBBB" }) + ]; + expect(matchMintLogToDeposit({ txHash: TX_A, valueRaw: amount }, deposits)).toBeNull(); + }); +}); + +describe("syntheticUnattributedOrderId", () => { + it("is deterministic, flagged, and fits the 64-char order-id column", () => { + const id = syntheticUnattributedOrderId(1, TX_A, 7); + expect(id).toBe(syntheticUnattributedOrderId(1, TX_A.toLowerCase(), 7)); + expect(id.startsWith(UNATTRIBUTED_ORDER_PREFIX)).toBe(true); + expect(id.length).toBeLessThanOrEqual(64); + }); + + it("differs per chain, transaction, and log index", () => { + const base = syntheticUnattributedOrderId(1, TX_A, 7); + expect(syntheticUnattributedOrderId(2, TX_A, 7)).not.toBe(base); + expect(syntheticUnattributedOrderId(1, TX_A, 8)).not.toBe(base); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts new file mode 100644 index 000000000..c5c793f3a --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts @@ -0,0 +1,231 @@ +import crypto from "crypto"; +import { Op } from "sequelize"; +import { Address } from "viem"; +import logger from "../../../config/logger"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumChainCursor from "../../../models/moneriumChainCursor.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import { eureTransferEvent, getChainId, getForwarderImmutables, getPublicClient } from "./chain"; +import { withForwarderLock } from "./deposit-processor"; + +/** + * Poll-based EURe Transfer watcher (plan §3, "Keeper: mint detection"): scans a + * persisted-cursor block range for Transfers TO known forwarder addresses (from any + * sender), matches each mint to its pending Monerium order, and flags non-Monerium + * inflows as unattributed (R09: flagged, not treated as a customer deposit claim). + */ + +/** Upper bound on blocks scanned per cycle, so getLogs stays bounded after downtime. */ +const MAX_BLOCK_RANGE = 2000n; + +// Confirmation lag before a block is scanned: deep enough that Ethereum reorgs past it +// are effectively unheard of, shallow enough to add well under a keeper cycle of delay. +const REORG_SAFETY_DEPTH = 12n; + +/** Order-id prefix marking a deposit row created from a mint log with no matching Monerium order. */ +export const UNATTRIBUTED_ORDER_PREFIX = "unattr:"; + +/** + * Deterministic synthetic order id for an unattributed mint (monerium_order_id is NOT + * NULL UNIQUE, max 64 chars — a raw tx hash would not fit alongside a prefix). + */ +export function syntheticUnattributedOrderId(chainId: number, txHash: string, logIndex: number): string { + const digest = crypto.createHash("sha256").update(`${chainId}:${txHash.toLowerCase()}:${logIndex}`).digest("hex"); + return `${UNATTRIBUTED_ORDER_PREFIX}${digest.slice(0, 56)}`; +} + +export interface MintLogFields { + txHash: string; + valueRaw: bigint; +} + +export type MatchableDeposit = Pick; + +/** + * Picks the deposit row a mint log belongs to, among the account's deposits that are + * still missing their mint fields (logIndex null). Precedence: + * 1. tx-hash AND amount match — a hash with a conflicting amount is a poisoned + * provider claim and the chain value is recorded separately as unattributed; + * 2. amount match on any open order without recorded chain identity — oldest first + * (caller passes createdAt order). A webhook-Minted row whose order carried no + * txHash is still a candidate here: requiring Pending would strand it and record + * its real mint as an unattributed duplicate. + * Held/returned orders never minted, so they are not candidates. Returns null when + * nothing matches (unattributed inflow). + */ +export function matchMintLogToDeposit(log: MintLogFields, candidates: MatchableDeposit[]): MatchableDeposit | null { + const open = candidates.filter( + deposit => + deposit.logIndex === null && + (deposit.status === MoneriumFiatDepositStatus.Pending || deposit.status === MoneriumFiatDepositStatus.Minted) + ); + const byHash = open.find(deposit => deposit.txHash !== null && deposit.txHash.toLowerCase() === log.txHash.toLowerCase()); + if (byHash) { + return BigInt(byHash.amountRaw) === log.valueRaw ? byHash : null; + } + const byAmount = open.filter(deposit => deposit.txHash === null && BigInt(deposit.amountRaw) === log.valueRaw); + return byAmount.length === 1 ? byAmount[0] : null; +} + +interface ObservedMint { + blockHash: string; + blockNumber: number; + logIndex: number; + to: Address; + txHash: string; + valueRaw: bigint; +} + +async function recordMint( + mint: ObservedMint, + chainId: number, + accountsByForwarder: Map +): Promise { + const account = accountsByForwarder.get(mint.to.toLowerCase()); + if (!account) { + // getLogs was filtered to known forwarders, so this only happens on a race with + // account archival; nothing to record against. + return null; + } + + return withForwarderLock(account.forwarderAddress, async transaction => { + // Idempotency: the (chain_id, tx_hash, log_index) partial unique index is the + // on-chain identity; a re-scan after a crash must not double-record. + const alreadyRecorded = await MoneriumFiatDeposit.findOne({ + transaction, + where: { chainId, logIndex: mint.logIndex, txHash: mint.txHash } + }); + if (alreadyRecorded) { + return null; + } + + const candidates = await MoneriumFiatDeposit.findAll({ + order: [["created_at", "ASC"]], + transaction, + where: { accountId: account.id, logIndex: null } + }); + const mismatchedHashClaim = candidates.find( + deposit => + deposit.logIndex === null && + deposit.txHash?.toLowerCase() === mint.txHash.toLowerCase() && + BigInt(deposit.amountRaw) !== mint.valueRaw + ); + const match = matchMintLogToDeposit({ txHash: mint.txHash, valueRaw: mint.valueRaw }, candidates); + + if (match) { + const deposit = candidates.find(row => row.id === match.id) as MoneriumFiatDeposit; + await deposit.update( + { + blockHash: mint.blockHash, + blockNumber: mint.blockNumber, + chainId, + logIndex: mint.logIndex, + // Forward-only: pending -> minted; a webhook-minted row just gains chain fields. + ...(deposit.status === MoneriumFiatDepositStatus.Pending ? { status: MoneriumFiatDepositStatus.Minted } : {}), + txHash: mint.txHash + }, + { transaction } + ); + } else { + if (mismatchedHashClaim) { + logger.error( + `monerium-b2b: refusing mismatched mint ${mint.txHash}#${mint.logIndex}: chain value ${mint.valueRaw.toString()} ` + + `disagrees with webhook amount ${mismatchedHashClaim.amountRaw} on deposit ${mismatchedHashClaim.id}` + ); + } + // R09-adjacent: EURe arrived without a matching Monerium order (direct transfer, + // or the order webhook has not landed yet). Record it flagged as unattributed so + // the balance stays accounted for; it is never presented as a customer deposit. + logger.warn( + `monerium-b2b: unattributed EURe mint ${mint.txHash}#${mint.logIndex} of ${mint.valueRaw.toString()} raw to forwarder ${account.forwarderAddress}` + ); + await MoneriumFiatDeposit.create( + { + accountId: account.id, + amountRaw: mint.valueRaw.toString(), + blockHash: mint.blockHash, + blockNumber: mint.blockNumber, + chainId, + currency: "eur", + logIndex: mint.logIndex, + moneriumOrderId: syntheticUnattributedOrderId(chainId, mint.txHash, mint.logIndex), + status: MoneriumFiatDepositStatus.Minted, + txHash: mint.txHash + }, + { transaction } + ); + } + return account.id; + }); +} + +/** + * Runs one watcher cycle. Returns the ids of accounts that received new mints, so the + * worker can enqueue conversion for them immediately. + */ +export async function runMintWatcher(): Promise { + const accounts = await MoneriumAccount.findAll({ + where: { status: { [Op.ne]: MoneriumAccountStatus.Closed } } + }); + if (accounts.length === 0) { + return []; + } + const accountsByForwarder = new Map(accounts.map(account => [account.forwarderAddress.toLowerCase(), account])); + + const client = getPublicClient(); + const chainId = await getChainId(); + const { eure } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const latest = await client.getBlockNumber(); + // Only scan settled blocks: the (chain_id, tx_hash, log_index) identity is not + // reorg-stable (logIndex and blockNumber change when a dropped tx re-mines), so a + // head-chasing scan could double-record one mint across a shallow reorg. + const safeHead = latest > REORG_SAFETY_DEPTH ? latest - REORG_SAFETY_DEPTH : 0n; + + const cursorName = `eure-mints:${chainId}`; + const cursor = await MoneriumChainCursor.findByPk(cursorName); + if (!cursor) { + // Bootstrap: start watching from the current settled head. Historic mints are + // covered by webhook-recorded orders; back-filling chain fields is manual. + await MoneriumChainCursor.create({ lastBlock: safeHead.toString(), name: cursorName }); + return []; + } + + const fromBlock = BigInt(cursor.lastBlock) + 1n; + if (fromBlock > safeHead) { + return []; + } + const toBlock = safeHead - fromBlock + 1n > MAX_BLOCK_RANGE ? fromBlock + MAX_BLOCK_RANGE - 1n : safeHead; + + const logs = await client.getLogs({ + address: eure, + args: { to: accounts.map(account => account.forwarderAddress as Address) }, + event: eureTransferEvent, + fromBlock, + toBlock + }); + + const touchedAccounts = new Set(); + for (const log of logs) { + if (log.blockHash === null || log.blockNumber === null || log.transactionHash === null || log.logIndex === null) { + continue; // pending log — will be picked up once mined (cursor only advances over mined ranges) + } + const accountId = await recordMint( + { + blockHash: log.blockHash, + blockNumber: Number(log.blockNumber), + logIndex: log.logIndex, + to: log.args.to as Address, + txHash: log.transactionHash, + valueRaw: log.args.value as bigint + }, + chainId, + accountsByForwarder + ); + if (accountId) { + touchedAccounts.add(accountId); + } + } + + await cursor.update({ lastBlock: toBlock.toString() }); + return [...touchedAccounts]; +} diff --git a/apps/api/src/api/services/monerium-b2b/monerium-api.test.ts b/apps/api/src/api/services/monerium-b2b/monerium-api.test.ts new file mode 100644 index 000000000..8c7b7080d --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monerium-api.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import type { MoneriumAddress, MoneriumIban } from "@vortexfi/shared"; +import { selectAccountIban, selectProfileChainAddresses } from "./monerium-api"; + +const ADDRESS = "0x1111111111111111111111111111111111111111"; +const PROFILE = "11111111-1111-4111-8111-111111111111"; +const OTHER_PROFILE = "22222222-2222-4222-8222-222222222222"; + +function iban(overrides: Partial): MoneriumIban { + return { + address: ADDRESS, + bic: "AIBKIE2D", + chain: "ethereum", + iban: "EE08 7224 5745 6244 9516", + name: "Client Ltd", + profile: PROFILE, + ...overrides + }; +} + +describe("Monerium B2B provider scope selection", () => { + it("selects an IBAN only for the exact profile, chain, and address", () => { + const correct = iban({}); + const ibans = [ + iban({ chain: "sepolia", iban: "EE52 1273 8426 8857 1285" }), + iban({ iban: "EE24 2200 2210 2014 5685", profile: OTHER_PROFILE }), + correct + ]; + + expect(selectAccountIban(ibans, ADDRESS.toLowerCase(), "ethereum", PROFILE)).toBe(correct); + expect(selectAccountIban(ibans, ADDRESS, "sepolia", OTHER_PROFILE)).toBeNull(); + }); + + it("refuses an ambiguous exact IBAN match", () => { + expect(() => selectAccountIban([iban({}), iban({ iban: "EE52 1273 8426 8857 1285" })], ADDRESS, "ethereum", PROFILE)).toThrow( + "Multiple Monerium IBANs matched" + ); + }); + + it("keeps only addresses linked to the expected profile and chain", () => { + const entries: MoneriumAddress[] = [ + { address: ADDRESS, chains: ["sepolia"], profile: PROFILE }, + { address: ADDRESS, chains: ["ethereum"], profile: OTHER_PROFILE }, + { address: ADDRESS.toLowerCase(), chains: ["ethereum"], profile: PROFILE } + ]; + + expect(selectProfileChainAddresses(entries, PROFILE, "ethereum")).toEqual([ADDRESS.toLowerCase()]); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/monerium-api.ts b/apps/api/src/api/services/monerium-b2b/monerium-api.ts new file mode 100644 index 000000000..519025fd5 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monerium-api.ts @@ -0,0 +1,89 @@ +import { + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + type MoneriumAddress, + MoneriumApiService, + type MoneriumChain, + type MoneriumIban +} from "@vortexfi/shared"; + +/** + * Narrow view of the shared Monerium white-label client (`@vortexfi/shared` + * `MoneriumApiService`) — the single Monerium transport in the repo. Only the + * operations the B2B onramp needs; auth, timeouts, wire-schema validation, and + * response redaction live in the shared client + * (docs/security-spec/05-integrations/monerium.md). + */ + +/** The shared client reads these directly; callers gate on this before touching it. */ +export function isWhitelabelConfigured(): boolean { + return Boolean(process.env.MONERIUM_WHITELABEL_CLIENT_ID && process.env.MONERIUM_WHITELABEL_CLIENT_SECRET); +} + +/** + * POST /addresses — links a forwarder address to a profile using the attestor's + * EIP-1271-verifiable signature over the fixed link message (see ./attestor.ts). + * The signature bytes pass through unchanged (shared-client invariant 8). + */ +export async function linkAddress( + profileId: string, + address: string, + chain: MoneriumChain, + signature: string +): Promise { + return MoneriumApiService.getInstance().linkAddress({ + address, + chain, + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: profileId, + signature + }); +} + +/** POST /ibans — requests IBAN issuance for a linked address (202 provisioning, 304 already issued). */ +export async function requestIban(address: string, chain: MoneriumChain): Promise { + return MoneriumApiService.getInstance().requestIban({ address, chain }); +} + +/** GET /ibans — all IBANs visible to the partner context (association monitor + lookups). */ +export async function listIbans(): Promise { + return (await MoneriumApiService.getInstance().listIbans()).ibans; +} + +/** Exact account-scoped IBAN match; never guesses across chain/profile duplicates. */ +export function selectAccountIban( + ibans: MoneriumIban[], + address: string, + chain: MoneriumChain, + profileId: string +): MoneriumIban | null { + const matches = ibans.filter( + entry => entry.address.toLowerCase() === address.toLowerCase() && entry.chain === chain && entry.profile === profileId + ); + if (matches.length > 1) { + throw new Error(`Multiple Monerium IBANs matched ${profileId}:${chain}:${address.toLowerCase()}`); + } + return matches[0] ?? null; +} + +/** GET /ibans — the IBAN issued for this exact profile/chain/address tuple. */ +export async function getIbanForAddress( + address: string, + chain: MoneriumChain, + profileId: string +): Promise { + return selectAccountIban(await listIbans(), address, chain, profileId); +} + +/** + * GET /addresses?profile={id} — the addresses linked to a profile. Used by the + * association monitor (S1 detective control): any address linked to a client profile + * beyond the forwarder is an alert condition. + */ +export function selectProfileChainAddresses(addresses: MoneriumAddress[], profileId: string, chain: MoneriumChain): string[] { + return addresses.filter(entry => entry.profile === profileId && entry.chains.includes(chain)).map(entry => entry.address); +} + +export async function getProfileAddresses(profileId: string, chain: MoneriumChain): Promise { + const response = await MoneriumApiService.getInstance().listAddresses({ profile: profileId }); + return selectProfileChainAddresses(response.addresses, profileId, chain); +} diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts new file mode 100644 index 000000000..c8eaec395 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import { + classifyStranding, + computeQuoteImpactBps, + detectConfigDrift, + diffAssociation, + eip1167RuntimeCode, + normalizeIban, + STRANDED_WARN_MS +} from "./monitoring"; + +// Pure monitoring logic (implementation plan D3): quote-impact math against the T6 +// liquidity baseline, stranding severity, association-diff detection (S1 detective +// control) and config-drift classification (R07). No chain or API involved. + +const EUR = 10n ** 18n; +const USDC = 10n ** 6n; + +describe("computeQuoteImpactBps", () => { + // T6 baseline (registry, mainnet block 25553101): Chainlink 1.14410, QuoterV2 + // 10k EURe -> 1.14278 USDC/EURe. Impact vs oracle: (11441 - 11427.8) / 11441 = 11.5 bps. + const CHAINLINK_EUR_USD = 114410000n; // 8 decimals + + it("matches the T6 baseline impact at 10k EURe", () => { + const amountIn = 10_000n * EUR; + const quoted = 11_427_800_000n; // 10_000 * 1.14278 in USDC 6dp + expect(computeQuoteImpactBps(amountIn, quoted, CHAINLINK_EUR_USD, 8)).toBe(11); + }); + + it("returns 0 for a quote exactly at the oracle rate", () => { + const amountIn = 1_000n * EUR; + const quoted = 1_144_100_000n; // 1_000 * 1.14410 + expect(computeQuoteImpactBps(amountIn, quoted, CHAINLINK_EUR_USD, 8)).toBe(0); + }); + + it("is negative when the quote beats the oracle", () => { + const amountIn = 1_000n * EUR; + expect(computeQuoteImpactBps(amountIn, 1_150n * USDC, CHAINLINK_EUR_USD, 8)).toBeLessThan(0); + }); + + it("flags a pause-threshold breach above SLIPPAGE_BPS", () => { + const amountIn = 25n * EUR; // minSwapAmount placeholder (registry P6) + const expectedOut = (amountIn * CHAINLINK_EUR_USD) / 10n ** 20n; + const quoted = (expectedOut * 9_850n) / 10_000n; // 150 bps impact + expect(computeQuoteImpactBps(amountIn, quoted, CHAINLINK_EUR_USD, 8)).toBeGreaterThan(100); // > P1 SLIPPAGE_BPS + }); + + it("handles a zero-ish expected output without dividing by zero", () => { + expect(computeQuoteImpactBps(0n, 0n, CHAINLINK_EUR_USD, 8)).toBe(0); + }); +}); + +describe("classifyStranding", () => { + const TRIGGER_DELAY = 86_400n; // 24h, registry P4 placeholder + const now = 1_800_000_000_000; // fixed epoch ms + + const armedAt = (msAgo: number): bigint => BigInt(Math.floor((now - msAgo) / 1000)); + + it("is ok when the marker is not armed", () => { + expect(classifyStranding(0n, TRIGGER_DELAY, now)).toBe("ok"); + }); + + it("is ok within the warn window", () => { + expect(classifyStranding(armedAt(60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("ok"); + }); + + it("warns after 12h", () => { + expect(classifyStranding(armedAt(STRANDED_WARN_MS + 60_000), TRIGGER_DELAY, now)).toBe("warn"); + }); + + it("errors past TRIGGER_DELAY", () => { + expect(classifyStranding(armedAt(25 * 60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("error"); + }); +}); + +describe("diffAssociation", () => { + const FORWARDER = "0xD7444AB7270A142227Fe659D63873ABdc8AF9b72"; + const IBAN = "EE08 7224 5745 6244 9516"; + const db = { forwarderAddress: FORWARDER, iban: IBAN }; + + it("reports no changes when the live state matches (case- and space-insensitively)", () => { + const live = { + ibans: [{ address: FORWARDER.toLowerCase(), iban: "ee08722457456244 9516" }], + profileAddresses: [FORWARDER.toLowerCase()] + }; + expect(diffAssociation(db, live)).toEqual([]); + }); + + it("detects the forwarder being unlinked", () => { + const changes = diffAssociation(db, { ibans: [{ address: FORWARDER, iban: IBAN }], profileAddresses: [] }); + expect(changes).toContain(`forwarder ${FORWARDER} is no longer linked to the profile`); + }); + + it("detects a new address linked to the profile", () => { + const intruder = "0x9999999999999999999999999999999999999999"; + const changes = diffAssociation(db, { + ibans: [{ address: FORWARDER, iban: IBAN }], + profileAddresses: [FORWARDER, intruder] + }); + expect(changes).toEqual([`unexpected address linked to the profile: ${intruder}`]); + }); + + it("detects the IBAN moving to another address (PATCH /ibans scenario)", () => { + const elsewhere = "0x8888888888888888888888888888888888888888"; + const changes = diffAssociation(db, { + ibans: [{ address: elsewhere, iban: IBAN }], + profileAddresses: [FORWARDER] + }); + expect(changes).toEqual([`IBAN ${IBAN} moved to address ${elsewhere}`]); + }); + + it("detects the IBAN disappearing", () => { + const changes = diffAssociation(db, { ibans: [], profileAddresses: [FORWARDER] }); + expect(changes).toEqual([`IBAN ${IBAN} no longer exists at Monerium`]); + }); + + it("detects an unrecorded IBAN on the forwarder", () => { + const other = "DE89370400440532013000"; + const changes = diffAssociation( + { forwarderAddress: FORWARDER, iban: null }, + { ibans: [{ address: FORWARDER, iban: other }], profileAddresses: [FORWARDER] } + ); + expect(changes).toEqual([`unrecorded IBAN issued for the forwarder: ${other}`]); + }); +}); + +describe("normalizeIban", () => { + it("strips whitespace and uppercases", () => { + expect(normalizeIban(" ee08 7224 5745\t6244 9516 ")).toBe("EE087224574562449516"); + }); +}); + +describe("detectConfigDrift", () => { + const base = { + destination: "0x1111111111111111111111111111111111111111", + fallbackAddress: "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", + feeBps: 0 + }; + + it("reports nothing when the chain matches the db (case-insensitively)", () => { + const onchain = { ...base, destination: base.destination.toLowerCase() }; + expect(detectConfigDrift(base, onchain)).toEqual({ errors: [], ownerAuthorizedUpdates: {} }); + }); + + it("classifies destination/fallback changes as owner-authorized updates (R07)", () => { + const onchain = { + ...base, + destination: "0x4444444444444444444444444444444444444444", + fallbackAddress: "0x5555555555555555555555555555555555555555" + }; + const drift = detectConfigDrift(base, onchain); + expect(drift.errors).toEqual([]); + expect(drift.ownerAuthorizedUpdates).toEqual({ + destination: onchain.destination, + fallbackAddress: onchain.fallbackAddress + }); + }); + + it("classifies a feeBps change as a guardian-authorized reconciliation (P11)", () => { + const drift = detectConfigDrift(base, { ...base, feeBps: 50 }); + expect(drift.errors).toEqual([]); + expect(drift.ownerAuthorizedUpdates.feeBps).toBe(50); + }); +}); + +describe("eip1167RuntimeCode", () => { + it("produces the canonical minimal-proxy runtime code for an implementation", () => { + expect(eip1167RuntimeCode("0x7e1c653CaAFCa44258d8680B09F42a33475504a9")).toBe( + "0x363d3d373d3d3d363d737e1c653caafca44258d8680b09f42a33475504a95af43d82803e903d91602b57fd5bf3" + ); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts new file mode 100644 index 000000000..f0df89938 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -0,0 +1,504 @@ +import { Op } from "sequelize"; +import { Address, encodePacked, Hex, parseAbi } from "viem"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import { + erc20Abi, + factoryAbi, + forwarderAbi, + getChainId, + getForwarderImmutables, + getPublicClient, + moneriumChainForChainId +} from "./chain"; +import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; + +/** + * Monitoring pass for the Monerium B2B onramp (implementation plan D3 / phase 3), run + * from the keeper worker. Four read-only monitors, alerting via the standard logger: + * + * 1. Executable-depth check (main PRD §7.4, T6 follow-up): QuoterV2 static quote on the + * pinned EURe->EURC->USDC path at perSwapCap and minSwapAmount sizes vs the + * Chainlink EUR/USD rate. Impact above SLIPPAGE_BPS at minSwapAmount size is the + * PAUSE THRESHOLD (error-level -> engage guardian pause per the incident runbook); + * at perSwapCap size it is an early warning. Mainnet-only (QuoterV2 pin). + * 2. Stranded-balance monitor: forwarders whose on-chain stranding marker (R03) has + * been armed for more than STRANDED_WARN_MS warn; past TRIGGER_DELAY (the + * permissionless-trigger delay, registry P4) they error — the keeper should have + * converted long before either. + * 3. Association monitor (S1 detective control, trust model in the b2b-variant doc): + * re-reads the linked-address and IBAN state from the Monerium API per active + * account and alerts on ANY divergence from the DB record (IBAN moved, new address + * linked). Vortex holds the whitelabel credentials, so association changes cannot + * be prevented client-side — only detected. + * 4. Config reconciliation (manifest re-verification, R07): re-reads per-clone config + * and clone bytecode. destination/fallbackAddress changes are owner-authorized by + * construction (`onlyFallback` in the contract) — they are reconciled into the DB + * and logged, not alarmed. feeBps/bytecode/registration drift is an incident. + * + * None of these monitors hold keys or send transactions; they are detection-only. + */ + +/** Uniswap V3 QuoterV2 on Ethereum mainnet (the pinned quoting contract, PRD §7.4). */ +export const MAINNET_QUOTER_V2: Address = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"; + +/** Stranding marker armed longer than this warns (the keeper converts within minutes normally). */ +export const STRANDED_WARN_MS = 12 * 60 * 60 * 1000; + +/** Full monitoring pass at most this often (the worker cycles every minute). */ +const MONITORING_INTERVAL_MS = 30 * 60_000; + +const quoterV2Abi = parseAbi([ + "function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)" +]); + +const chainlinkAbi = parseAbi([ + "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)" +]); + +// Read-only getters beyond the keeper ABI surface in ./chain.ts. +const forwarderMonitoringAbi = parseAbi([ + "function destination() view returns (address)", + "function fallbackAddress() view returns (address)", + "function feeBps() view returns (uint16)", + "function EURC() view returns (address)", + "function USDC() view returns (address)", + "function ORACLE() view returns (address)", + "function ORACLE_DECIMALS() view returns (uint8)", + "function SLIPPAGE_BPS() view returns (uint16)", + "function TRIGGER_DELAY() view returns (uint256)", + "function POOL_FEE_EURE_EURC() view returns (uint24)", + "function POOL_FEE_EURC_USDC() view returns (uint24)" +]); + +const factoryMonitoringAbi = parseAbi([ + "function implementation() view returns (address)", + "function isForwarder(address forwarder) view returns (bool)" +]); + +// ------------------------------------------------------------------ pure logic + +/** + * Price impact of an executable quote vs the Chainlink EUR/USD rate, in bps (floored; + * negative when the quote beats the oracle). Same scaling as VortexForwarder._minOut + * without the slippage haircut: EURe 18 dp in, USDC 6 dp out. + */ +export function computeQuoteImpactBps( + amountInRaw: bigint, + quotedOutRaw: bigint, + oracleAnswer: bigint, + oracleDecimals: number +): number { + const expectedOut = (amountInRaw * oracleAnswer) / 10n ** BigInt(12 + oracleDecimals); + if (expectedOut <= 0n) { + return 0; + } + return Number(((expectedOut - quotedOutRaw) * 10_000n) / expectedOut); +} + +export type StrandingSeverity = "error" | "ok" | "warn"; + +/** + * Severity of an armed stranding marker (R03): older than TRIGGER_DELAY (the + * permissionless-trigger delay) is an error; older than STRANDED_WARN_MS a warning. + */ +export function classifyStranding(strandedSinceSec: bigint, triggerDelaySec: bigint, nowMs: number): StrandingSeverity { + if (strandedSinceSec === 0n) { + return "ok"; + } + const armedMs = nowMs - Number(strandedSinceSec) * 1000; + if (armedMs >= Number(triggerDelaySec) * 1000) { + return "error"; + } + if (armedMs >= STRANDED_WARN_MS) { + return "warn"; + } + return "ok"; +} + +export interface AssociationDbRecord { + forwarderAddress: string; + iban: string | null; +} + +export interface LiveAssociationState { + /** All IBANs visible to the partner context: { iban, address } pairs. */ + ibans: { address: string; iban: string }[]; + /** Addresses linked to this account's profile. */ + profileAddresses: string[]; +} + +export function normalizeIban(iban: string): string { + return iban.replace(/\s+/g, "").toUpperCase(); +} + +/** + * Detects ANY divergence between the DB association record and the live Monerium + * state (S1/PATCH-ibans detective control): forwarder unlinked, extra addresses on + * the profile, the IBAN moved to another address, or an IBAN we did not record. + */ +export function diffAssociation(db: AssociationDbRecord, live: LiveAssociationState): string[] { + const changes: string[] = []; + const forwarder = db.forwarderAddress.toLowerCase(); + + if (!live.profileAddresses.some(address => address.toLowerCase() === forwarder)) { + changes.push(`forwarder ${db.forwarderAddress} is no longer linked to the profile`); + } + for (const address of live.profileAddresses) { + if (address.toLowerCase() !== forwarder) { + changes.push(`unexpected address linked to the profile: ${address}`); + } + } + + const dbIban = db.iban ? normalizeIban(db.iban) : null; + if (dbIban) { + const entry = live.ibans.find(candidate => normalizeIban(candidate.iban) === dbIban); + if (!entry) { + changes.push(`IBAN ${db.iban} no longer exists at Monerium`); + } else if (entry.address.toLowerCase() !== forwarder) { + changes.push(`IBAN ${db.iban} moved to address ${entry.address}`); + } + } + for (const entry of live.ibans) { + if (entry.address.toLowerCase() === forwarder && normalizeIban(entry.iban) !== dbIban) { + changes.push(`unrecorded IBAN issued for the forwarder: ${entry.iban}`); + } + } + return changes; +} + +export interface ForwarderConfigRecord { + destination: string; + fallbackAddress: string; + feeBps: number; +} + +export interface ConfigDriftResult { + /** Immutable-config violations — should be impossible; alarm, never reconcile. */ + errors: string[]; + /** Authorized on-chain transitions — reconcile the DB: destination/fallbackAddress + * change only via the client's own key (R07), feeBps only via the guardian's + * timelocked setter (P11); both leave an on-chain event trail. */ + ownerAuthorizedUpdates: Partial>; +} + +/** + * Classifies drift between the DB config record and on-chain clone state. + * destination/fallbackAddress are mutable ONLY by the client's fallbackAddress + * (`onlyFallback`) and feeBps ONLY by the guardian's timelocked setter (P11), so any + * change in those is an expected authorized transition to reconcile; everything else + * (bytecode, registration) is immutable and a change there is an incident. + */ +export function detectConfigDrift(db: ForwarderConfigRecord, onchain: ForwarderConfigRecord): ConfigDriftResult { + const result: ConfigDriftResult = { errors: [], ownerAuthorizedUpdates: {} }; + if (db.feeBps !== onchain.feeBps) { + result.ownerAuthorizedUpdates.feeBps = onchain.feeBps; + } + if (db.destination.toLowerCase() !== onchain.destination.toLowerCase()) { + result.ownerAuthorizedUpdates.destination = onchain.destination; + } + if (db.fallbackAddress.toLowerCase() !== onchain.fallbackAddress.toLowerCase()) { + result.ownerAuthorizedUpdates.fallbackAddress = onchain.fallbackAddress; + } + return result; +} + +/** Runtime code of a standard EIP-1167 minimal proxy pointing at `implementation`. */ +export function eip1167RuntimeCode(implementation: Address): Hex { + return `0x363d3d373d3d3d363d73${implementation.slice(2).toLowerCase()}5af43d82803e903d91602b57fd5bf3` as Hex; +} + +// ------------------------------------------------------------------ monitor runners + +async function monitoredAccounts(statuses: MoneriumAccountStatus[]): Promise { + return MoneriumAccount.findAll({ where: { status: { [Op.in]: statuses } } }); +} + +/** + * Executable-depth check (PRD §7.4): QuoterV2 static quotes at minSwapAmount and + * perSwapCap on the pinned path vs Chainlink. Runs only against Ethereum mainnet — + * MAINNET_QUOTER_V2 is a mainnet pin. + */ +export async function runExecutableDepthCheck(): Promise { + if ((await getChainId()) !== 1) { + return; + } + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const client = getPublicClient(); + const forwarder = accounts[0].forwarderAddress as Address; + const { eure, factory } = await getForwarderImmutables(forwarder); + const [eurc, usdc, oracle, oracleDecimals, slippageBps, poolFeeEureEurc, poolFeeEurcUsdc, minSwapAmount, perSwapCap] = + await Promise.all([ + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "EURC" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "USDC" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "ORACLE" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "ORACLE_DECIMALS" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "SLIPPAGE_BPS" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "POOL_FEE_EURE_EURC" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "POOL_FEE_EURC_USDC" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }) + ]); + + const [, answer, , updatedAt] = await client.readContract({ + abi: chainlinkAbi, + address: oracle, + functionName: "latestRoundData" + }); + if (answer <= 0n) { + logger.error(`monerium-b2b: depth check aborted — Chainlink EUR/USD answered ${answer}`); + return; + } + + const path = encodePacked( + ["address", "uint24", "address", "uint24", "address"], + [eure, poolFeeEureEurc, eurc, poolFeeEurcUsdc, usdc] + ); + const quote = async (amountIn: bigint): Promise => { + const { result } = await client.simulateContract({ + abi: quoterV2Abi, + address: MAINNET_QUOTER_V2, + args: [path, amountIn], + functionName: "quoteExactInput" + }); + return result[0]; + }; + + const [minOut, capOut] = await Promise.all([quote(minSwapAmount), quote(perSwapCap)]); + const minImpactBps = computeQuoteImpactBps(minSwapAmount, minOut, answer, Number(oracleDecimals)); + const capImpactBps = computeQuoteImpactBps(perSwapCap, capOut, answer, Number(oracleDecimals)); + const detail = + `oracle=${answer} (updatedAt=${updatedAt}), minSwapAmount=${minSwapAmount} -> ${minOut} (${minImpactBps} bps), ` + + `perSwapCap=${perSwapCap} -> ${capOut} (${capImpactBps} bps), SLIPPAGE_BPS=${slippageBps}`; + + if (minImpactBps > slippageBps) { + // PAUSE THRESHOLD (PRD §7.4): even minimum-size swaps would revert on minOut. + logger.error( + "monerium-b2b: PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS; engage guardian pause per " + + `docs/operations-monerium-b2b-runbook.md. ${detail}` + ); + } else if (capImpactBps > slippageBps) { + logger.warn(`monerium-b2b: executable depth below perSwapCap — cap-sized swaps would revert on minOut. ${detail}`); + } else { + logger.info(`monerium-b2b: depth check ok. ${detail}`); + } +} + +/** Stranded-balance monitor: armed R03 markers older than 12h warn, older than TRIGGER_DELAY error. */ +export async function runStrandedBalanceMonitor(now: number = Date.now()): Promise { + const accounts = await monitoredAccounts([ + MoneriumAccountStatus.Onboarding, + MoneriumAccountStatus.Active, + MoneriumAccountStatus.Suspended + ]); + if (accounts.length === 0) { + return; + } + const client = getPublicClient(); + const { factory } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const [minSwapFloor, triggerDelay] = await Promise.all([ + client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }), + client.readContract({ + abi: forwarderMonitoringAbi, + address: accounts[0].forwarderAddress as Address, + functionName: "TRIGGER_DELAY" + }) + ]); + + for (const account of accounts) { + try { + const forwarder = account.forwarderAddress as Address; + const { eure } = await getForwarderImmutables(forwarder); + const [balance, strandedSince] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }) + ]); + if (balance < minSwapFloor) { + continue; + } + const severity = classifyStranding(strandedSince, triggerDelay, now); + if (severity === "ok") { + continue; + } + const hours = Math.floor((now - Number(strandedSince) * 1000) / 3_600_000); + const message = + `monerium-b2b: stranded EURe on forwarder ${forwarder} (account ${account.id}): balance=${balance}, ` + + `marker armed ${hours}h ago${severity === "error" ? " — past TRIGGER_DELAY, permissionless trigger is live" : ""}`; + if (severity === "error") { + logger.error(message); + } else { + logger.warn(message); + } + } catch (error) { + // Per-account isolation like the sibling monitors: one failing read must not + // hide stranding on every other account. + logger.warn(`monerium-b2b: stranded-balance check failed for account ${account.id}:`, error); + } + } +} + +/** + * Association monitor (S1 detective control): compares the Monerium-side linked + * addresses + IBAN state per active account against the DB record and alerts on ANY + * change. Error-level: an unexplained association change is an incident trigger + * (docs/operations-monerium-b2b-runbook.md). + */ +export async function runAssociationMonitor(): Promise { + const accounts = await monitoredAccounts([MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const chainId = await getChainId(); + const chainName = moneriumChainForChainId(chainId); + if (!chainName) { + logger.error(`monerium-b2b: association monitor has no Monerium chain name for chain id ${chainId}`); + return; + } + const allIbans = await listIbans(); + for (const account of accounts) { + try { + const ibans = allIbans + .filter(entry => entry.chain === chainName && entry.profile === account.profileId) + .map(entry => ({ address: entry.address, iban: entry.iban })); + const profileAddresses = await getProfileAddresses(account.profileId, chainName); + const changes = diffAssociation( + { forwarderAddress: account.forwarderAddress, iban: account.iban }, + { ibans, profileAddresses } + ); + if (changes.length > 0) { + logger.error( + `monerium-b2b: ASSOCIATION CHANGE for account ${account.id} (profile ${account.profileId}): ${changes.join("; ")}` + ); + } + } catch (error) { + logger.warn(`monerium-b2b: association monitor failed for account ${account.id}:`, error); + } + } +} + +/** + * Config reconciliation (manifest re-verification pass, R07): re-checks per-clone + * state against the DB. Owner-authorized destination/fallback changes are reconciled + * (DB update + configVersion bump), immutable violations are alarmed. + */ +export async function runConfigReconciliation(): Promise { + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const client = getPublicClient(); + const trustedFactory = config.moneriumB2b.forwarderFactoryAddress; + if (!trustedFactory) { + logger.error("monerium-b2b: config reconciliation skipped — trusted forwarder factory is not configured"); + return; + } + const implementationByFactory = new Map(); + + for (const account of accounts) { + try { + const forwarder = account.forwarderAddress as Address; + const { factory } = await getForwarderImmutables(forwarder); + if (factory.toLowerCase() !== trustedFactory.toLowerCase()) { + logger.error( + `monerium-b2b: forwarder ${forwarder} (account ${account.id}) reports untrusted factory ${factory}; ` + + `expected ${trustedFactory}` + ); + continue; + } + const trustedFactoryAddress = trustedFactory as Address; + let implementation = implementationByFactory.get(trustedFactory.toLowerCase()); + if (!implementation) { + implementation = await client.readContract({ + abi: factoryMonitoringAbi, + address: trustedFactoryAddress, + functionName: "implementation" + }); + implementationByFactory.set(trustedFactory.toLowerCase(), implementation); + } + + const [destination, fallbackAddress, feeBps, isForwarder, code] = await Promise.all([ + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "destination" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "fallbackAddress" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "feeBps" }), + client.readContract({ + abi: factoryMonitoringAbi, + address: trustedFactoryAddress, + args: [forwarder], + functionName: "isForwarder" + }), + client.getCode({ address: forwarder }) + ]); + + if (!isForwarder) { + logger.error( + `monerium-b2b: forwarder ${forwarder} (account ${account.id}) is not registered on trusted factory ${trustedFactory}` + ); + } + if ((code ?? "0x").toLowerCase() !== eip1167RuntimeCode(implementation).toLowerCase()) { + logger.error( + `monerium-b2b: forwarder ${forwarder} (account ${account.id}) bytecode is not the EIP-1167 clone of ${implementation}` + ); + } + + const drift = detectConfigDrift( + { destination: account.destination, fallbackAddress: account.fallbackAddress, feeBps: account.feeBps }, + { destination, fallbackAddress, feeBps: Number(feeBps) } + ); + for (const error of drift.errors) { + logger.error(`monerium-b2b: config violation on forwarder ${forwarder} (account ${account.id}): ${error}`); + } + if (Object.keys(drift.ownerAuthorizedUpdates).length > 0) { + // Authorized transition: destination/fallback change only via the client's + // fallbackAddress (R07), feeBps only via the guardian's timelocked setter + // (P11) — reconcile, do not alarm. + await account.update({ ...drift.ownerAuthorizedUpdates, configVersion: account.configVersion + 1 }); + logger.warn( + `monerium-b2b: reconciled owner-authorized config change on forwarder ${forwarder} (account ${account.id}): ` + + `${JSON.stringify(drift.ownerAuthorizedUpdates)} (configVersion -> ${account.configVersion})` + ); + } + } catch (error) { + logger.warn(`monerium-b2b: config reconciliation failed for account ${account.id}:`, error); + } + } +} + +// ------------------------------------------------------------------ pass orchestration + +let lastPassAt = 0; + +export function resetMonitoringStateForTests(): void { + lastPassAt = 0; +} + +async function guarded(name: string, run: () => Promise): Promise { + try { + await run(); + } catch (error) { + logger.error(`monerium-b2b: ${name} failed:`, error); + } +} + +/** + * Runs the monitors at most every MONITORING_INTERVAL_MS. Chain monitors need only + * MONERIUM_B2B_RPC_URL (no keys); the association monitor needs the whitelabel API + * credentials. Each monitor is skipped, never fatal, when its config is absent. + */ +export async function runMonitoringPass(now: number = Date.now()): Promise { + if (now - lastPassAt < MONITORING_INTERVAL_MS) { + return; + } + lastPassAt = now; + if (config.moneriumB2b.rpcUrl) { + await guarded("executable-depth check", runExecutableDepthCheck); + await guarded("stranded-balance monitor", () => runStrandedBalanceMonitor(now)); + await guarded("config reconciliation", runConfigReconciliation); + } + if (isWhitelabelConfigured()) { + await guarded("association monitor", runAssociationMonitor); + } +} diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts new file mode 100644 index 000000000..c3dbcfc67 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -0,0 +1,317 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { config } from "../../../config/vars"; +import FinancialOperation from "../../../models/financialOperation.model"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import { provisionManagedProfile } from "../managed-profile-provisioning.service"; +import { processMoneriumWebhookInbox } from "./deposit-processor"; +import { advanceOnboardingAccounts, type OnboardingDeps } from "./onboarding"; + +const FORWARDER = "0x1111111111111111111111111111111111111111"; +const DESTINATION = "0x2222222222222222222222222222222222222222"; +const FALLBACK = "0x3333333333333333333333333333333333333333"; +const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; +const IBAN = "EE08 7224 5745 6244 9516"; +const ETHEREUM_CHAIN = { getChainId: async () => 1 }; + +const savedConfig = { ...config.moneriumB2b }; + +interface FakeDeps extends OnboardingDeps { + calls: { getChainId: number; getIbanForAddress: number; getProfileAddresses: number; linkAddress: unknown[][]; requestIban: unknown[][]; signLinkAttestation: unknown[][] }; + ibanByAddress: Map; + linkedAddresses: Set; +} + +function fakeDeps(): FakeDeps { + const deps: FakeDeps = { + calls: { getChainId: 0, getIbanForAddress: 0, getProfileAddresses: 0, linkAddress: [], requestIban: [], signLinkAttestation: [] }, + async getChainId() { + deps.calls.getChainId += 1; + return 1; + }, + async getIbanForAddress(address: string) { + deps.calls.getIbanForAddress += 1; + const iban = deps.ibanByAddress.get(address.toLowerCase()); + return iban ? { iban } : null; + }, + async getProfileAddresses() { + deps.calls.getProfileAddresses += 1; + return [...deps.linkedAddresses]; + }, + ibanByAddress: new Map(), + async linkAddress(...args: unknown[]) { + deps.calls.linkAddress.push(args); + return {}; + }, + linkedAddresses: new Set(), + async requestIban(...args: unknown[]) { + deps.calls.requestIban.push(args); + return {}; + }, + async signLinkAttestation(...args: unknown[]) { + deps.calls.signLinkAttestation.push(args); + return { signature: "0xattestor-signature" }; + } + }; + return deps; +} + +async function createMappedAccount(overrides: Partial[0]> = {}): Promise { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const child = await provisionManagedProfile({ + contactEmail: `client-${crypto.randomUUID()}@example.com`, + creationSource: "vortex", + customerType: "business", + externalSubjectId: crypto.randomUUID(), + managerProfileId: manager.id + }); + return MoneriumAccount.create({ + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: FORWARDER, + profileId: MONERIUM_PROFILE, + vortexProfileId: child.profileId, + ...overrides + }); +} + +describe("monerium b2b onboarding automation", () => { + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.moneriumB2b.attestorPrivateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + config.moneriumB2b.rpcUrl = "http://rpc.invalid"; + // MONERIUM_WHITELABEL_CLIENT_ID/SECRET come from test-utils/preload.ts. + }); + + afterAll(() => { + Object.assign(config.moneriumB2b, savedConfig); + }); + + it("links the forwarder and requests an IBAN through the financial-operation ledger", async () => { + const account = await createMappedAccount(); + const deps = fakeDeps(); + + expect(await advanceOnboardingAccounts(deps)).toBe(1); + + expect(deps.calls.signLinkAttestation).toEqual([[1n, FORWARDER]]); + expect(deps.calls.linkAddress).toEqual([[MONERIUM_PROFILE, FORWARDER, "ethereum", "0xattestor-signature"]]); + expect(deps.calls.requestIban).toEqual([[FORWARDER, "ethereum"]]); + + const ownerProfileId = account.vortexProfileId as string; + const operations = await FinancialOperation.findAll({ order: [["phase", "ASC"]] }); + expect(operations.map(op => ({ phase: op.phase, scopeId: op.scopeId, scopeType: op.scopeType, status: op.status }))).toEqual([ + { phase: "linkAddress", scopeId: ownerProfileId, scopeType: "profile", status: "confirmed" }, + { phase: "requestIban", scopeId: ownerProfileId, scopeType: "profile", status: "confirmed" } + ]); + }); + + it("never repeats a claimed provider write on replay", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + + await advanceOnboardingAccounts(deps); + // Simulate the next cycle before the link/IBAN become visible upstream. + await advanceOnboardingAccounts(deps); + + expect(deps.calls.linkAddress).toHaveLength(1); + expect(deps.calls.requestIban).toHaveLength(1); + }); + + it("skips the link call when the forwarder is already linked upstream", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + deps.linkedAddresses.add(FORWARDER); + + await advanceOnboardingAccounts(deps); + + expect(deps.calls.linkAddress).toHaveLength(0); + expect(deps.calls.requestIban).toHaveLength(1); + }); + + it("records the IBAN once issuance completes", async () => { + const account = await createMappedAccount(); + const deps = fakeDeps(); + deps.linkedAddresses.add(FORWARDER); + deps.ibanByAddress.set(FORWARDER, IBAN); + + await advanceOnboardingAccounts(deps); + + await account.reload(); + expect(account.iban).toBe(IBAN); + expect(deps.calls.requestIban).toHaveLength(0); + }); + + it("only advances mapped accounts still in onboarding", async () => { + await createMappedAccount({ status: MoneriumAccountStatus.Active }); + await MoneriumAccount.create({ + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: "0x9999999999999999999999999999999999999999", + profileId: crypto.randomUUID() + // no vortexProfileId: pre-mapping row stays operator-managed + }); + const deps = fakeDeps(); + + expect(await advanceOnboardingAccounts(deps)).toBe(0); + expect(deps.calls.getChainId).toBe(0); + expect(deps.calls.linkAddress).toHaveLength(0); + }); + + it("retries a failed link next cycle without wedging the ledger row", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + let failNext = true; + const workingLinkAddress = deps.linkAddress.bind(deps); + deps.linkAddress = async (profileId, address, chain, signature) => { + if (failNext) { + failNext = false; + throw new Error("Monerium request failed"); + } + return workingLinkAddress(profileId, address, chain, signature); + }; + + // First cycle: the link call fails with no side effect; the row must not be + // parked in a state that requires manual reconciliation. + await advanceOnboardingAccounts(deps); + const afterFailure = await FinancialOperation.findOne({ where: { phase: "linkAddress" } }); + expect(afterFailure?.status).toBe("failed"); + + // Second cycle: clean retry performs the call again and confirms. + await advanceOnboardingAccounts(deps); + const afterRetry = await FinancialOperation.findOne({ where: { phase: "linkAddress" } }); + expect(afterRetry?.status).toBe("confirmed"); + expect(deps.calls.linkAddress).toHaveLength(1); + }); + + it("reconciles an interrupted link from upstream state instead of re-posting", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + deps.linkAddress = async () => { + // The POST landed upstream but the response was lost mid-flight. + deps.linkedAddresses.add(FORWARDER); + throw new Error("socket hang up"); + }; + + await advanceOnboardingAccounts(deps); + + const operation = await FinancialOperation.findOne({ where: { phase: "linkAddress" } }); + expect(operation?.status).toBe("confirmed"); + expect(deps.calls.signLinkAttestation).toHaveLength(1); + }); + + it("retries a failed iban request next cycle", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + deps.linkedAddresses.add(FORWARDER); + let failNext = true; + const workingRequestIban = deps.requestIban.bind(deps); + deps.requestIban = async (address, chain) => { + if (failNext) { + failNext = false; + throw new Error("Monerium request failed"); + } + return workingRequestIban(address, chain); + }; + + await advanceOnboardingAccounts(deps); + expect((await FinancialOperation.findOne({ where: { phase: "requestIban" } }))?.status).toBe("failed"); + + await advanceOnboardingAccounts(deps); + expect((await FinancialOperation.findOne({ where: { phase: "requestIban" } }))?.status).toBe("confirmed"); + expect(deps.calls.requestIban).toHaveLength(1); + }); + + it("does nothing while the whitelabel credentials are not configured", async () => { + await createMappedAccount(); + const savedClientId = process.env.MONERIUM_WHITELABEL_CLIENT_ID; + process.env.MONERIUM_WHITELABEL_CLIENT_ID = ""; + const deps = fakeDeps(); + + try { + expect(await advanceOnboardingAccounts(deps)).toBe(0); + expect(deps.calls.getProfileAddresses).toBe(0); + } finally { + process.env.MONERIUM_WHITELABEL_CLIENT_ID = savedClientId; + } + }); +}); + +describe("iban.updated inbox recording", () => { + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + it("records the issued IBAN on the account and never overwrites a different one", async () => { + const account = await createMappedAccount(); + await MoneriumWebhookEvent.create({ + eventId: "evt-iban-1", + payload: { + data: { address: FORWARDER, chain: "ethereum", iban: IBAN, profile: MONERIUM_PROFILE }, + timestamp: "2026-08-25T12:00:00Z", + type: "iban.updated" + } + }); + + await processMoneriumWebhookInbox(ETHEREUM_CHAIN); + await account.reload(); + expect(account.iban).toBe(IBAN); + + // A later iban.updated with a different IBAN is an alert condition, not data. + await MoneriumWebhookEvent.create({ + eventId: "evt-iban-2", + payload: { + data: { + address: FORWARDER, + chain: "ethereum", + iban: "EE00 0000 0000 0000 0000", + profile: MONERIUM_PROFILE + }, + timestamp: "2026-08-25T12:00:00Z", + type: "iban.updated" + } + }); + await processMoneriumWebhookInbox(ETHEREUM_CHAIN); + await account.reload(); + expect(account.iban).toBe(IBAN); + + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + }); + + it("acks iban events for unknown forwarders without failing the drain", async () => { + await MoneriumWebhookEvent.create({ + eventId: "evt-iban-3", + payload: { + data: { + address: "0x8888888888888888888888888888888888888888", + chain: "ethereum", + iban: IBAN, + profile: MONERIUM_PROFILE + }, + timestamp: "2026-08-25T12:00:00Z", + type: "iban.updated" + } + }); + + expect(await processMoneriumWebhookInbox(ETHEREUM_CHAIN)).toBe(1); + expect(await MoneriumWebhookEvent.count({ where: { processedAt: null } })).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.ts b/apps/api/src/api/services/monerium-b2b/onboarding.ts new file mode 100644 index 000000000..8e3738e3e --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/onboarding.ts @@ -0,0 +1,175 @@ +import type { MoneriumChain } from "@vortexfi/shared"; +import { Op } from "sequelize"; +import type { Address } from "viem"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import { FinancialOperationRejectedError, runFinancialOperation } from "../phases/blocks/core/financial-operation"; +import { signLinkAttestation } from "./attestor"; +import { getChainId, moneriumChainForChainId } from "./chain"; +import { getIbanForAddress, getProfileAddresses, isWhitelabelConfigured, linkAddress, requestIban } from "./monerium-api"; + +const ONBOARDING_FLOW = { id: "monerium-b2b-onboarding", version: 1 } as const; + +export interface OnboardingDeps { + getChainId(): Promise; + getIbanForAddress(address: string, chain: MoneriumChain, profileId: string): Promise<{ iban: string } | null>; + getProfileAddresses(profileId: string, chain: MoneriumChain): Promise; + linkAddress(profileId: string, address: string, chain: MoneriumChain, signature: string): Promise; + requestIban(address: string, chain: MoneriumChain): Promise; + signLinkAttestation(chainId: bigint, forwarderAddress: Address): Promise<{ signature: string }>; +} + +const defaultDeps: OnboardingDeps = { + getChainId, + getIbanForAddress, + getProfileAddresses, + linkAddress, + requestIban, + signLinkAttestation +}; + +export function isOnboardingConfigured(): boolean { + const { attestorPrivateKey, rpcUrl } = config.moneriumB2b; + return Boolean(attestorPrivateKey && rpcUrl && isWhitelabelConfigured()); +} + +let configWarned = false; + +async function isForwarderLinked( + deps: OnboardingDeps, + moneriumProfileId: string, + forwarderAddress: string, + chainName: MoneriumChain +): Promise { + const forwarderKey = forwarderAddress.toLowerCase(); + const addresses = await deps.getProfileAddresses(moneriumProfileId, chainName); + return addresses.some(address => address.toLowerCase() === forwarderKey); +} + +async function ensureLinked( + deps: OnboardingDeps, + account: MoneriumAccount, + chainId: number, + chainName: MoneriumChain +): Promise { + if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) return; + await runFinancialOperation({ + attemptClass: "provider-address-link", + flow: ONBOARDING_FLOW, + perform: async () => { + const attestation = await deps.signLinkAttestation(BigInt(chainId), account.forwarderAddress as Address); + try { + await deps.linkAddress(account.profileId, account.forwarderAddress, chainName, attestation.signature); + } catch (error) { + // Linking is synchronous upstream: if the address is not linked after a + // failure, the call had no side effect — signal that so the ledger allows a + // clean retry next cycle instead of parking the row in `unknown` forever. + if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) { + return { linked: true }; + } + throw new FinancialOperationRejectedError( + `link call failed with no side effect: ${error instanceof Error ? error.message : String(error)}` + ); + } + return { linked: true }; + }, + phase: "linkAddress", + provider: "monerium", + // A crash between the POST and its confirmation resolves by re-reading the + // profile's linked addresses instead of issuing a second link call. + reconcile: async () => + (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) ? { linked: true } : null, + request: { address: account.forwarderAddress.toLowerCase(), chain: chainName, moneriumProfileId: account.profileId }, + retryFailed: true, + // vortexProfileId is non-null for every account this loop selects. + scopeId: account.vortexProfileId as string, + scopeType: "profile" + }); +} + +async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainName: MoneriumChain): Promise { + if (account.iban) return; + const issued = await deps.getIbanForAddress(account.forwarderAddress, chainName, account.profileId); + if (issued) { + await account.update({ iban: issued.iban }); + return; + } + await runFinancialOperation({ + attemptClass: "provider-iban-request", + flow: ONBOARDING_FLOW, + perform: async () => { + try { + await deps.requestIban(account.forwarderAddress, chainName); + } catch (error) { + // IBAN issuance is unique per (address, chain), so a repeated request can + // never create a second IBAN — a failed call is safe to retry next cycle. + // If the request did land, the reconcile read adopts the issued IBAN anyway. + throw new FinancialOperationRejectedError( + `iban request failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + return { requested: true }; + }, + phase: "requestIban", + provider: "monerium", + reconcile: async () => + (await deps.getIbanForAddress(account.forwarderAddress, chainName, account.profileId)) ? { requested: true } : null, + request: { address: account.forwarderAddress.toLowerCase(), chain: chainName }, + retryFailed: true, + scopeId: account.vortexProfileId as string, + scopeType: "profile" + }); + // Issuance is asynchronous: the IBAN is recorded by the iban.updated webhook or + // by the read at the top of the next cycle. +} + +/** + * Advances every mapped account still in onboarding: links its forwarder to the + * Monerium profile with the attestor signature, then requests IBAN issuance. Both + * provider writes run through the profile-scoped financial-operation ledger, so a + * crash or retry never repeats a claimed call. Activation (after the penny test) + * stays a manual operator step. + */ +export async function advanceOnboardingAccounts(deps: OnboardingDeps = defaultDeps): Promise { + if (!isOnboardingConfigured()) { + if (!configWarned) { + configWarned = true; + logger.warn( + "monerium-b2b: onboarding automation disabled — requires MONERIUM_WHITELABEL_CLIENT_ID/SECRET, MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, and MONERIUM_B2B_RPC_URL" + ); + } + return 0; + } + + const accounts = await MoneriumAccount.findAll({ + order: [["created_at", "ASC"]], + where: { status: MoneriumAccountStatus.Onboarding, vortexProfileId: { [Op.ne]: null } } + }); + if (accounts.length === 0) return 0; + + const chainId = await deps.getChainId(); + const chainName = moneriumChainForChainId(chainId); + if (!chainName) { + logger.error(`monerium-b2b: no Monerium chain name known for chain id ${chainId}; onboarding automation halted`); + return 0; + } + + let advanced = 0; + for (const account of accounts) { + try { + await ensureLinked(deps, account, chainId, chainName); + await ensureIban(deps, account, chainName); + advanced += 1; + } catch (error) { + // The next cycle retries; the financial-operation ledger keeps provider + // writes exactly-once across retries. + logger.error(`monerium-b2b: onboarding advance failed for account ${account.id}:`, error); + } + } + return advanced; +} + +export function resetOnboardingWarningForTests(): void { + configWarned = false; +} diff --git a/apps/api/src/api/services/monerium-b2b/webhook.test.ts b/apps/api/src/api/services/monerium-b2b/webhook.test.ts new file mode 100644 index 000000000..1982de76c --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/webhook.test.ts @@ -0,0 +1,198 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import crypto from "crypto"; +// 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. +import * as webhookEventNamespace from "../../../models/moneriumWebhookEvent.model"; +import * as depositProcessorNamespace from "./deposit-processor"; + +const webhookEventReal = { ...webhookEventNamespace }; +const depositProcessorReal = { ...depositProcessorNamespace }; + +const callOrder: string[] = []; +const bulkCreate = mock(async (_rows: unknown, _options: unknown) => { + callOrder.push("insert"); + return []; +}); +const processInbox = mock(async () => 0); + +mock.module("../../../models/moneriumWebhookEvent.model", () => ({ + default: { bulkCreate } +})); +mock.module("./deposit-processor", () => ({ + ...depositProcessorReal, + processMoneriumWebhookInbox: processInbox +})); + +let webhook: typeof import("./webhook"); +let controller: typeof import("../../controllers/monerium-b2b.controller"); +let config: typeof import("../../../config/vars").config; + +const SECRET = `whsec_${Buffer.from("01234567890123456789012345678901", "utf8").toString("base64")}`; +const WEBHOOK_ID = "msg_2LhLhM4Q6YwqZ1fX"; +const WEBHOOK_TIMESTAMP = "1789142400"; + +function sign( + rawBody: Buffer, + secret = SECRET, + webhookId = WEBHOOK_ID, + webhookTimestamp = WEBHOOK_TIMESTAMP +): string { + const key = Buffer.from(secret.slice("whsec_".length), "base64"); + const signedPayload = Buffer.concat([Buffer.from(`${webhookId}.${webhookTimestamp}.`, "utf8"), rawBody]); + return `v1,${crypto.createHmac("sha256", key).update(signedPayload).digest("base64")}`; +} + +function mockRequest( + rawBody: Buffer | undefined, + signature: string | undefined, + webhookId: string | undefined = WEBHOOK_ID, + webhookTimestamp: string | undefined = WEBHOOK_TIMESTAMP +): never { + return { + header: (name: string) => { + if (name.toLowerCase() === "webhook-id") return webhookId; + if (name.toLowerCase() === "webhook-timestamp") return webhookTimestamp; + if (name.toLowerCase() === "webhook-signature") return signature; + return undefined; + }, + rawBody + } as never; +} + +function mockResponse(): { json: ReturnType; status: ReturnType } { + const res = { + json: mock((_value: unknown) => { + callOrder.push("respond"); + return res; + }), + status: mock((_code: number) => res) + }; + return res; +} + +async function flushSetImmediate(): Promise { + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); +} + +beforeAll(async () => { + webhook = await import("./webhook"); + controller = await import("../../controllers/monerium-b2b.controller"); + ({ config } = await import("../../../config/vars")); +}); + +beforeEach(() => { + config.moneriumB2b.webhookSecret = SECRET; + bulkCreate.mockClear(); + processInbox.mockClear(); + callOrder.length = 0; +}); + +afterAll(() => { + mock.module("../../../models/moneriumWebhookEvent.model", () => ({ ...webhookEventReal })); + mock.module("./deposit-processor", () => ({ ...depositProcessorReal })); + mock.restore(); +}); + +describe("verifyWebhookSignature", () => { + const body = Buffer.from(JSON.stringify({ data: { id: "order-1" }, type: "order.updated" }), "utf8"); + + it("accepts the documented Monerium v1 signature", () => { + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body), SECRET)).toBe(true); + }); + + it("rejects tampered signed components and malformed credentials", () => { + const otherSecret = `whsec_${Buffer.from("other-secret", "utf8").toString("base64")}`; + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body, otherSecret), SECRET)).toBe(false); + expect( + webhook.verifyWebhookSignature(Buffer.concat([body, Buffer.from(" ")]), WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body), SECRET) + ).toBe(false); + expect(webhook.verifyWebhookSignature(body, `${WEBHOOK_ID}-tampered`, WEBHOOK_TIMESTAMP, sign(body), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, `${WEBHOOK_TIMESTAMP}1`, sign(body), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, undefined, WEBHOOK_TIMESTAMP, sign(body), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, undefined, sign(body), SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, undefined, SECRET)).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body), "test-webhook-secret")).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body), "whsec_not-base64")).toBe(false); + expect(webhook.verifyWebhookSignature(body, WEBHOOK_ID, WEBHOOK_TIMESTAMP, sign(body).replace("v1,", "v2,"), SECRET)).toBe( + false + ); + }); +}); + +describe("recordWebhookEvent", () => { + it("inserts with on-conflict-do-nothing dedup semantics", async () => { + await webhook.recordWebhookEvent("evt-1", { type: "order.updated" }); + expect(bulkCreate).toHaveBeenCalledTimes(1); + const [rows, options] = bulkCreate.mock.calls[0] as [unknown, unknown]; + expect(rows).toEqual([{ eventId: "evt-1", payload: { type: "order.updated" } }]); + expect(options).toEqual({ ignoreDuplicates: true }); + }); +}); + +describe("POST /v1/monerium-b2b/webhook controller", () => { + const payload = { data: { id: "order-1" }, timestamp: "2026-07-17T00:00:00Z", type: "order.updated" }; + const rawBody = Buffer.from(JSON.stringify(payload), "utf8"); + + it("persists the delivery durably before responding 200 and processes async", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody)), res as never, next as never); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(bulkCreate).toHaveBeenCalledTimes(1); + // Durable insert strictly precedes the 200 (R06). + expect(callOrder).toEqual(["insert", "respond"]); + + await flushSetImmediate(); + expect(processInbox).toHaveBeenCalledTimes(1); + }); + + it("rejects an invalid signature with 401 and never touches the inbox", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + const wrongSecret = `whsec_${Buffer.from("wrong-secret", "utf8").toString("base64")}`; + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody, wrongSecret)), res as never, next as never); + + expect(next).toHaveBeenCalledTimes(1); + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 401 }); + expect(bulkCreate).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects when the raw body was not captured", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(undefined, sign(rawBody)), res as never, next as never); + + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 401 }); + expect(bulkCreate).not.toHaveBeenCalled(); + }); + + it("responds 503 when the webhook secret is not configured", async () => { + config.moneriumB2b.webhookSecret = ""; + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody)), res as never, next as never); + + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 503 }); + expect(bulkCreate).not.toHaveBeenCalled(); + }); + + it("acks a redelivery with 200 (insert is a dedup no-op)", async () => { + const res = mockResponse(); + const next = mock((_error: unknown) => undefined); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody)), res as never, next as never); + await controller.handleWebhook(mockRequest(rawBody, sign(rawBody)), res as never, next as never); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenNthCalledWith(2, 200); + // Same event id both times — the unique index makes the second insert a no-op. + const firstRows = bulkCreate.mock.calls[0]?.[0] as Array<{ eventId: string }>; + const secondRows = bulkCreate.mock.calls[1]?.[0] as Array<{ eventId: string }>; + expect(firstRows[0].eventId).toBe(WEBHOOK_ID); + expect(secondRows[0].eventId).toBe(WEBHOOK_ID); + await flushSetImmediate(); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/webhook.ts b/apps/api/src/api/services/monerium-b2b/webhook.ts new file mode 100644 index 000000000..6480deb80 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/webhook.ts @@ -0,0 +1,58 @@ +import crypto from "crypto"; +import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; + +/** + * Monerium B2B webhook authentication + durable inbox (plan §3, R06). + * + * Monerium signs `${webhook-id}.${webhook-timestamp}.${rawBody}` with the base64-decoded + * bytes after the `whsec_` prefix. The signature header is `v1,`. + * Raw bytes are load-bearing: parsing and re-serialising JSON changes the MAC input. + */ + +export const MONERIUM_ID_HEADER = "webhook-id"; +export const MONERIUM_SIGNATURE_HEADER = "webhook-signature"; +export const MONERIUM_TIMESTAMP_HEADER = "webhook-timestamp"; + +function constantTimeEquals(a: Buffer, b: Buffer): boolean { + if (a.length !== b.length) { + // Compare against self to keep timing independent of the mismatch position. + crypto.timingSafeEqual(a, a); + return false; + } + return crypto.timingSafeEqual(a, b); +} + +function decodeBase64(value: string, minBytes: number, maxBytes: number): Buffer | null { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null; + const decoded = Buffer.from(value, "base64"); + return decoded.length >= minBytes && decoded.length <= maxBytes ? decoded : null; +} + +export function verifyWebhookSignature( + rawBody: Buffer, + webhookId: string | undefined, + webhookTimestamp: string | undefined, + signatureHeader: string | undefined, + secret: string +): boolean { + if (!webhookId || !webhookTimestamp || !signatureHeader || !secret.startsWith("whsec_")) return false; + if (webhookId.length > 128) return false; + + const secretBytes = decodeBase64(secret.slice("whsec_".length), 24, 64); + const signatureMatch = /^v1,([A-Za-z0-9+/]+={0,2})$/.exec(signatureHeader.trim()); + const provided = signatureMatch ? decodeBase64(signatureMatch[1], 32, 32) : null; + if (!secretBytes || !provided) return false; + + const signedPayload = Buffer.concat([Buffer.from(`${webhookId}.${webhookTimestamp}.`, "utf8"), rawBody]); + const expected = crypto.createHmac("sha256", secretBytes).update(signedPayload).digest(); + return constantTimeEquals(provided, expected); +} + +/** + * Durably persists a delivery BEFORE the webhook responds 200. `ignoreDuplicates` + * compiles to `ON CONFLICT DO NOTHING` on the unique event_id — a redelivery is a + * silent no-op, and the caller still acks with 200. + */ +export async function recordWebhookEvent(eventId: string, payload: unknown): Promise { + await MoneriumWebhookEvent.bulkCreate([{ eventId, payload }], { ignoreDuplicates: true }); +} diff --git a/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts b/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts index 8c15fc65d..82d59f24f 100644 --- a/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts +++ b/apps/api/src/api/services/webhook/__tests__/webhook.service.test.ts @@ -381,6 +381,125 @@ describe('WebhookService', () => { }); }); + describe('registerWebhook (deposit events)', () => { + it('rejects deposit-event registration while Monerium B2B is disabled', async () => { + const disabledService = new WebhookService(false); + + const error = await disabledService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + url: 'https://example.com/webhook' + }, USER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); + expect((error as APIError).message).toContain('disabled'); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('registers an account-scoped deposit webhook without a quote or session', async () => { + const mockWebhook = createMockWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED], + partnerId: null, + quoteId: null, + userId: 'user-1' + }); + createMock.mockResolvedValue(mockWebhook); + + await webhookService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED], + url: 'https://example.com/webhook' + }, USER_OWNER); + + expect(quoteTicketFindByPkMock).not.toHaveBeenCalled(); + expect(createMock).toHaveBeenCalledWith({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED], + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: 'https://example.com/webhook', + userId: 'user-1' + }); + }); + + it('rejects mixing deposit events with transaction events', async () => { + const error = await webhookService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.STATUS_CHANGE], + url: 'https://example.com/webhook' + }, USER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('rejects a deposit webhook carrying a quoteId or sessionId', async () => { + for (const target of [{ quoteId: 'quote-123' }, { sessionId: 'session-1' }]) { + const error = await webhookService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + url: 'https://example.com/webhook', + ...target + }, USER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); + } + expect(createMock).not.toHaveBeenCalled(); + }); + + it('rejects a deposit webhook for a partner-scoped credential', async () => { + // Delivery resolves the account's controlling manager by profile, so a + // partner-owned row could never match — refuse it up front. + const error = await webhookService.registerWebhook({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + url: 'https://example.com/webhook' + }, PARTNER_OWNER).then( + () => { throw new Error('registerWebhook did not reject'); }, + e => e + ); + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(400); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('never defaults omitted events into the deposit family', async () => { + quoteTicketFindByPkMock.mockResolvedValue(createMockQuote()); + createMock.mockResolvedValue(createMockWebhook()); + + await webhookService.registerWebhook({ + url: 'https://example.com/webhook', + quoteId: 'quote-123' + }, PARTNER_OWNER); + + expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ + events: [WebhookEventType.TRANSACTION_CREATED, WebhookEventType.STATUS_CHANGE] + })); + }); + }); + + describe('findAccountEventWebhooks', () => { + it('filters by owner profile, event type, and active flag', async () => { + findAllMock.mockResolvedValue([]); + + await webhookService.findAccountEventWebhooks(WebhookEventType.DEPOSIT_RECEIVED, 'manager-1'); + + expect(findAllMock).toHaveBeenCalledWith({ + where: { + events: { [Op.contains]: [WebhookEventType.DEPOSIT_RECEIVED] }, + isActive: true, + userId: 'manager-1' + } + }); + }); + }); + describe('deleteWebhook', () => { it('should delete an existing webhook owned by the caller', async () => { // Mock data diff --git a/apps/api/src/api/services/webhook/webhook-delivery.service.ts b/apps/api/src/api/services/webhook/webhook-delivery.service.ts index b39cbc7f6..b097a9bf4 100644 --- a/apps/api/src/api/services/webhook/webhook-delivery.service.ts +++ b/apps/api/src/api/services/webhook/webhook-delivery.service.ts @@ -24,7 +24,12 @@ export class WebhookDeliveryService { return TransactionStatus.PENDING; } - private async deliverWebhook(webhook: Webhook, payload: WebhookPayload, attempt = 1): Promise { + /** + * One signed delivery attempt with the SSRF re-resolution guard. Shared by the + * legacy in-process retry loop and the durable outbox dispatcher, which owns its + * own retry/backoff bookkeeping. + */ + public async deliverSingleAttempt(webhook: Webhook, payload: WebhookPayload): Promise<{ ok: boolean; error: string | null }> { try { // Re-resolved on every attempt so a DNS record cannot be re-pointed at internal // infrastructure after registration (SSRF guard). @@ -59,16 +64,22 @@ export class WebhookDeliveryService { clearTimeout(timeoutId); if (response.ok) { - logger.info(`Webhook delivered successfully: ${webhook.id} to ${webhook.url} (attempt ${attempt})`); - return true; + return { error: null, ok: true }; } - - logger.warn(`Webhook delivery failed: ${webhook.id} to ${webhook.url} - Status: ${response.status} (attempt ${attempt})`); - return false; + return { error: `HTTP ${response.status}`, ok: false }; } catch (error) { - logger.error(`Webhook delivery error: ${webhook.id} to ${webhook.url} (attempt ${attempt}):`, error); - return false; + return { error: error instanceof Error ? error.message : String(error), ok: false }; + } + } + + private async deliverWebhook(webhook: Webhook, payload: WebhookPayload, attempt = 1): Promise { + const result = await this.deliverSingleAttempt(webhook, payload); + if (result.ok) { + logger.info(`Webhook delivered successfully: ${webhook.id} to ${webhook.url} (attempt ${attempt})`); + return true; } + logger.warn(`Webhook delivery failed: ${webhook.id} to ${webhook.url} - ${result.error} (attempt ${attempt})`); + return false; } private async deliverWithRetry(webhook: Webhook, payload: WebhookPayload): Promise { diff --git a/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts b/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts new file mode 100644 index 000000000..ddfd1d7aa --- /dev/null +++ b/apps/api/src/api/services/webhook/webhook-outbox.service.test.ts @@ -0,0 +1,198 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { WebhookEventType, type WebhookPayload } from "@vortexfi/shared"; +import sequelize from "../../../config/database"; +import Webhook from "../../../models/webhook.model"; +import WebhookDelivery, { WebhookDeliveryStatus } from "../../../models/webhookDelivery.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import webhookDeliveryService from "./webhook-delivery.service"; +import { + dispatchDueWebhookDeliveries, + enqueueWebhookDeliveries, + pruneSettledWebhookDeliveries, + reconcileStuckWebhookDeliveries +} from "./webhook-outbox.service"; + +const realDeliverSingleAttempt = webhookDeliveryService.deliverSingleAttempt.bind(webhookDeliveryService); +let deliverResults: Array<{ ok: boolean; error: string | null }> = []; +let deliverCalls: Array<{ url: string; payload: WebhookPayload }> = []; + +function payloadFor(eventId: string): WebhookPayload { + return { + eventId, + eventType: WebhookEventType.DEPOSIT_RECEIVED, + payload: { + accountId: "acc-1", + amountRaw: "100000000000000000000", + currency: "eur", + depositId: "dep-1", + profileId: "profile-1", + status: "minted", + txHash: null + }, + timestamp: new Date().toISOString() + } as WebhookPayload; +} + +describe("webhook delivery outbox", () => { + beforeAll(async () => { + await setupTestDatabase(); + webhookDeliveryService.deliverSingleAttempt = async (webhook, payload) => { + deliverCalls.push({ payload, url: webhook.url }); + return deliverResults.shift() ?? { error: "no scripted result", ok: false }; + }; + }); + + afterAll(() => { + webhookDeliveryService.deliverSingleAttempt = realDeliverSingleAttempt; + }); + + beforeEach(async () => { + await resetTestDatabase(); + deliverResults = []; + deliverCalls = []; + }); + + async function createDepositWebhook(): Promise { + const owner = await createTestUser(); + return Webhook.create({ + events: [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED], + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: "https://integrator.example.com/hook", + userId: owner.id + }); + } + + it("enqueues idempotently per (webhook, event)", async () => { + const webhook = await createDepositWebhook(); + const payload = payloadFor("deposit-received:dep-1"); + + await enqueueWebhookDeliveries([webhook], payload); + await enqueueWebhookDeliveries([webhook], payload); + + expect(await WebhookDelivery.count()).toBe(1); + }); + + it("dispatches a due delivery and records the sent outcome", async () => { + const webhook = await createDepositWebhook(); + await enqueueWebhookDeliveries([webhook], payloadFor("deposit-received:dep-1")); + deliverResults = [{ error: null, ok: true }]; + + expect(await dispatchDueWebhookDeliveries()).toBe(1); + + const row = await WebhookDelivery.findOne({ where: { webhookId: webhook.id } }); + expect(row?.status).toBe(WebhookDeliveryStatus.Sent); + expect(row?.attempts).toBe(1); + expect(row?.sentAt).not.toBeNull(); + expect(deliverCalls).toHaveLength(1); + expect(deliverCalls[0].url).toBe("https://integrator.example.com/hook"); + }); + + it("retries with backoff and abandons after the attempt cap", async () => { + const webhook = await createDepositWebhook(); + await enqueueWebhookDeliveries([webhook], payloadFor("deposit-received:dep-1")); + + for (let attempt = 1; attempt <= 6; attempt++) { + deliverResults = [{ error: "HTTP 500", ok: false }]; + // Force the row due again regardless of the recorded backoff. + await WebhookDelivery.update({ nextAttemptAt: new Date(Date.now() - 1000) }, { where: { webhookId: webhook.id } }); + await dispatchDueWebhookDeliveries(); + + const row = await WebhookDelivery.findOne({ where: { webhookId: webhook.id } }); + if (attempt < 6) { + expect(row?.status).toBe(WebhookDeliveryStatus.Pending); + expect(row?.attempts).toBe(attempt); + expect(row?.nextAttemptAt.getTime()).toBeGreaterThan(Date.now()); + expect(row?.lastError).toBe("HTTP 500"); + } else { + expect(row?.status).toBe(WebhookDeliveryStatus.Abandoned); + } + } + + // Abandoned rows are terminal — nothing further is claimed. + deliverResults = [{ error: null, ok: true }]; + await WebhookDelivery.update({ nextAttemptAt: new Date(Date.now() - 1000) }, { where: { webhookId: webhook.id } }); + expect(await dispatchDueWebhookDeliveries()).toBe(0); + }); + + it("abandons deliveries whose webhook is gone or inactive without deactivating others", async () => { + const webhook = await createDepositWebhook(); + await enqueueWebhookDeliveries([webhook], payloadFor("deposit-received:dep-1")); + await webhook.update({ isActive: false }); + + await dispatchDueWebhookDeliveries(); + + const row = await WebhookDelivery.findOne({ where: { webhookId: webhook.id } }); + expect(row?.status).toBe(WebhookDeliveryStatus.Abandoned); + expect(deliverCalls).toHaveLength(0); + }); + + it("never double-delivers under concurrent dispatch (skip-locked claims)", async () => { + const webhook = await createDepositWebhook(); + for (let i = 0; i < 10; i++) { + await enqueueWebhookDeliveries([webhook], payloadFor(`deposit-received:dep-${i}`)); + } + deliverResults = Array.from({ length: 20 }, () => ({ error: null, ok: true })); + + // Two dispatchers race for the same due backlog; SKIP LOCKED must partition it. + await Promise.all([dispatchDueWebhookDeliveries(), dispatchDueWebhookDeliveries()]); + while ((await dispatchDueWebhookDeliveries()) > 0) { + // drain any remainder + } + + expect(deliverCalls).toHaveLength(10); + expect(await WebhookDelivery.count({ where: { status: WebhookDeliveryStatus.Sent } })).toBe(10); + }); + + it("requeues stuck sending rows so a crash cannot strand a delivery", async () => { + const webhook = await createDepositWebhook(); + await enqueueWebhookDeliveries([webhook], payloadFor("deposit-received:dep-1")); + await WebhookDelivery.update( + { status: WebhookDeliveryStatus.Sending, updatedAt: new Date(Date.now() - 16 * 60 * 1000) }, + { silent: true, where: { webhookId: webhook.id } } + ); + + expect(await reconcileStuckWebhookDeliveries()).toBe(1); + const row = await WebhookDelivery.findOne({ where: { webhookId: webhook.id } }); + expect(row?.status).toBe(WebhookDeliveryStatus.Pending); + }); +}); + +describe("settled delivery retention", () => { + beforeAll(async () => { + await setupTestDatabase(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + it("prunes old sent rows and keeps pending and recent ones", async () => { + const owner = await createTestUser(); + const webhook = await Webhook.create({ + events: [WebhookEventType.DEPOSIT_RECEIVED], + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url: "https://integrator.example.com/hook", + userId: owner.id + }); + const old = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + await WebhookDelivery.bulkCreate([ + { eventId: "old-sent", eventType: "DEPOSIT_RECEIVED", payload: payloadFor("old-sent"), sentAt: old, status: WebhookDeliveryStatus.Sent, webhookId: webhook.id }, + { eventId: "fresh-sent", eventType: "DEPOSIT_RECEIVED", payload: payloadFor("fresh-sent"), sentAt: new Date(), status: WebhookDeliveryStatus.Sent, webhookId: webhook.id }, + { eventId: "old-pending", eventType: "DEPOSIT_RECEIVED", payload: payloadFor("old-pending"), webhookId: webhook.id } + ]); + await sequelize.query("UPDATE webhook_deliveries SET updated_at = :old WHERE event_id IN ('old-sent', 'old-pending')", { + replacements: { old } + }); + + expect(await pruneSettledWebhookDeliveries()).toBe(1); + const remaining = (await WebhookDelivery.findAll()).map(row => row.eventId).sort(); + expect(remaining).toEqual(["fresh-sent", "old-pending"]); + }); +}); diff --git a/apps/api/src/api/services/webhook/webhook-outbox.service.ts b/apps/api/src/api/services/webhook/webhook-outbox.service.ts new file mode 100644 index 000000000..cad76e373 --- /dev/null +++ b/apps/api/src/api/services/webhook/webhook-outbox.service.ts @@ -0,0 +1,138 @@ +import { WebhookPayload } from "@vortexfi/shared"; +import { literal, Op, Transaction } from "sequelize"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import Webhook from "../../../models/webhook.model"; +import WebhookDelivery, { WebhookDeliveryStatus } from "../../../models/webhookDelivery.model"; +import webhookDeliveryService from "./webhook-delivery.service"; + +// Same shape as the email-notification dispatcher: attempts are incremented at claim +// time, backoff grows per attempt, and the row is abandoned after the cap. Unlike the +// legacy in-process path, a failing endpoint never deactivates the webhook — the +// delivery is durable and the subscription survives outages. +const BACKOFF_MINUTES = [1, 5, 15, 60, 180]; +const MAX_ATTEMPTS = BACKOFF_MINUTES.length + 1; +const BATCH_SIZE = 25; +const STUCK_SENDING_MS = 15 * 60 * 1000; + +/** + * Enqueues one durable delivery per webhook for an already-built event envelope. + * Idempotent: the unique (webhook_id, event_id) pair absorbs re-emits after crashes. + */ +export async function enqueueWebhookDeliveries(webhooks: Webhook[], payload: WebhookPayload): Promise { + if (webhooks.length === 0) return; + await WebhookDelivery.bulkCreate( + webhooks.map(webhook => ({ + eventId: payload.eventId, + eventType: payload.eventType, + payload, + webhookId: webhook.id + })), + { ignoreDuplicates: true } + ); +} + +async function claimDueDeliveries(): Promise { + return sequelize.transaction(async transaction => { + const due = await WebhookDelivery.findAll({ + limit: BATCH_SIZE, + lock: Transaction.LOCK.UPDATE, + order: [["next_attempt_at", "ASC"]], + skipLocked: true, + transaction, + where: { + attempts: { [Op.lt]: MAX_ATTEMPTS }, + nextAttemptAt: { [Op.lte]: new Date() }, + status: WebhookDeliveryStatus.Pending + } + }); + if (due.length === 0) return []; + await WebhookDelivery.update( + { attempts: literal("attempts + 1") as unknown as number, status: WebhookDeliveryStatus.Sending }, + { transaction, where: { id: due.map(row => row.id) } } + ); + for (const row of due) { + row.attempts += 1; + row.status = WebhookDeliveryStatus.Sending; + } + return due; + }); +} + +async function settle(row: WebhookDelivery, ok: boolean, error: string | null): Promise { + if (ok) { + await row.update({ lastError: null, sentAt: new Date(), status: WebhookDeliveryStatus.Sent }); + return; + } + if (row.attempts >= MAX_ATTEMPTS) { + logger.error(`webhook-outbox: delivery ${row.id} abandoned after ${row.attempts} attempts: ${error}`); + await row.update({ lastError: error, status: WebhookDeliveryStatus.Abandoned }); + return; + } + const backoffMinutes = BACKOFF_MINUTES[Math.min(row.attempts - 1, BACKOFF_MINUTES.length - 1)]; + await row.update({ + lastError: error, + nextAttemptAt: new Date(Date.now() + backoffMinutes * 60 * 1000), + status: WebhookDeliveryStatus.Pending + }); +} + +/** Dispatches one claimed batch of due deliveries. Returns the number processed. */ +export async function dispatchDueWebhookDeliveries(): Promise { + const claimed = await claimDueDeliveries(); + for (const row of claimed) { + try { + const webhook = await Webhook.findByPk(row.webhookId); + if (!webhook || !webhook.isActive) { + await row.update({ lastError: "webhook missing or inactive", status: WebhookDeliveryStatus.Abandoned }); + continue; + } + const result = await webhookDeliveryService.deliverSingleAttempt(webhook, row.payload); + await settle(row, result.ok, result.error); + } catch (error) { + logger.error(`webhook-outbox: dispatch failed for delivery ${row.id}:`, error); + await settle(row, false, error instanceof Error ? error.message : String(error)).catch(settleError => { + // The stuck-sending reconciler requeues the row; still say why settling failed. + logger.error(`webhook-outbox: could not settle delivery ${row.id}:`, settleError); + }); + } + } + return claimed.length; +} + +/** Settled rows older than this are pruned; the payload's audit value has expired by then. */ +const SETTLED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +/** Deletes sent/abandoned deliveries past the retention window (bounded table growth). */ +export async function pruneSettledWebhookDeliveries(): Promise { + const count = await WebhookDelivery.destroy({ + where: { + status: { [Op.in]: [WebhookDeliveryStatus.Sent, WebhookDeliveryStatus.Abandoned] }, + updatedAt: { [Op.lt]: new Date(Date.now() - SETTLED_RETENTION_MS) } + } + }); + if (count > 0) { + logger.info(`webhook-outbox: pruned ${count} settled delivery row(s)`); + } + return count; +} + +/** + * Requeues rows stuck in `sending` (a crash between claim and settle). The attempts + * counter was already incremented at claim, so the cap still holds. + */ +export async function reconcileStuckWebhookDeliveries(): Promise { + const [count] = await WebhookDelivery.update( + { status: WebhookDeliveryStatus.Pending }, + { + where: { + status: WebhookDeliveryStatus.Sending, + updatedAt: { [Op.lt]: new Date(Date.now() - STUCK_SENDING_MS) } + } + } + ); + if (count > 0) { + logger.warn(`webhook-outbox: requeued ${count} stuck delivery row(s)`); + } + return count; +} diff --git a/apps/api/src/api/services/webhook/webhook.service.ts b/apps/api/src/api/services/webhook/webhook.service.ts index 12512931f..45aa575cc 100644 --- a/apps/api/src/api/services/webhook/webhook.service.ts +++ b/apps/api/src/api/services/webhook/webhook.service.ts @@ -1,7 +1,13 @@ -import { RegisterWebhookRequest, RegisterWebhookResponse, WebhookEventType } from "@vortexfi/shared"; +import { + ACCOUNT_WEBHOOK_EVENT_TYPES, + RegisterWebhookRequest, + RegisterWebhookResponse, + WebhookEventType +} from "@vortexfi/shared"; import httpStatus from "http-status"; import { Op, WhereOptions } from "sequelize"; import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; import Webhook from "../../../models/webhook.model"; import { APIError } from "../../errors/api-error"; @@ -17,10 +23,24 @@ export interface WebhookOwner { } export class WebhookService { + constructor(private readonly moneriumB2bEnabled = config.moneriumB2b.enabled) {} + public async registerWebhook(request: RegisterWebhookRequest, owner: WebhookOwner): Promise { try { const { url, quoteId, sessionId, events } = request; + if ( + !this.moneriumB2bEnabled && + (events ?? []).some(event => + ACCOUNT_WEBHOOK_EVENT_TYPES.includes(event as (typeof ACCOUNT_WEBHOOK_EVENT_TYPES)[number]) + ) + ) { + throw new APIError({ + message: "Monerium B2B deposit webhooks are disabled", + status: httpStatus.BAD_REQUEST + }); + } + if (!owner.partnerId && !owner.userId) { throw new APIError({ message: "API key is not linked to a partner or user", @@ -76,6 +96,44 @@ export class WebhookService { } } + // Account-scoped event family (deposit events): owner-only subscriptions with + // their own rules — no quote/session target, no mixing with transaction events, + // and a profile owner (delivery resolves the account's controlling manager by + // profile, so a partner-owned row could never match). + const accountEventTypes: readonly WebhookEventType[] = ACCOUNT_WEBHOOK_EVENT_TYPES; + const requestedAccountEvents = (events ?? []).filter(event => accountEventTypes.includes(event)); + if (requestedAccountEvents.length > 0) { + if ((events ?? []).some(event => !accountEventTypes.includes(event))) { + throw new APIError({ + message: "Deposit events cannot be combined with transaction events in one webhook", + status: httpStatus.BAD_REQUEST + }); + } + if (quoteId || sessionId) { + throw new APIError({ + message: "Deposit-event webhooks are account-scoped and do not accept a quoteId or sessionId", + status: httpStatus.BAD_REQUEST + }); + } + if (!owner.userId) { + throw new APIError({ + message: "Deposit-event webhooks require a profile-scoped credential", + status: httpStatus.BAD_REQUEST + }); + } + + const webhook = await Webhook.create({ + events: requestedAccountEvents, + isActive: true, + partnerId: null, + quoteId: null, + sessionId: null, + url, + userId: owner.userId + }); + return this.toRegisterResponse(webhook); + } + // Validate that at least one of quoteId or sessionId is provided if (!quoteId && !sessionId) { throw new APIError({ @@ -99,7 +157,12 @@ export class WebhookService { } } - const webhookEvents: WebhookEventType[] = events || Object.values(WebhookEventType); + // Omitted events default to the transaction family only — new event families + // must always be explicit opt-in, never a silent subscription. + const webhookEvents: WebhookEventType[] = events || [ + WebhookEventType.TRANSACTION_CREATED, + WebhookEventType.STATUS_CHANGE + ]; const webhook = await Webhook.create({ events: webhookEvents, @@ -111,17 +174,7 @@ export class WebhookService { userId: owner.partnerId ? null : owner.userId }); - logger.info(`Webhook registered: ${webhook.id} for URL: ${url}`); - - return { - createdAt: webhook.createdAt.toISOString(), - events: webhook.events, - id: webhook.id, - isActive: webhook.isActive, - quoteId: webhook.quoteId, - sessionId: webhook.sessionId, - url: webhook.url - }; + return this.toRegisterResponse(webhook); } catch (error: unknown) { logger.error("Error registering webhook:", error); @@ -137,6 +190,35 @@ export class WebhookService { } } + private toRegisterResponse(webhook: Webhook): RegisterWebhookResponse { + logger.info(`Webhook registered: ${webhook.id} for URL: ${webhook.url}`); + return { + createdAt: webhook.createdAt.toISOString(), + events: webhook.events, + id: webhook.id, + isActive: webhook.isActive, + quoteId: webhook.quoteId, + sessionId: webhook.sessionId, + url: webhook.url + }; + } + + /** + * Owner-filtered lookup for the account-scoped event family: only webhooks owned by + * the given profile (the account's controlling manager, resolved by the caller from + * the managed-profile relationship). This is the deposit-event counterpart of the + * quote-owner filtering in findWebhooksForEvent — there is still no ownerless branch. + */ + public async findAccountEventWebhooks(eventType: WebhookEventType, ownerProfileId: string): Promise { + return Webhook.findAll({ + where: { + events: { [Op.contains]: [eventType] }, + isActive: true, + userId: ownerProfileId + } + }); + } + public async deleteWebhook(id: string, owner: WebhookOwner): Promise { try { if (!owner.partnerId && !owner.userId) { diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts new file mode 100644 index 000000000..204ce6c61 --- /dev/null +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -0,0 +1,119 @@ +import { CronJob } from "cron"; +import { QueryTypes } from "sequelize"; +import sequelize from "../../config/database"; +import logger from "../../config/logger"; +import { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; +import { isKeeperChainConfigured } from "../services/monerium-b2b/chain"; +import { reconcileConfirmedExecutionAllocations, runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; +import { processMoneriumWebhookInbox, pruneProcessedWebhookEvents } from "../services/monerium-b2b/deposit-processor"; +import { runDormancyGate } from "../services/monerium-b2b/dormancy"; +import { emitMoneriumDepositEvents } from "../services/monerium-b2b/manager-events"; +import { runMintWatcher } from "../services/monerium-b2b/mint-watcher"; +import { runMonitoringPass } from "../services/monerium-b2b/monitoring"; +import { advanceOnboardingAccounts } from "../services/monerium-b2b/onboarding"; + +const DEFAULT_CRON_TIME = "* * * * *"; // every minute + +/** + * Keeper loop for the Monerium B2B onramp (plan §3): webhook inbox -> mint watcher -> + * per-account conversion executor -> dormancy gate (R05). Chain steps are skipped + * (inbox processing still runs) until MONERIUM_B2B_RPC_URL and + * MONERIUM_B2B_KEEPER_PRIVATE_KEY are configured. + */ +class MoneriumB2bWorker { + private job: CronJob; + private running = false; + private chainConfigWarned = false; + + constructor(cronTime = DEFAULT_CRON_TIME) { + this.job = new CronJob(cronTime, this.cycle.bind(this), null, false, undefined, null, true); + } + + public start(): void { + logger.info("Starting Monerium B2B keeper worker"); + this.job.start(); + } + + public stop(): void { + logger.info("Stopping Monerium B2B keeper worker"); + this.job.stop(); + } + + private async cycle(): Promise { + if (this.running) { + return; // previous cycle (e.g. waiting on a receipt) still in progress + } + this.running = true; + try { + await processMoneriumWebhookInbox(); + + // Link + IBAN issuance for mapped accounts still in onboarding; internally + // gated on the whitelabel credentials, attestor key, and read RPC. + await advanceOnboardingAccounts(); + + if (!isKeeperChainConfigured()) { + if (!this.chainConfigWarned) { + this.chainConfigWarned = true; + logger.warn( + "monerium-b2b: MONERIUM_B2B_RPC_URL / MONERIUM_B2B_KEEPER_PRIVATE_KEY not configured — keeper chain steps disabled" + ); + } + } else { + const mintedAccountIds = await runMintWatcher(); + await reconcileConfirmedExecutionAllocations(); + const candidateIds = await this.conversionCandidates(mintedAccountIds); + for (const accountId of candidateIds) { + try { + await runConversionExecutor(accountId); + } catch (error) { + logger.error(`monerium-b2b: conversion executor failed for account ${accountId}:`, error); + } + } + + await runDormancyGate(); + } + + // Manager-facing deposit events into the durable webhook outbox; the + // converted event self-gates on the read RPC for its confirmation depth. + await emitMoneriumDepositEvents(); + + // Bounded retention for the durable inbox (dedup only needs the retry horizon). + await pruneProcessedWebhookEvents(); + + // Detection-only monitors (plan D3); internally rate-limited and gated on the + // read RPC / API credentials, so this is safe to call every cycle. + await runMonitoringPass(); + } catch (error) { + logger.error("Error during Monerium B2B keeper cycle:", error); + } finally { + this.running = false; + } + } + + /** + * Accounts worth running the executor for: settled mints from this cycle and accounts + * with chain-indexed, minted-but-unallocated deposits. The executor never outruns the + * watcher's reorg-safety window merely because a live balance is visible. + */ + private async conversionCandidates(mintedAccountIds: string[]): Promise { + const candidates = new Set(mintedAccountIds); + + const outstanding = await sequelize.query<{ accountId: string }>( + `SELECT DISTINCT deposit.account_id AS "accountId" + FROM monerium_fiat_deposits AS deposit + LEFT JOIN monerium_deposit_allocations AS allocation ON allocation.deposit_id = deposit.id + WHERE deposit.status = :minted + AND deposit.block_number IS NOT NULL + GROUP BY deposit.id + HAVING COALESCE(SUM(allocation.eure_in_raw), 0) < deposit.amount_raw`, + { replacements: { minted: MoneriumFiatDepositStatus.Minted }, type: QueryTypes.SELECT } + ); + for (const row of outstanding) { + candidates.add(row.accountId); + } + + return [...candidates]; + } +} + +export default MoneriumB2bWorker; diff --git a/apps/api/src/api/workers/webhook-outbox.worker.ts b/apps/api/src/api/workers/webhook-outbox.worker.ts new file mode 100644 index 000000000..168029ec1 --- /dev/null +++ b/apps/api/src/api/workers/webhook-outbox.worker.ts @@ -0,0 +1,60 @@ +import { CronJob } from "cron"; +import logger from "../../config/logger"; +import { + dispatchDueWebhookDeliveries, + pruneSettledWebhookDeliveries, + reconcileStuckWebhookDeliveries +} from "../services/webhook/webhook-outbox.service"; + +/** + * Dispatches the durable webhook-delivery outbox (account-scoped event family). + * Claim-before-send makes it safe to run on every backend sharing the database. + */ +class WebhookOutboxWorker { + private dispatchJob: CronJob; + private reconcileJob: CronJob; + private running = false; + + constructor(dispatchCron = "* * * * *", reconcileCron = "10 * * * *") { + this.dispatchJob = new CronJob(dispatchCron, this.dispatchCycle.bind(this), null, false, undefined, null, true); + this.reconcileJob = new CronJob(reconcileCron, this.reconcileCycle.bind(this), null, false, undefined, null, false); + } + + public start(): void { + logger.info("Starting webhook outbox worker"); + this.dispatchJob.start(); + this.reconcileJob.start(); + } + + public stop(): void { + logger.info("Stopping webhook outbox worker"); + this.dispatchJob.stop(); + this.reconcileJob.stop(); + } + + private async dispatchCycle(): Promise { + if (this.running) return; + this.running = true; + try { + // Drain everything currently due, one claimed batch at a time. + while ((await dispatchDueWebhookDeliveries()) > 0) { + // keep claiming until the due backlog is empty + } + } catch (error) { + logger.error("Error during webhook outbox dispatch cycle:", error); + } finally { + this.running = false; + } + } + + private async reconcileCycle(): Promise { + try { + await reconcileStuckWebhookDeliveries(); + await pruneSettledWebhookDeliveries(); + } catch (error) { + logger.error("Error during webhook outbox reconcile cycle:", error); + } + } +} + +export default WebhookOutboxWorker; diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index 6312d4767..d87cc7354 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -63,6 +63,23 @@ app.use(["/v1/brl/kyc/import-token", "/v1/brla/kyc/import-token"], brlaKycImport // not buffer the 20mb the JSON API allows before the signature is even checked. app.use("/v1/webhooks/avenia", bodyParser.raw({ limit: "100kb", type: "*/*" }), aveniaWebhookRoutes); +// Same reasoning for the Monerium B2B webhook: the HMAC is computed over the RAW +// request bytes (captured here for the controller), and this unauthenticated route +// gets its own small limit instead of buffering the 20mb the JSON API allows before +// the signature is even checked. Mounted ahead of the global JSON parser, which +// skips bodies that are already parsed. +if (config.moneriumB2b.enabled) { + app.use( + "/v1/monerium-b2b/webhook", + bodyParser.json({ + limit: "100kb", + verify: (req, _res, buf) => { + (req as typeof req & { rawBody?: Buffer }).rawBody = buf; + } + }) + ); +} + // parse body params and attach them to req.body app.use(bodyParser.json({ limit: REQUEST_BODY_LIMIT })); app.use(bodyParser.urlencoded({ extended: true, limit: REQUEST_BODY_LIMIT })); diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 2d42c2957..ad336025c 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -16,6 +16,20 @@ const requiredProductionEnv = { WEBHOOK_PRIVATE_KEY: "test-webhook-private-key" }; +const requiredMoneriumB2bEnv = { + FLOW_VARIANT: "mykobo", + MONERIUM_B2B_ATTESTOR_PRIVATE_KEY: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + MONERIUM_B2B_ENABLED: "true", + MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS: "0x0000000000000000000000000000000000000001", + MONERIUM_B2B_GUARDIAN_PRIVATE_KEY: "0x2222222222222222222222222222222222222222222222222222222222222222", + MONERIUM_B2B_KEEPER_PRIVATE_KEY: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + MONERIUM_B2B_PRIVATE_RPC_URL: "https://private-rpc.example.com", + MONERIUM_B2B_RPC_URL: "https://rpc.example.com", + MONERIUM_B2B_WEBHOOK_SECRET: "whsec_MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=", + MONERIUM_WHITELABEL_CLIENT_ID: "test-whitelabel-client-id", + MONERIUM_WHITELABEL_CLIENT_SECRET: "test-whitelabel-client-secret" +}; + async function importVarsWithEnv(env: Record) { const proc = Bun.spawn({ cmd: [ @@ -109,6 +123,73 @@ describe("vars deployment environment validation", () => { expect(result.stderr).toContain("MONERIUM_CLIENT_ID"); }); + it("keeps Monerium B2B disabled unless its flag is exactly true", async () => { + for (const enabled of ["", "TRUE", "1", "false"]) { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + MONERIUM_B2B_ENABLED: enabled, + NODE_ENV: "production" + }); + + expect(result).toEqual({ exitCode: 0, stderr: "", stdout: "ok\n" }); + } + }); + + it("requires the complete Monerium B2B configuration when enabled", async () => { + for (const name of Object.keys(requiredMoneriumB2bEnv).filter( + name => name !== "MONERIUM_B2B_ENABLED" && name !== "FLOW_VARIANT" + )) { + const result = await importVarsWithEnv({ + ...requiredMoneriumB2bEnv, + DEPLOYMENT_ENV: "production", + [name]: "", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(name); + } + }); + + it("accepts a complete Monerium B2B production configuration", async () => { + const result = await importVarsWithEnv({ + ...requiredMoneriumB2bEnv, + DEPLOYMENT_ENV: "production", + NODE_ENV: "production" + }); + + expect(result).toEqual({ exitCode: 0, stderr: "", stdout: "ok\n" }); + }); + + it("rejects malformed activation secrets and a zero factory", async () => { + for (const overrides of [ + { MONERIUM_B2B_ATTESTOR_PRIVATE_KEY: "not-a-key" }, + { MONERIUM_B2B_ATTESTOR_PRIVATE_KEY: requiredMoneriumB2bEnv.MONERIUM_B2B_KEEPER_PRIVATE_KEY }, + { MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS: "0x0000000000000000000000000000000000000000" }, + { MONERIUM_B2B_WEBHOOK_SECRET: "plain-text" } + ]) { + const result = await importVarsWithEnv({ + ...requiredMoneriumB2bEnv, + ...overrides, + DEPLOYMENT_ENV: "production", + NODE_ENV: "production" + }); + expect(result.exitCode).toBe(1); + } + }); + + it("requires the mykobo flow variant when Monerium B2B is enabled", async () => { + const result = await importVarsWithEnv({ + ...requiredMoneriumB2bEnv, + DEPLOYMENT_ENV: "production", + FLOW_VARIANT: "monerium", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("FLOW_VARIANT=mykobo"); + }); + it("accepts a lower recipient-invite discount ceiling", async () => { const result = await importVarsWithEnv({ DEPLOYMENT_ENV: "production", diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index c85ca01da..7da9decb4 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -220,6 +220,18 @@ interface Config { clientId: string; redirectUri: string; }; + // B2B whitelabel onramp integration (docs/architecture-monerium-b2b-onramp.md §3). + // Separate credential set from the legacy consumer OAuth integration above. + moneriumB2b: { + attestorPrivateKey: string | undefined; + enabled: boolean; + forwarderFactoryAddress: string | undefined; + guardianPrivateKey: string | undefined; + keeperPrivateKey: string | undefined; + privateRpcUrl: string | undefined; + rpcUrl: string | undefined; + webhookSecret: string; + }; subscanApiKey: string | undefined; vortexFeePenPercentage: number; @@ -322,6 +334,23 @@ export const config: Config = { clientId: process.env.MONERIUM_CLIENT_ID || "", redirectUri: process.env.MONERIUM_REDIRECT_URI || "http://localhost:5174/monerium/callback" }, + moneriumB2b: { + // Whitelabel API credentials and base URL live with the shared client + // (MONERIUM_WHITELABEL_CLIENT_ID/SECRET, MONERIUM_API_URL — @vortexfi/shared); + // this block keeps only the chain/keeper-specific settings. + attestorPrivateKey: process.env.MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, + enabled: process.env.MONERIUM_B2B_ENABLED === "true", + forwarderFactoryAddress: process.env.MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS, + // Dormancy-gate pause key (guardian on the factory/forwarders). Distinct from the + // keeper and attestor keys by design; unset = log-only mode for the dormancy gate. + guardianPrivateKey: process.env.MONERIUM_B2B_GUARDIAN_PRIVATE_KEY, + keeperPrivateKey: process.env.MONERIUM_B2B_KEEPER_PRIVATE_KEY, + // Private-orderflow submission endpoint (e.g. https://rpc.flashbots.net); when unset + // the keeper falls back to the public RPC and logs a warning (see chain.ts). + privateRpcUrl: process.env.MONERIUM_B2B_PRIVATE_RPC_URL, + rpcUrl: process.env.MONERIUM_B2B_RPC_URL, + webhookSecret: process.env.MONERIUM_B2B_WEBHOOK_SECRET || "" + }, mykobo: { feeFallback: readMykoboFeeFallback() }, @@ -417,6 +446,62 @@ if (config.demoProviderEnabled && config.deploymentEnv !== "sandbox") { ); } +if (config.moneriumB2b.enabled) { + if (config.flowVariant !== "mykobo") { + throw new Error("MONERIUM_B2B_ENABLED=true requires FLOW_VARIANT=mykobo"); + } + + const missing: string[] = []; + if (!process.env.MONERIUM_WHITELABEL_CLIENT_ID) missing.push("MONERIUM_WHITELABEL_CLIENT_ID"); + if (!process.env.MONERIUM_WHITELABEL_CLIENT_SECRET) missing.push("MONERIUM_WHITELABEL_CLIENT_SECRET"); + if (!config.moneriumB2b.attestorPrivateKey) missing.push("MONERIUM_B2B_ATTESTOR_PRIVATE_KEY"); + if (!config.moneriumB2b.guardianPrivateKey) missing.push("MONERIUM_B2B_GUARDIAN_PRIVATE_KEY"); + if (!config.moneriumB2b.keeperPrivateKey) missing.push("MONERIUM_B2B_KEEPER_PRIVATE_KEY"); + if (!config.moneriumB2b.rpcUrl) missing.push("MONERIUM_B2B_RPC_URL"); + if (!config.moneriumB2b.webhookSecret) missing.push("MONERIUM_B2B_WEBHOOK_SECRET"); + if (!config.moneriumB2b.forwarderFactoryAddress) missing.push("MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS"); + if (config.deploymentEnv === "production" && !config.moneriumB2b.privateRpcUrl) { + missing.push("MONERIUM_B2B_PRIVATE_RPC_URL"); + } + if (missing.length > 0) { + throw new Error(`Missing required environment variables for Monerium B2B: ${missing.join(", ")}`); + } + + if ( + !/^0x[0-9a-fA-F]{40}$/.test(config.moneriumB2b.forwarderFactoryAddress as string) || + /^0x0{40}$/i.test(config.moneriumB2b.forwarderFactoryAddress as string) + ) { + throw new Error("MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS must be a valid EVM address"); + } + for (const [name, value] of [ + ["MONERIUM_B2B_ATTESTOR_PRIVATE_KEY", config.moneriumB2b.attestorPrivateKey], + ["MONERIUM_B2B_GUARDIAN_PRIVATE_KEY", config.moneriumB2b.guardianPrivateKey], + ["MONERIUM_B2B_KEEPER_PRIVATE_KEY", config.moneriumB2b.keeperPrivateKey] + ] as const) { + if (!/^0x[0-9a-fA-F]{64}$/.test(value as string)) { + throw new Error(`${name} must be a 32-byte 0x-prefixed private key`); + } + } + const b2bKeys = [ + config.moneriumB2b.attestorPrivateKey, + config.moneriumB2b.guardianPrivateKey, + config.moneriumB2b.keeperPrivateKey + ].map(value => (value as string).toLowerCase()); + if (new Set(b2bKeys).size !== b2bKeys.length) { + throw new Error("Monerium B2B attestor, guardian, and keeper private keys must be distinct"); + } + const encodedWebhookSecret = config.moneriumB2b.webhookSecret.slice("whsec_".length); + const decodedWebhookSecret = Buffer.from(encodedWebhookSecret, "base64"); + if ( + !config.moneriumB2b.webhookSecret.startsWith("whsec_") || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encodedWebhookSecret) || + decodedWebhookSecret.length < 24 || + decodedWebhookSecret.length > 64 + ) { + throw new Error("MONERIUM_B2B_WEBHOOK_SECRET must encode 24-64 bytes using whsec_"); + } +} + if (config.env === "production") { const missing: string[] = []; diff --git a/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts b/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts new file mode 100644 index 000000000..c7b797b60 --- /dev/null +++ b/apps/api/src/database/migrations/069-monerium-b2b-onramp-tables.ts @@ -0,0 +1,106 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// B2B zero-touch onramp persistence (docs/architecture-monerium-b2b-onramp.md §3). +// Deliberately separate from ramp_states: a Monerium IBAN account is permanent and +// repeatedly funded, not a one-shot ramp. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("monerium_accounts", { + config_version: { allowNull: false, defaultValue: 1, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + destination: { allowNull: false, type: DataTypes.STRING(42) }, + dormant_since: { allowNull: true, type: DataTypes.DATE }, + fallback_address: { allowNull: false, type: DataTypes.STRING(42) }, + fee_bps: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + forwarder_address: { allowNull: false, type: DataTypes.STRING(42), unique: true }, + iban: { allowNull: true, type: DataTypes.STRING(42) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + profile_id: { allowNull: false, type: DataTypes.STRING(64), unique: true }, + status: { + allowNull: false, + defaultValue: "onboarding", + type: DataTypes.ENUM("onboarding", "active", "suspended", "closed") + }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + await queryInterface.addIndex("monerium_accounts", ["status"]); + + await queryInterface.createTable("monerium_fiat_deposits", { + account_id: { + allowNull: false, + references: { key: "id", model: "monerium_accounts" }, + type: DataTypes.UUID + }, + allocated_execution_id: { allowNull: true, type: DataTypes.UUID }, + amount_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + block_hash: { allowNull: true, type: DataTypes.STRING(66) }, + chain_id: { allowNull: true, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + currency: { allowNull: false, defaultValue: "eur", type: DataTypes.STRING(8) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + log_index: { allowNull: true, type: DataTypes.INTEGER }, + monerium_order_id: { allowNull: false, type: DataTypes.STRING(64), unique: true }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.ENUM("pending", "minted", "held", "returned") + }, + tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + await queryInterface.addIndex("monerium_fiat_deposits", ["account_id", "status"]); + // On-chain identity: one deposit per mint log (partial unique — mint fields are null + // until the Transfer is observed). + await queryInterface.sequelize.query( + "CREATE UNIQUE INDEX monerium_fiat_deposits_mint_log ON monerium_fiat_deposits (chain_id, tx_hash, log_index) WHERE tx_hash IS NOT NULL" + ); + + await queryInterface.createTable("monerium_conversion_executions", { + account_id: { + allowNull: false, + references: { key: "id", model: "monerium_accounts" }, + type: DataTypes.UUID + }, + block_number: { allowNull: true, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + destination: { allowNull: false, type: DataTypes.STRING(42) }, + error: { allowNull: true, type: DataTypes.TEXT }, + eure_in_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + fee_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.ENUM("pending", "confirmed", "failed") + }, + tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + usdc_gross_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + usdc_net_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) } + }); + await queryInterface.addIndex("monerium_conversion_executions", ["account_id", "status"]); + + // Durable webhook inbox: persist-before-200 with payload, dedup by delivery id (R06). + await queryInterface.createTable("monerium_webhook_events", { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + event_id: { allowNull: false, type: DataTypes.STRING(128), unique: true }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + payload: { allowNull: false, type: DataTypes.JSONB }, + processed_at: { allowNull: true, type: DataTypes.DATE } + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + // The options object is required: with the models registered, the postgres + // dropTable assigns onto it during ENUM cleanup and throws on undefined. + await queryInterface.dropTable("monerium_webhook_events", {}); + await queryInterface.dropTable("monerium_conversion_executions", {}); + await queryInterface.dropTable("monerium_fiat_deposits", {}); + await queryInterface.dropTable("monerium_accounts", {}); + for (const enumName of [ + "enum_monerium_accounts_status", + "enum_monerium_fiat_deposits_status", + "enum_monerium_conversion_executions_status" + ]) { + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "${enumName}"`); + } +} diff --git a/apps/api/src/database/migrations/070-monerium-keeper-chain-state.ts b/apps/api/src/database/migrations/070-monerium-keeper-chain-state.ts new file mode 100644 index 000000000..f01885248 --- /dev/null +++ b/apps/api/src/database/migrations/070-monerium-keeper-chain-state.ts @@ -0,0 +1,25 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Keeper chain state for the B2B onramp (docs/architecture-monerium-b2b-onramp.md §3): +// - monerium_chain_cursors: persisted getLogs cursors for the poll-based EURe mint +// watcher, keyed by watcher name (one row per watcher+chain). +// - monerium_fiat_deposits.block_number: the mint block, required by the R04 attribution +// rule ("mint block <= execution block"); blockHash alone cannot be compared. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("monerium_chain_cursors", { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + last_block: { allowNull: false, type: DataTypes.BIGINT }, + name: { primaryKey: true, type: DataTypes.STRING(64) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE } + }); + + await queryInterface.addColumn("monerium_fiat_deposits", "block_number", { + allowNull: true, + type: DataTypes.INTEGER + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_fiat_deposits", "block_number"); + await queryInterface.dropTable("monerium_chain_cursors"); +} diff --git a/apps/api/src/database/migrations/071-link-monerium-accounts-to-profiles.ts b/apps/api/src/database/migrations/071-link-monerium-accounts-to-profiles.ts new file mode 100644 index 000000000..497502dd4 --- /dev/null +++ b/apps/api/src/database/migrations/071-link-monerium-accounts-to-profiles.ts @@ -0,0 +1,21 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Bind each Monerium B2B account to the Vortex managed profile that owns it. +// Nullable because pre-mapping sandbox rows exist; application code requires it +// for every account provisioned through the admin mapping endpoint. The partial +// unique index enforces one account per profile without rejecting legacy NULLs. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_accounts", "vortex_profile_id", { + allowNull: true, + references: { key: "id", model: "profiles" }, + type: DataTypes.UUID + }); + await queryInterface.sequelize.query( + "CREATE UNIQUE INDEX monerium_accounts_vortex_profile_id ON monerium_accounts (vortex_profile_id) WHERE vortex_profile_id IS NOT NULL" + ); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query("DROP INDEX IF EXISTS monerium_accounts_vortex_profile_id"); + await queryInterface.removeColumn("monerium_accounts", "vortex_profile_id"); +} diff --git a/apps/api/src/database/migrations/072-create-webhook-deliveries.ts b/apps/api/src/database/migrations/072-create-webhook-deliveries.ts new file mode 100644 index 000000000..90a2500a3 --- /dev/null +++ b/apps/api/src/database/migrations/072-create-webhook-deliveries.ts @@ -0,0 +1,41 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Durable webhook delivery outbox for the account-scoped event family: a row per +// (webhook, event) is enqueued before any send, dispatched by a worker with backoff, +// and deduplicated by the unique pair so crashes and re-emits never double-deliver. +// The legacy quote-scoped transaction events keep their in-process delivery path. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("webhook_deliveries", { + attempts: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + event_id: { allowNull: false, type: DataTypes.STRING(128) }, + event_type: { allowNull: false, type: DataTypes.STRING(64) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + last_error: { allowNull: true, type: DataTypes.TEXT }, + next_attempt_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + payload: { allowNull: false, type: DataTypes.JSONB }, + sent_at: { allowNull: true, type: DataTypes.DATE }, + status: { allowNull: false, defaultValue: "pending", type: DataTypes.STRING(16) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + webhook_id: { + allowNull: false, + onDelete: "CASCADE", + references: { key: "id", model: "webhooks" }, + type: DataTypes.UUID + } + }); + await queryInterface.addConstraint("webhook_deliveries", { + fields: ["webhook_id", "event_id"], + name: "uniq_webhook_deliveries_webhook_event", + type: "unique" + }); + await queryInterface.addIndex("webhook_deliveries", ["status", "next_attempt_at"]); + await queryInterface.sequelize.query( + `ALTER TABLE webhook_deliveries ADD CONSTRAINT chk_webhook_deliveries_status + CHECK (status IN ('pending', 'sending', 'sent', 'abandoned'))` + ); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("webhook_deliveries", {}); +} diff --git a/apps/api/src/database/migrations/073-add-monerium-deposit-event-markers.ts b/apps/api/src/database/migrations/073-add-monerium-deposit-event-markers.ts new file mode 100644 index 000000000..9b83e946d --- /dev/null +++ b/apps/api/src/database/migrations/073-add-monerium-deposit-event-markers.ts @@ -0,0 +1,20 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Emission markers for the manager-facing deposit events: set once when the event is +// emitted (whether or not any webhook is subscribed at that moment), so the emitter +// never replays history to a late subscriber and never double-emits after a crash. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_fiat_deposits", "received_event_at", { + allowNull: true, + type: DataTypes.DATE + }); + await queryInterface.addColumn("monerium_fiat_deposits", "converted_event_at", { + allowNull: true, + type: DataTypes.DATE + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_fiat_deposits", "converted_event_at"); + await queryInterface.removeColumn("monerium_fiat_deposits", "received_event_at"); +} diff --git a/apps/api/src/database/migrations/074-add-conversion-execution-nonce.ts b/apps/api/src/database/migrations/074-add-conversion-execution-nonce.ts new file mode 100644 index 000000000..412777828 --- /dev/null +++ b/apps/api/src/database/migrations/074-add-conversion-execution-nonce.ts @@ -0,0 +1,16 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// The keeper persists the swap's transaction nonce BEFORE broadcasting, so crash +// recovery can distinguish "never sent" from "sent but the tx hash was lost" (a crash +// or DB error between broadcast and the hash update) by checking nonce consumption +// on chain instead of silently failing the row and double-executing. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "nonce", { + allowNull: true, + type: DataTypes.INTEGER + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "nonce"); +} diff --git a/apps/api/src/database/migrations/075-add-conversion-broadcast-block.ts b/apps/api/src/database/migrations/075-add-conversion-broadcast-block.ts new file mode 100644 index 000000000..6eb82379e --- /dev/null +++ b/apps/api/src/database/migrations/075-add-conversion-broadcast-block.ts @@ -0,0 +1,14 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Recovery scans from the block observed immediately before broadcast. Unlike a +// fixed lookback, this remains complete after an arbitrarily long worker outage. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "broadcast_block_number", { + allowNull: true, + type: DataTypes.INTEGER + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "broadcast_block_number"); +} diff --git a/apps/api/src/database/migrations/076-create-monerium-deposit-allocations.ts b/apps/api/src/database/migrations/076-create-monerium-deposit-allocations.ts new file mode 100644 index 000000000..1f59b0960 --- /dev/null +++ b/apps/api/src/database/migrations/076-create-monerium-deposit-allocations.ts @@ -0,0 +1,56 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// A deposit may span multiple capped swaps, and one swap may consume multiple +// deposits. The join table is the accounting source of truth for both directions. +export async function up(queryInterface: QueryInterface): Promise { + const [[existing]] = (await queryInterface.sequelize.query( + "SELECT COUNT(*)::integer AS count FROM monerium_fiat_deposits WHERE allocated_execution_id IS NOT NULL" + )) as [[{ count: number }], unknown]; + if (existing.count > 0) { + throw new Error("Cannot migrate existing Monerium deposit allocations automatically; reconcile them before deploying"); + } + + await queryInterface.createTable("monerium_deposit_allocations", { + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + deposit_id: { + allowNull: false, + onDelete: "CASCADE", + references: { key: "id", model: "monerium_fiat_deposits" }, + type: DataTypes.UUID + }, + eure_in_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + execution_id: { + allowNull: false, + onDelete: "CASCADE", + references: { key: "id", model: "monerium_conversion_executions" }, + type: DataTypes.UUID + }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + usdc_net_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) } + }); + await queryInterface.sequelize.query( + "ALTER TABLE monerium_deposit_allocations ADD CONSTRAINT monerium_deposit_allocations_eure_positive CHECK (eure_in_raw > 0)" + ); + await queryInterface.sequelize.query( + "ALTER TABLE monerium_deposit_allocations ADD CONSTRAINT monerium_deposit_allocations_usdc_nonnegative CHECK (usdc_net_raw >= 0)" + ); + await queryInterface.addIndex("monerium_deposit_allocations", ["deposit_id", "execution_id"], { unique: true }); + await queryInterface.addIndex("monerium_deposit_allocations", ["execution_id"]); + await queryInterface.removeColumn("monerium_fiat_deposits", "allocated_execution_id"); +} + +export async function down(queryInterface: QueryInterface): Promise { + const [[existing]] = (await queryInterface.sequelize.query( + "SELECT COUNT(*)::integer AS count FROM monerium_deposit_allocations" + )) as [[{ count: number }], unknown]; + if (existing.count > 0) { + throw new Error("Cannot roll back Monerium deposit allocations after accounting rows have been created"); + } + + await queryInterface.addColumn("monerium_fiat_deposits", "allocated_execution_id", { + allowNull: true, + type: DataTypes.UUID + }); + await queryInterface.dropTable("monerium_deposit_allocations", {}); +} diff --git a/apps/api/src/database/migrations/077-add-conversion-swap-log-index.ts b/apps/api/src/database/migrations/077-add-conversion-swap-log-index.ts new file mode 100644 index 000000000..eb806f816 --- /dev/null +++ b/apps/api/src/database/migrations/077-add-conversion-swap-log-index.ts @@ -0,0 +1,14 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Allocation waits for the mint cursor to cover the swap and needs the event's +// block-global position so same-block deposits after the swap are not attributed to it. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "swap_log_index", { + allowNull: true, + type: DataTypes.INTEGER + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "swap_log_index"); +} diff --git a/apps/api/src/database/migrator.test.ts b/apps/api/src/database/migrator.test.ts index c70a31260..86df3bcf6 100644 --- a/apps/api/src/database/migrator.test.ts +++ b/apps/api/src/database/migrator.test.ts @@ -7,6 +7,8 @@ import { getExecutedMigrations, getPendingMigrations, revertLastMigration, rever // Old-name/new-name pairs of the migrations renumbered to clear the duplicate-055 prefix. // Must stay in sync with MIGRATION_RENAMES in migrator.ts. const RENAMED = [ + ["051-monerium-b2b-onramp-tables.js", "069-monerium-b2b-onramp-tables.js"], + ["052-monerium-keeper-chain-state.js", "070-monerium-keeper-chain-state.js"], ["055-create-api-credentials.js", "057-create-api-credentials.js"], ["057-create-partner-managed-profiles.js", "058-create-partner-managed-profiles.js"], ["058-add-api-credential-id-to-quote-tickets.js", "059-add-api-credential-id-to-quote-tickets.js"] diff --git a/apps/api/src/database/migrator.ts b/apps/api/src/database/migrator.ts index 3b7bde1f6..f1b611827 100644 --- a/apps/api/src/database/migrator.ts +++ b/apps/api/src/database/migrator.ts @@ -175,6 +175,11 @@ const umzug = new Umzug({ // createTable. This cannot be a migration itself: umzug resolves the pending list before // executing any of them. const MIGRATION_RENAMES: Record = { + // Monerium B2B migrations renumbered 051/052 -> 069/070 when the branch caught up + // with staging (which had claimed 051/052 in the meantime). Only development + // databases ever ran the old names; deployed databases never did. + "051-monerium-b2b-onramp-tables": "069-monerium-b2b-onramp-tables", + "052-monerium-keeper-chain-state": "070-monerium-keeper-chain-state", "055-create-api-credentials": "057-create-api-credentials", "057-create-partner-managed-profiles": "058-create-partner-managed-profiles", "058-add-api-credential-id-to-quote-tickets": "059-add-api-credential-id-to-quote-tickets" diff --git a/apps/api/src/database/monerium-deposit-allocation-migration.test.ts b/apps/api/src/database/monerium-deposit-allocation-migration.test.ts new file mode 100644 index 000000000..c8e72825d --- /dev/null +++ b/apps/api/src/database/monerium-deposit-allocation-migration.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { QueryInterface } from "sequelize"; +import { down } from "./migrations/076-create-monerium-deposit-allocations"; + +describe("Monerium deposit allocation migration rollback", () => { + it("refuses to discard allocation accounting", async () => { + const schemaChanges: string[] = []; + const queryInterface = { + addColumn: async () => { + schemaChanges.push("addColumn"); + }, + dropTable: async () => { + schemaChanges.push("dropTable"); + }, + sequelize: { + query: async () => [[{ count: 1 }], undefined] + } + } as unknown as QueryInterface; + + await expect(down(queryInterface)).rejects.toThrow( + "Cannot roll back Monerium deposit allocations after accounting rows have been created" + ); + expect(schemaChanges).toEqual([]); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 53cfdf199..15e69885d 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,6 +12,7 @@ import "./models"; // Initialize models import { AlfredpayLimitsService } from "./api/services/alfredpay/alfredpay-limits.service"; import { assertApiCredentialSchemaReady } from "./api/services/apiCredential.service"; import { installDemoProviders } from "./api/services/demo/demo-alfredpay.provider"; +import { shouldStartMoneriumB2bWorker } from "./api/services/monerium-b2b/feature"; import { assertPersistedBlockFlowVersionsSupported, registerBlockFlowHandlers @@ -21,9 +22,11 @@ import AlfredpayStatusWorker from "./api/workers/alfredpay-status.worker"; import ApiClientEventsRetentionWorker from "./api/workers/api-client-events-retention.worker"; import CleanupWorker from "./api/workers/cleanup.worker"; import KybStatusWorker from "./api/workers/kyb-status.worker"; +import MoneriumB2bWorker from "./api/workers/monerium-b2b.worker"; import NotificationDispatchWorker from "./api/workers/notification-dispatch.worker"; import RampRecoveryWorker from "./api/workers/ramp-recovery.worker"; import UnhandledPaymentWorker from "./api/workers/unhandled-payment.worker"; +import WebhookOutboxWorker from "./api/workers/webhook-outbox.worker"; dotenv.config({ path: [path.resolve(process.cwd(), ".env"), path.resolve(process.cwd(), "../.env")] @@ -84,14 +87,22 @@ const initializeApp = async () => { new RampRecoveryWorker().start(); new UnhandledPaymentWorker().start(); new NotificationDispatchWorker().start(); + new WebhookOutboxWorker().start(); // Both flow-variant backends share this database and these provider accounts. Give // the replacement backend sole ownership of external status polling so the legacy // grace-period backend does not make every Avenia/Alfredpay request a second time. + // The Monerium keeper holds the same ownership: two keepers against one database + // and one keeper key would race nonces and double-broadcast. if (config.flowVariant === "mykobo") { new KybStatusWorker().start(); new AlfredpayStatusWorker().start(); + if (shouldStartMoneriumB2bWorker(config.flowVariant, config.moneriumB2b.enabled)) { + new MoneriumB2bWorker().start(); + } else { + logger.info("Monerium B2B onramp is disabled"); + } } else { - logger.info("Provider status workers are owned by the mykobo backend"); + logger.info("Provider status workers and the Monerium keeper are owned by the mykobo backend"); } // Start AlfredPay limits refresh loop (daily; falls back to hardcoded if stale) diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 6c97bf074..2e322ab07 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -10,6 +10,12 @@ import KycCase from "./kycCase.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; import ManagedProfile from "./managedProfile.model"; import ManagedProfileManager from "./managedProfileManager.model"; +import MoneriumAccount from "./moneriumAccount.model"; +import MoneriumChainCursor from "./moneriumChainCursor.model"; +import MoneriumConversionExecution from "./moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "./moneriumDepositAllocation.model"; +import MoneriumFiatDeposit from "./moneriumFiatDeposit.model"; +import MoneriumWebhookEvent from "./moneriumWebhookEvent.model"; import Notification from "./notification.model"; import NotificationPreference from "./notificationPreference.model"; import Partner from "./partner.model"; @@ -26,8 +32,21 @@ import SenderRecipient from "./senderRecipient.model"; import Subsidy from "./subsidy.model"; import User from "./user.model"; import Webhook from "./webhook.model"; +import WebhookDelivery from "./webhookDelivery.model"; // Define associations +MoneriumAccount.hasMany(MoneriumFiatDeposit, { as: "fiatDeposits", foreignKey: "accountId" }); +MoneriumFiatDeposit.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); +MoneriumAccount.hasMany(MoneriumConversionExecution, { as: "conversionExecutions", foreignKey: "accountId" }); +MoneriumConversionExecution.belongsTo(MoneriumAccount, { as: "account", foreignKey: "accountId" }); +MoneriumFiatDeposit.hasMany(MoneriumDepositAllocation, { as: "allocations", foreignKey: "depositId" }); +MoneriumDepositAllocation.belongsTo(MoneriumFiatDeposit, { as: "deposit", foreignKey: "depositId" }); +MoneriumConversionExecution.hasMany(MoneriumDepositAllocation, { as: "allocations", foreignKey: "executionId" }); +MoneriumDepositAllocation.belongsTo(MoneriumConversionExecution, { as: "execution", foreignKey: "executionId" }); +MoneriumAccount.belongsTo(User, { as: "vortexProfile", foreignKey: "vortexProfileId" }); +User.hasOne(MoneriumAccount, { as: "moneriumAccount", foreignKey: "vortexProfileId" }); +Webhook.hasMany(WebhookDelivery, { as: "deliveries", foreignKey: "webhookId" }); +WebhookDelivery.belongsTo(Webhook, { as: "webhook", foreignKey: "webhookId" }); RampState.belongsTo(QuoteTicket, { as: "quote", foreignKey: "quoteId" }); QuoteTicket.hasOne(RampState, { as: "rampState", foreignKey: "quoteId" }); QuoteTicket.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); @@ -124,6 +143,12 @@ const models = { MaintenanceSchedule, ManagedProfile, ManagedProfileManager, + MoneriumAccount, + MoneriumChainCursor, + MoneriumConversionExecution, + MoneriumDepositAllocation, + MoneriumFiatDeposit, + MoneriumWebhookEvent, Notification, NotificationPreference, Partner, @@ -139,7 +164,8 @@ const models = { SenderRecipient, Subsidy, User, - Webhook + Webhook, + WebhookDelivery }; // Export models and sequelize instance diff --git a/apps/api/src/models/moneriumAccount.model.ts b/apps/api/src/models/moneriumAccount.model.ts new file mode 100644 index 000000000..72e008be2 --- /dev/null +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -0,0 +1,136 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum MoneriumAccountStatus { + Onboarding = "onboarding", + Active = "active", + Suspended = "suspended", + Closed = "closed" +} + +// Persistent B2B onramp account (docs/architecture-monerium-b2b-onramp.md §3): +// one row per client = one Monerium profile + IBAN + deployed forwarder. Long-lived, +// repeatedly funded — deliberately NOT a RampState. profileId is the MONERIUM profile +// UUID; vortexProfileId is the owning Vortex managed profile (nullable only for rows +// that predate the managed-profile mapping). +export interface MoneriumAccountAttributes { + id: string; + profileId: string; + vortexProfileId: string | null; + iban: string | null; + forwarderAddress: string; + destination: string; + fallbackAddress: string; + feeBps: number; + configVersion: number; + status: MoneriumAccountStatus; + dormantSince: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumAccountCreationAttributes = Optional< + MoneriumAccountAttributes, + "id" | "vortexProfileId" | "iban" | "configVersion" | "status" | "dormantSince" | "createdAt" | "updatedAt" +>; + +class MoneriumAccount + extends Model + implements MoneriumAccountAttributes +{ + declare id: string; + declare profileId: string; + declare vortexProfileId: string | null; + declare iban: string | null; + declare forwarderAddress: string; + declare destination: string; + declare fallbackAddress: string; + declare feeBps: number; + declare configVersion: number; + declare status: MoneriumAccountStatus; + declare dormantSince: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumAccount.init( + { + configVersion: { + allowNull: false, + defaultValue: 1, + field: "config_version", + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + destination: { + allowNull: false, + type: DataTypes.STRING(42) + }, + dormantSince: { + allowNull: true, + field: "dormant_since", + type: DataTypes.DATE + }, + fallbackAddress: { + allowNull: false, + field: "fallback_address", + type: DataTypes.STRING(42) + }, + feeBps: { + allowNull: false, + defaultValue: 0, + field: "fee_bps", + type: DataTypes.INTEGER + }, + forwarderAddress: { + allowNull: false, + field: "forwarder_address", + type: DataTypes.STRING(42), + unique: true + }, + iban: { + allowNull: true, + type: DataTypes.STRING(42) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + profileId: { + allowNull: false, + field: "profile_id", + type: DataTypes.STRING(64), + unique: true + }, + status: { + allowNull: false, + defaultValue: MoneriumAccountStatus.Onboarding, + type: DataTypes.ENUM(...Object.values(MoneriumAccountStatus)) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + vortexProfileId: { + allowNull: true, + field: "vortex_profile_id", + type: DataTypes.UUID + } + }, + { + indexes: [{ fields: ["status"] }], + modelName: "MoneriumAccount", + sequelize, + tableName: "monerium_accounts" + } +); + +export default MoneriumAccount; diff --git a/apps/api/src/models/moneriumChainCursor.model.ts b/apps/api/src/models/moneriumChainCursor.model.ts new file mode 100644 index 000000000..42d224fa5 --- /dev/null +++ b/apps/api/src/models/moneriumChainCursor.model.ts @@ -0,0 +1,58 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// Persisted block cursor for the poll-based mint watcher (one row per watcher name, +// e.g. "eure-mints:1"). lastBlock is the highest block already scanned; the next run +// resumes at lastBlock + 1 so a crash between getLogs and processing re-scans rather +// than skips (deposit writes are idempotent via the mint-log unique index). +export interface MoneriumChainCursorAttributes { + name: string; + lastBlock: string; // BIGINT, stringified + createdAt: Date; + updatedAt: Date; +} + +type MoneriumChainCursorCreationAttributes = Optional; + +class MoneriumChainCursor + extends Model + implements MoneriumChainCursorAttributes +{ + declare name: string; + declare lastBlock: string; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumChainCursor.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + lastBlock: { + allowNull: false, + field: "last_block", + type: DataTypes.BIGINT + }, + name: { + primaryKey: true, + type: DataTypes.STRING(64) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + modelName: "MoneriumChainCursor", + sequelize, + tableName: "monerium_chain_cursors" + } +); + +export default MoneriumChainCursor; diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts new file mode 100644 index 000000000..d905b534d --- /dev/null +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -0,0 +1,165 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum MoneriumConversionExecutionStatus { + Pending = "pending", + Confirmed = "confirmed", + Failed = "failed" +} + +// One row per swapAndForward execution (or intentional batch). Allocation to deposits +// is cursor-gated and snapshot-based (plan §3, R04): included deposits precede the +// execution's exact block/log position and are not yet allocated; pro-rata by amount, +// remainder to largest. +export interface MoneriumConversionExecutionAttributes { + id: string; + accountId: string; + eureInRaw: string; // 18-decimal base units + usdcGrossRaw: string | null; // 6-decimal base units + feeRaw: string | null; + usdcNetRaw: string | null; + destination: string; + txHash: string | null; + /** The swap's transaction nonce, persisted BEFORE broadcast (crash-recovery identity). */ + nonce: number | null; + /** Chain head observed with the nonce, persisted before broadcast for complete recovery scans. */ + broadcastBlockNumber: number | null; + blockNumber: number | null; + /** Block-global SwapExecuted log position used as the deposit snapshot boundary. */ + swapLogIndex: number | null; + status: MoneriumConversionExecutionStatus; + error: string | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumConversionExecutionCreationAttributes = Optional< + MoneriumConversionExecutionAttributes, + | "id" + | "usdcGrossRaw" + | "feeRaw" + | "usdcNetRaw" + | "txHash" + | "nonce" + | "broadcastBlockNumber" + | "blockNumber" + | "swapLogIndex" + | "status" + | "error" + | "createdAt" + | "updatedAt" +>; + +class MoneriumConversionExecution + extends Model + implements MoneriumConversionExecutionAttributes +{ + declare id: string; + declare accountId: string; + declare eureInRaw: string; + declare usdcGrossRaw: string | null; + declare feeRaw: string | null; + declare usdcNetRaw: string | null; + declare destination: string; + declare txHash: string | null; + declare nonce: number | null; + declare broadcastBlockNumber: number | null; + declare blockNumber: number | null; + declare swapLogIndex: number | null; + declare status: MoneriumConversionExecutionStatus; + declare error: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumConversionExecution.init( + { + accountId: { + allowNull: false, + field: "account_id", + type: DataTypes.UUID + }, + blockNumber: { + allowNull: true, + field: "block_number", + type: DataTypes.INTEGER + }, + broadcastBlockNumber: { + allowNull: true, + field: "broadcast_block_number", + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + destination: { + allowNull: false, + type: DataTypes.STRING(42) + }, + error: { + allowNull: true, + type: DataTypes.TEXT + }, + eureInRaw: { + allowNull: false, + field: "eure_in_raw", + type: DataTypes.DECIMAL(38, 0) + }, + feeRaw: { + allowNull: true, + field: "fee_raw", + type: DataTypes.DECIMAL(38, 0) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + nonce: { + allowNull: true, + type: DataTypes.INTEGER + }, + status: { + allowNull: false, + defaultValue: MoneriumConversionExecutionStatus.Pending, + type: DataTypes.ENUM(...Object.values(MoneriumConversionExecutionStatus)) + }, + swapLogIndex: { + allowNull: true, + field: "swap_log_index", + type: DataTypes.INTEGER + }, + txHash: { + allowNull: true, + field: "tx_hash", + type: DataTypes.STRING(66) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + usdcGrossRaw: { + allowNull: true, + field: "usdc_gross_raw", + type: DataTypes.DECIMAL(38, 0) + }, + usdcNetRaw: { + allowNull: true, + field: "usdc_net_raw", + type: DataTypes.DECIMAL(38, 0) + } + }, + { + indexes: [{ fields: ["account_id", "status"] }], + modelName: "MoneriumConversionExecution", + sequelize, + tableName: "monerium_conversion_executions" + } +); + +export default MoneriumConversionExecution; diff --git a/apps/api/src/models/moneriumDepositAllocation.model.ts b/apps/api/src/models/moneriumDepositAllocation.model.ts new file mode 100644 index 000000000..2fb0f2138 --- /dev/null +++ b/apps/api/src/models/moneriumDepositAllocation.model.ts @@ -0,0 +1,82 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export interface MoneriumDepositAllocationAttributes { + id: string; + depositId: string; + executionId: string; + /** Portion of the deposit consumed by this execution (EURe, 18 decimals). */ + eureInRaw: string; + /** Portion of this execution's net swap output attributed to this deposit (6 decimals). */ + usdcNetRaw: string; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumDepositAllocationCreationAttributes = Optional< + MoneriumDepositAllocationAttributes, + "id" | "createdAt" | "updatedAt" +>; + +class MoneriumDepositAllocation + extends Model + implements MoneriumDepositAllocationAttributes +{ + declare id: string; + declare depositId: string; + declare executionId: string; + declare eureInRaw: string; + declare usdcNetRaw: string; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumDepositAllocation.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + depositId: { + allowNull: false, + field: "deposit_id", + type: DataTypes.UUID + }, + eureInRaw: { + allowNull: false, + field: "eure_in_raw", + type: DataTypes.DECIMAL(38, 0) + }, + executionId: { + allowNull: false, + field: "execution_id", + type: DataTypes.UUID + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + usdcNetRaw: { + allowNull: false, + field: "usdc_net_raw", + type: DataTypes.DECIMAL(38, 0) + } + }, + { + indexes: [{ fields: ["deposit_id", "execution_id"], unique: true }, { fields: ["execution_id"] }], + modelName: "MoneriumDepositAllocation", + sequelize, + tableName: "monerium_deposit_allocations" + } +); + +export default MoneriumDepositAllocation; diff --git a/apps/api/src/models/moneriumFiatDeposit.model.ts b/apps/api/src/models/moneriumFiatDeposit.model.ts new file mode 100644 index 000000000..dfca1552d --- /dev/null +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -0,0 +1,162 @@ +import { DataTypes, Model, Op, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum MoneriumFiatDepositStatus { + Pending = "pending", + Minted = "minted", + Held = "held", + Returned = "returned" +} + +// One row per Monerium issue order (SEPA deposit → EURe mint). Identity/idempotency: +// monerium_order_id for accounting, (chain_id, tx_hash, log_index) for the on-chain +// mint. Status transitions are forward-only (plan §3, R06/R13). +export interface MoneriumFiatDepositAttributes { + id: string; + accountId: string; + moneriumOrderId: string; + amountRaw: string; // EURe base units (18 decimals), stringified + currency: string; + status: MoneriumFiatDepositStatus; + chainId: number | null; + txHash: string | null; + logIndex: number | null; + blockHash: string | null; + blockNumber: number | null; + receivedEventAt: Date | null; + convertedEventAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumFiatDepositCreationAttributes = Optional< + MoneriumFiatDepositAttributes, + | "id" + | "status" + | "chainId" + | "txHash" + | "logIndex" + | "blockHash" + | "blockNumber" + | "receivedEventAt" + | "convertedEventAt" + | "createdAt" + | "updatedAt" +>; + +class MoneriumFiatDeposit + extends Model + implements MoneriumFiatDepositAttributes +{ + declare id: string; + declare accountId: string; + declare moneriumOrderId: string; + declare amountRaw: string; + declare currency: string; + declare status: MoneriumFiatDepositStatus; + declare chainId: number | null; + declare txHash: string | null; + declare logIndex: number | null; + declare blockHash: string | null; + declare blockNumber: number | null; + declare receivedEventAt: Date | null; + declare convertedEventAt: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumFiatDeposit.init( + { + accountId: { + allowNull: false, + field: "account_id", + type: DataTypes.UUID + }, + amountRaw: { + allowNull: false, + field: "amount_raw", + type: DataTypes.DECIMAL(38, 0) + }, + blockHash: { + allowNull: true, + field: "block_hash", + type: DataTypes.STRING(66) + }, + // Mint block, set by the mint watcher; the R04 attribution rule compares it to the + // execution block (docs/architecture-monerium-b2b-onramp.md §3). + blockNumber: { + allowNull: true, + field: "block_number", + type: DataTypes.INTEGER + }, + chainId: { + allowNull: true, + field: "chain_id", + type: DataTypes.INTEGER + }, + convertedEventAt: { + allowNull: true, + field: "converted_event_at", + type: DataTypes.DATE + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + currency: { + allowNull: false, + defaultValue: "eur", + type: DataTypes.STRING(8) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + logIndex: { + allowNull: true, + field: "log_index", + type: DataTypes.INTEGER + }, + moneriumOrderId: { + allowNull: false, + field: "monerium_order_id", + type: DataTypes.STRING(64), + unique: true + }, + receivedEventAt: { + allowNull: true, + field: "received_event_at", + type: DataTypes.DATE + }, + status: { + allowNull: false, + defaultValue: MoneriumFiatDepositStatus.Pending, + type: DataTypes.ENUM(...Object.values(MoneriumFiatDepositStatus)) + }, + txHash: { + allowNull: true, + field: "tx_hash", + type: DataTypes.STRING(66) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { fields: ["account_id", "status"] }, + { fields: ["chain_id", "tx_hash", "log_index"], unique: true, where: { tx_hash: { [Op.ne]: null } } } + ], + modelName: "MoneriumFiatDeposit", + sequelize, + tableName: "monerium_fiat_deposits" + } +); + +export default MoneriumFiatDeposit; diff --git a/apps/api/src/models/moneriumWebhookEvent.model.ts b/apps/api/src/models/moneriumWebhookEvent.model.ts new file mode 100644 index 000000000..f4d09248c --- /dev/null +++ b/apps/api/src/models/moneriumWebhookEvent.model.ts @@ -0,0 +1,66 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// Durable inbox for Monerium B2B webhook deliveries (plan §3, R06): rows are inserted +// (dedup on event_id, on conflict do nothing) BEFORE the webhook returns 200 and +// processed asynchronously afterwards. Table created by migration 069. +export interface MoneriumWebhookEventAttributes { + id: string; + eventId: string; + payload: unknown; + processedAt: Date | null; + createdAt: Date; +} + +type MoneriumWebhookEventCreationAttributes = Optional; + +class MoneriumWebhookEvent + extends Model + implements MoneriumWebhookEventAttributes +{ + declare id: string; + declare eventId: string; + declare payload: unknown; + declare processedAt: Date | null; + declare createdAt: Date; +} + +MoneriumWebhookEvent.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + eventId: { + allowNull: false, + field: "event_id", + type: DataTypes.STRING(128), + unique: true + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + payload: { + allowNull: false, + type: DataTypes.JSONB + }, + processedAt: { + allowNull: true, + field: "processed_at", + type: DataTypes.DATE + } + }, + { + modelName: "MoneriumWebhookEvent", + sequelize, + tableName: "monerium_webhook_events", + // The inbox table has no updated_at column (append + processed_at flip only). + updatedAt: false + } +); + +export default MoneriumWebhookEvent; diff --git a/apps/api/src/models/webhook.model.ts b/apps/api/src/models/webhook.model.ts index e12da9e9a..c480283c5 100644 --- a/apps/api/src/models/webhook.model.ts +++ b/apps/api/src/models/webhook.model.ts @@ -59,7 +59,7 @@ Webhook.init( throw new Error("events must be a non-empty array"); } - const validEvents: WebhookEventType[] = [WebhookEventType.TRANSACTION_CREATED, WebhookEventType.STATUS_CHANGE]; + const validEvents: WebhookEventType[] = Object.values(WebhookEventType); for (const event of value) { if (!validEvents.includes(event)) { throw new Error(`Invalid event type: ${event}`); diff --git a/apps/api/src/models/webhookDelivery.model.ts b/apps/api/src/models/webhookDelivery.model.ts new file mode 100644 index 000000000..2bb43e064 --- /dev/null +++ b/apps/api/src/models/webhookDelivery.model.ts @@ -0,0 +1,76 @@ +import { WebhookPayload } from "@vortexfi/shared"; +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export enum WebhookDeliveryStatus { + Pending = "pending", + Sending = "sending", + Sent = "sent", + Abandoned = "abandoned" +} + +// Durable outbox row for one (webhook, event) delivery of the account-scoped event +// family. The unique (webhook_id, event_id) pair makes enqueueing idempotent; the +// dispatch worker claims rows, sends with backoff, and abandons after the cap. +export interface WebhookDeliveryAttributes { + id: string; + webhookId: string; + eventId: string; + eventType: string; + payload: WebhookPayload; + status: WebhookDeliveryStatus; + attempts: number; + nextAttemptAt: Date; + sentAt: Date | null; + lastError: string | null; + createdAt: Date; + updatedAt: Date; +} + +type WebhookDeliveryCreationAttributes = Optional< + WebhookDeliveryAttributes, + "id" | "status" | "attempts" | "nextAttemptAt" | "sentAt" | "lastError" | "createdAt" | "updatedAt" +>; + +class WebhookDelivery + extends Model + implements WebhookDeliveryAttributes +{ + declare id: string; + declare webhookId: string; + declare eventId: string; + declare eventType: string; + declare payload: WebhookPayload; + declare status: WebhookDeliveryStatus; + declare attempts: number; + declare nextAttemptAt: Date; + declare sentAt: Date | null; + declare lastError: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +WebhookDelivery.init( + { + attempts: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + eventId: { allowNull: false, field: "event_id", type: DataTypes.STRING(128) }, + eventType: { allowNull: false, field: "event_type", type: DataTypes.STRING(64) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + lastError: { allowNull: true, field: "last_error", type: DataTypes.TEXT }, + nextAttemptAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "next_attempt_at", type: DataTypes.DATE }, + payload: { allowNull: false, type: DataTypes.JSONB }, + sentAt: { allowNull: true, field: "sent_at", type: DataTypes.DATE }, + status: { allowNull: false, defaultValue: WebhookDeliveryStatus.Pending, type: DataTypes.STRING(16) }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE }, + webhookId: { allowNull: false, field: "webhook_id", type: DataTypes.UUID } + }, + { + indexes: [{ fields: ["status", "next_attempt_at"] }], + modelName: "WebhookDelivery", + sequelize, + tableName: "webhook_deliveries" + } +); + +export default WebhookDelivery; diff --git a/apps/api/src/test-utils/contract-support.ts b/apps/api/src/test-utils/contract-support.ts index ffe6e77d4..1b8662941 100644 --- a/apps/api/src/test-utils/contract-support.ts +++ b/apps/api/src/test-utils/contract-support.ts @@ -22,6 +22,14 @@ export async function runLive(label: string, call: () => Promise): Promise return result; } catch (error) { if (error instanceof ZodError) throw error; + if ( + typeof error === "object" && + error !== null && + "providerContractViolation" in error && + error.providerContractViolation === true + ) { + throw error; + } console.warn(`[contract:live] ${label} inconclusive: ${error instanceof Error ? error.message : String(error)}`); return null; } diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 32546be4d..41914c250 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -30,6 +30,17 @@ if (!process.env.RUN_LIVE_TESTS) { process.env.ALFREDPAY_BASE_URL = "http://alfredpay.invalid"; process.env.ALFREDPAY_API_KEY = "test-alfredpay-api-key"; process.env.ALFREDPAY_API_SECRET = "test-alfredpay-api-secret"; + process.env.MONERIUM_API_URL = "http://monerium.invalid"; + process.env.MONERIUM_WHITELABEL_CLIENT_ID = "test-monerium-whitelabel-client-id"; + process.env.MONERIUM_WHITELABEL_CLIENT_SECRET = "test-monerium-whitelabel-client-secret"; + process.env.MONERIUM_B2B_ENABLED = "true"; + process.env.MONERIUM_B2B_ATTESTOR_PRIVATE_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + process.env.MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000001"; + process.env.MONERIUM_B2B_GUARDIAN_PRIVATE_KEY = "0x2222222222222222222222222222222222222222222222222222222222222222"; + process.env.MONERIUM_B2B_KEEPER_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + process.env.MONERIUM_B2B_RPC_URL = "http://evm.invalid"; + process.env.MONERIUM_B2B_PRIVATE_RPC_URL = "http://evm-private.invalid"; + process.env.MONERIUM_B2B_WEBHOOK_SECRET = "whsec_dGVzdC1tb25lcml1bS13ZWJob29rLXNlY3JldA=="; // COINGECKO_API_URL is deliberately NOT overridden: priceFeed config tests // assert its default, and the fetch guard blocks real calls anyway. process.env.ALCHEMY_API_KEY = ""; diff --git a/apps/api/src/tests/contracts/monerium.contract.test.ts b/apps/api/src/tests/contracts/monerium.contract.test.ts new file mode 100644 index 000000000..3767fc602 --- /dev/null +++ b/apps/api/src/tests/contracts/monerium.contract.test.ts @@ -0,0 +1,316 @@ +/** + * External API contract: Monerium white-label API v2 (docs/operations-testing.md). + * + * Read-only live checks require MONERIUM_WHITELABEL_CLIENT_ID/MONERIUM_WHITELABEL_CLIENT_SECRET and may use: + * - MONERIUM_CONTRACT_PROFILE_ID + * - MONERIUM_CONTRACT_ADDRESS + * - MONERIUM_CONTRACT_IBAN + * - MONERIUM_CONTRACT_ORDER_ID + * + * Mutating checks are prepared but independently opt-in because they create persistent + * sandbox resources or can move sandbox EURe: + * - MONERIUM_CONTRACT_RUN_ADDRESS_FLOW=1 plus PROFILE_ID, ADDRESS, ADDRESS_CHAIN, ADDRESS_SIGNATURE + * - MONERIUM_CONTRACT_RUN_IBAN_FLOW=1 plus ADDRESS and ADDRESS_CHAIN + * - MONERIUM_CONTRACT_RUN_ORDER_FLOW=1 plus MONERIUM_CONTRACT_ORDER_REQUEST_JSON + * - MONERIUM_CONTRACT_RUN_FILE_UPLOAD=1 + * - MONERIUM_CONTRACT_RUN_WEBHOOK_FLOW=1 plus WEBHOOK_URL and WEBHOOK_SECRET + * + * The address signature is sent unchanged. For a Safe off-chain EIP-1271 flow it must be + * the combined owner-signature bytes assembled externally; there is no extra endpoint. + */ +import { describe, expect, test } from "bun:test"; +import { + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + type MoneriumChain, + MoneriumApiService, + MoneriumContractError, + type MoneriumRedeemOrderRequest, + moneriumAddressSchema, + moneriumIbanSchema, + moneriumListAddressesResponseSchema, + moneriumListIbansResponseSchema, + moneriumListOrdersResponseSchema, + moneriumListProfilesResponseSchema, + moneriumOrderSchema, + moneriumProfileSchema, + moneriumRedeemOrderRequestSchema, + moneriumUploadedFileSchema, + moneriumWebhookEventSchema, + moneriumWebhookSubscriptionSchema, + moneriumListWebhooksResponseSchema +} from "@vortexfi/shared"; +import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; + +const RUN_LIVE = process.env.RUN_LIVE_TESTS === "1"; +const HAS_CREDS = !!( + process.env.MONERIUM_WHITELABEL_CLIENT_ID && process.env.MONERIUM_WHITELABEL_CLIENT_SECRET +); +const PROFILE_ID = process.env.MONERIUM_CONTRACT_PROFILE_ID; +const ADDRESS = process.env.MONERIUM_CONTRACT_ADDRESS; +const ADDRESS_CHAIN = process.env.MONERIUM_CONTRACT_ADDRESS_CHAIN as MoneriumChain | undefined; +const IBAN = process.env.MONERIUM_CONTRACT_IBAN; +const ORDER_ID = process.env.MONERIUM_CONTRACT_ORDER_ID; +const RUN_ADDRESS_FLOW = process.env.MONERIUM_CONTRACT_RUN_ADDRESS_FLOW === "1"; +const RUN_IBAN_FLOW = process.env.MONERIUM_CONTRACT_RUN_IBAN_FLOW === "1"; +const RUN_ORDER_FLOW = process.env.MONERIUM_CONTRACT_RUN_ORDER_FLOW === "1"; +const RUN_FILE_UPLOAD = process.env.MONERIUM_CONTRACT_RUN_FILE_UPLOAD === "1"; +const RUN_WEBHOOK_FLOW = process.env.MONERIUM_CONTRACT_RUN_WEBHOOK_FLOW === "1"; + +const FIXTURE_PROFILE_ID = "123e4567-e89b-42d3-a456-426614174000"; +const FIXTURE_RESOURCE_ID = "223e4567-e89b-42d3-a456-426614174001"; +const FIXTURE_ADDRESS = "0x59cFC408d310697f9D3598e1BE75B0157a072407"; +const FIXTURE_IBAN = "EE521273842688571285"; + +if (RUN_LIVE && !HAS_CREDS) { + console.warn( + "[contract:live] Monerium live half skipped: " + + "MONERIUM_WHITELABEL_CLIENT_ID/MONERIUM_WHITELABEL_CLIENT_SECRET not set" + ); +} + +function assertSandboxMutationTarget(rawUrl = process.env.MONERIUM_API_URL): void { + if (!rawUrl) throw new Error("MONERIUM_API_URL must be set explicitly for mutating contract tests"); + const url = new URL(rawUrl); + if (url.origin !== "https://api.monerium.dev" || (url.pathname !== "/" && url.pathname !== "")) { + throw new Error("Mutating Monerium contract tests require exactly https://api.monerium.dev"); + } +} + +function orderFixture() { + return { + address: FIXTURE_ADDRESS, + amount: "100.00", + chain: "ethereum", + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: FIXTURE_IBAN, standard: "iban" } + }, + currency: "eur", + id: FIXTURE_RESOURCE_ID, + kind: "redeem", + memo: "Powered by Monerium", + meta: { placedAt: "2026-08-25T12:00:00Z" }, + profile: FIXTURE_PROFILE_ID, + state: "placed" + }; +} + +describe("Monerium external API contract - hermetic fixtures", () => { + test("profile, address, and IBAN responses satisfy consumed contracts", () => { + const profile = { + details: { state: "approved" }, + form: { state: "approved" }, + id: FIXTURE_PROFILE_ID, + kind: "personal", + name: "Jane Doe", + state: "approved", + verifications: [{ kind: "idDocument", state: "approved" }] + }; + expect(() => moneriumListProfilesResponseSchema.parse({ profiles: [profile] })).not.toThrow(); + expect(() => moneriumProfileSchema.parse(profile)).not.toThrow(); + expect(() => + moneriumListAddressesResponseSchema.parse({ + addresses: [{ address: FIXTURE_ADDRESS, chains: ["ethereum"], profile: FIXTURE_PROFILE_ID }] + }) + ).not.toThrow(); + expect(() => + moneriumListIbansResponseSchema.parse({ + ibans: [ + { + address: FIXTURE_ADDRESS, + bic: "CBHFLU2LXXX", + chain: "ethereum", + iban: FIXTURE_IBAN, + name: "Jane Doe", + profile: FIXTURE_PROFILE_ID + } + ] + }) + ).not.toThrow(); + }); + + test("redeem request and order response preserve signing semantics", () => { + const timestamp = `${new Date(Date.now() + 60_000).toISOString().slice(0, 16)}Z`; + const request = { + address: FIXTURE_ADDRESS, + amount: "100.00", + chain: "ethereum", + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: FIXTURE_IBAN, standard: "iban" } + }, + currency: "eur", + kind: "redeem", + message: `Send EUR 100.00 to ${FIXTURE_IBAN} at ${timestamp}`, + signature: `0x${"ab".repeat(65)}` + }; + expect(() => moneriumRedeemOrderRequestSchema.parse(request)).not.toThrow(); + expect(() => moneriumListOrdersResponseSchema.parse({ orders: [orderFixture()] })).not.toThrow(); + }); + + test("webhook event fixtures preserve event discriminators", () => { + expect(() => + moneriumWebhookEventSchema.parse({ + data: orderFixture(), + timestamp: "2026-08-25T12:01:00Z", + type: "order.updated" + }) + ).not.toThrow(); + expect(() => + moneriumWebhookEventSchema.parse({ + data: { id: FIXTURE_PROFILE_ID, kind: "personal", state: "approved" }, + timestamp: "2026-08-25T12:01:00Z", + type: "profile.updated" + }) + ).not.toThrow(); + expect(() => + moneriumWebhookEventSchema.parse({ + data: { + address: FIXTURE_ADDRESS, + chain: "ethereum", + iban: "EE52 1273 8426 8857 1285", + profile: FIXTURE_PROFILE_ID, + state: "approved" + }, + timestamp: "2026-08-25T12:01:00Z", + type: "iban.updated" + }) + ).not.toThrow(); + }); + + test("provider contract violations fail instead of becoming inconclusive", async () => { + await expect( + runLive("monerium malformed success", async () => { + throw new MoneriumContractError("GET /profiles"); + }) + ).rejects.toBeInstanceOf(MoneriumContractError); + }); + + test("mutating checks refuse the production API", () => { + expect(() => assertSandboxMutationTarget("https://api.monerium.app")).toThrow(); + expect(() => assertSandboxMutationTarget("https://api.monerium.dev/v2")).toThrow(); + expect(() => assertSandboxMutationTarget("https://api.monerium.dev")).not.toThrow(); + }); +}); + +describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Monerium external API contract - live sandbox", () => { + const api = () => MoneriumApiService.getInstance(); + + test("GET profile collection and fixture profile satisfy API v2 contracts", async () => { + const profiles = await runLive("monerium listProfiles", () => api().listProfiles()); + if (!profiles) return; + const parsed = moneriumListProfilesResponseSchema.parse(profiles); + + const profileId = PROFILE_ID ?? parsed.profiles[0]?.id; + if (!profileId) return; + const profile = await runLive("monerium getProfile", () => api().getProfile(profileId)); + if (profile) moneriumProfileSchema.parse(profile); + }); + + test("GET address, IBAN, and order collections satisfy API v2 contracts", async () => { + const addresses = await runLive("monerium listAddresses", () => api().listAddresses({ profile: PROFILE_ID })); + if (addresses) moneriumListAddressesResponseSchema.parse(addresses); + + const ibans = await runLive("monerium listIbans", () => api().listIbans({ profile: PROFILE_ID })); + if (ibans) moneriumListIbansResponseSchema.parse(ibans); + + const orders = await runLive("monerium listOrders", () => api().listOrders({ profile: PROFILE_ID })); + if (orders) moneriumListOrdersResponseSchema.parse(orders); + }); + + test.skipIf(!ADDRESS)("GET fixture address satisfies the address contract", async () => { + const address = await runLive("monerium getAddress", () => api().getAddress(ADDRESS as string)); + if (address) moneriumAddressSchema.parse(address); + }); + + test.skipIf(!IBAN)("GET fixture IBAN satisfies the IBAN contract", async () => { + const iban = await runLive("monerium getIban", () => api().getIban(IBAN as string)); + if (iban) moneriumIbanSchema.parse(iban); + }); + + test.skipIf(!ORDER_ID)("GET fixture order satisfies the order contract", async () => { + const order = await runLive("monerium getOrder", () => api().getOrder(ORDER_ID as string)); + if (order) moneriumOrderSchema.parse(order); + }); + + test.skipIf(!RUN_ADDRESS_FLOW || !PROFILE_ID || !ADDRESS || !ADDRESS_CHAIN || !process.env.MONERIUM_CONTRACT_ADDRESS_SIGNATURE)( + "POST /addresses accepts the prepared ownership proof", + async () => { + assertSandboxMutationTarget(); + const result = await runLive("monerium linkAddress", () => + api().linkAddress({ + address: ADDRESS as string, + chain: ADDRESS_CHAIN as MoneriumChain, + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID as string, + signature: process.env.MONERIUM_CONTRACT_ADDRESS_SIGNATURE as string + }) + ); + if (result) expect([201, 202]).toContain(result.httpStatus); + } + ); + + test.skipIf(!RUN_IBAN_FLOW || !ADDRESS || !ADDRESS_CHAIN)( + "POST /ibans preserves accepted and already-provisioned semantics", + async () => { + assertSandboxMutationTarget(); + const result = await runLive("monerium requestIban", () => + api().requestIban({ address: ADDRESS as string, chain: ADDRESS_CHAIN as MoneriumChain }) + ); + if (result) expect([202, 304]).toContain(result.httpStatus); + } + ); + + test.skipIf(!RUN_ORDER_FLOW || !process.env.MONERIUM_CONTRACT_ORDER_REQUEST_JSON)( + "POST /orders accepts a freshly signed sandbox redemption", + async () => { + assertSandboxMutationTarget(); + const request = moneriumRedeemOrderRequestSchema.parse( + JSON.parse(process.env.MONERIUM_CONTRACT_ORDER_REQUEST_JSON as string) + ) as MoneriumRedeemOrderRequest; + const result = await runLive("monerium createRedemptionOrder", () => api().createRedemptionOrder(request)); + if (result?.httpStatus === 200) moneriumOrderSchema.parse(result.order); + if (result) expect([200, 202]).toContain(result.httpStatus); + } + ); + + test.skipIf(!RUN_FILE_UPLOAD)("POST /files accepts the documented multipart contract", async () => { + assertSandboxMutationTarget(); + const pdf = new Blob(["%PDF-1.4\n%%EOF\n"], { type: "application/pdf" }); + const uploaded = await runLive("monerium uploadFile", () => api().uploadFile(pdf, "vortex-contract-test.pdf")); + if (uploaded) moneriumUploadedFileSchema.parse(uploaded); + }); + + test.skipIf( + !RUN_WEBHOOK_FLOW || !process.env.MONERIUM_CONTRACT_WEBHOOK_URL || !process.env.MONERIUM_CONTRACT_WEBHOOK_SECRET + )("POST + GET + PATCH /webhooks create and deactivate a subscription", async () => { + assertSandboxMutationTarget(); + let subscriptionId: string | undefined; + try { + const created = await runLive("monerium createWebhook", () => + api().createWebhook({ + secret: process.env.MONERIUM_CONTRACT_WEBHOOK_SECRET as string, + types: ["profile.updated", "profile.error", "iban.updated", "order.created", "order.updated"], + url: process.env.MONERIUM_CONTRACT_WEBHOOK_URL as string + }) + ); + if (!created) return; + subscriptionId = moneriumWebhookSubscriptionSchema.parse(created).id; + + const subscriptions = await runLive("monerium listWebhooks", () => api().listWebhooks()); + if (subscriptions) { + const parsed = moneriumListWebhooksResponseSchema.parse(subscriptions); + expect(parsed.subscriptions.map(subscription => subscription.id)).toContain(subscriptionId); + } + } finally { + if (subscriptionId) { + await runLive("monerium deactivateWebhook", () => api().updateWebhook(subscriptionId as string, { state: "inactive" })); + } + } + }); +}); + +// Monerium is not in contracts.yml until sandbox white-label credentials and fixtures are provisioned. +test.skipIf(!RUN_LIVE || !HAS_CREDS)("live contract coverage actually ran", () => { + assertLiveCoverage(); +}); diff --git a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts new file mode 100644 index 000000000..cbcaef1e9 --- /dev/null +++ b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts @@ -0,0 +1,209 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type { CorridorCountry } from "@vortexfi/shared"; +import { config } from "../config/vars"; +import ManagedProfileManager from "../models/managedProfileManager.model"; +import MoneriumConversionExecution, { MoneriumConversionExecutionStatus } from "../models/moneriumConversionExecution.model"; +import MoneriumDepositAllocation from "../models/moneriumDepositAllocation.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../models/moneriumFiatDeposit.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestApiKey, createTestUser } from "../test-utils/factories"; +import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; +import { provisionMoneriumB2bAccount } from "../api/services/monerium-b2b/account-provisioning"; + +const FORWARDER = "0x1111111111111111111111111111111111111111"; +const DESTINATION = "0x2222222222222222222222222222222222222222"; +const FALLBACK = "0x3333333333333333333333333333333333333333"; +const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; + +describe("monerium b2b account read surface", () => { + let app: TestApp; + let world: FakeWorld; + let originalRpcUrl: string | undefined; + + beforeAll(async () => { + originalRpcUrl = config.moneriumB2b.rpcUrl; + config.moneriumB2b.rpcUrl = undefined; + world = installFakeWorld(); + await setupTestDatabase(); + app = await startTestApp(); + }); + + afterAll(async () => { + config.moneriumB2b.rpcUrl = originalRpcUrl; + await app?.close(); + world?.restore(); + }); + + beforeEach(async () => { + await resetTestDatabase(); + }); + + async function jsonRequest(path: string, headers: Record): Promise<{ body: Record; status: number }> { + const response = await app.request(path, { headers, method: "GET" }); + const text = await response.text(); + return { body: text ? (JSON.parse(text) as Record) : {}, status: response.status }; + } + + async function setupMappedChild(corridors: CorridorCountry[] = ["EU"]) { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: corridors, + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + const managerCredential = await createTestApiKey({ userId: manager.id }); + const mapped = await provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: DESTINATION, + externalSubjectId: "client-1", + fallbackAddress: FALLBACK, + forwarderAddress: FORWARDER, + managerProfileId: manager.id, + moneriumProfileId: MONERIUM_PROFILE + }); + return { + delegatedHeaders: { "X-API-Key": managerCredential.plaintextKey, "X-Managed-Profile-Id": mapped.profileId }, + managerHeaders: { "X-API-Key": managerCredential.plaintextKey }, + mapped + }; + } + + it("returns the acting child's account and deposit history with conversion status", async () => { + const { delegatedHeaders, mapped } = await setupMappedChild(); + + const account = await jsonRequest("/v1/monerium-b2b/account", delegatedHeaders); + expect(account.status).toBe(200); + expect(account.body.account).toMatchObject({ + accountId: mapped.accountId, + destination: DESTINATION, + fallbackAddress: FALLBACK, + feeBps: 0, + forwarderAddress: FORWARDER, + iban: null, + status: "onboarding" + }); + + const execution = await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + destination: DESTINATION, + eureInRaw: "100000000000000000000", + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + const convertedDeposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + currency: "eur", + amountRaw: "100000000000000000000", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + await MoneriumDepositAllocation.create({ + depositId: convertedDeposit.id, + eureInRaw: "100000000000000000000", + executionId: execution.id, + usdcNetRaw: "108000000" + }); + await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + currency: "eur", + amountRaw: "50000000000000000000", + moneriumOrderId: "order-2", + status: MoneriumFiatDepositStatus.Pending + }); + // R09 synthetic unattributed inflow: ops-only, must never appear in the API. + await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + currency: "eur", + amountRaw: "1000000000000000000", + moneriumOrderId: "unattr:1:0xdead:0", + status: MoneriumFiatDepositStatus.Minted + }); + + const deposits = await jsonRequest("/v1/monerium-b2b/deposits", delegatedHeaders); + expect(deposits.status).toBe(200); + const rows = deposits.body.deposits as Array>; + expect(rows).toHaveLength(2); + expect(rows.map(row => row.status)).toEqual(["pending", "minted"]); + expect(rows[1]).toMatchObject({ + amountRaw: "100000000000000000000", + conversions: [ + { + eureInRaw: "100000000000000000000", + executionId: execution.id, + status: "confirmed", + txHash: "0xswap", + usdcNetRaw: "108000000" + } + ], + txHash: "0xmint", + usdcNetRaw: "108000000" + }); + expect(rows[0]).toMatchObject({ conversions: [], usdcNetRaw: "0" }); + expect(deposits.body.pagination).toMatchObject({ total: 2 }); + }); + + it("rejects delegation for a manager without the EU corridor", async () => { + const { delegatedHeaders } = await setupMappedChild(["BR"]); + const response = await jsonRequest("/v1/monerium-b2b/account", delegatedHeaders); + expect(response.status).toBe(403); + }); + + it("rejects a foreign manager and unauthenticated callers", async () => { + const { mapped } = await setupMappedChild(); + + const stranger = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: stranger.id + }); + const strangerCredential = await createTestApiKey({ userId: stranger.id }); + const foreign = await jsonRequest("/v1/monerium-b2b/account", { + "X-API-Key": strangerCredential.plaintextKey, + "X-Managed-Profile-Id": mapped.profileId + }); + expect(foreign.status).toBe(403); + + const unauthenticated = await app.request("/v1/monerium-b2b/account", { method: "GET" }); + expect(unauthenticated.status).toBe(401); + }); + + it("returns 404 for an authenticated profile without a mapped account", async () => { + const { managerHeaders } = await setupMappedChild(); + const response = await jsonRequest("/v1/monerium-b2b/account", managerHeaders); + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ error: { code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND" } }); + }); + + // Regression: the controller used to demand quoteId/sessionId before the service's + // account-family branch could run, making this documented registration impossible. + it("registers a deposit-event webhook over HTTP without a quote or session", async () => { + const { managerHeaders } = await setupMappedChild(); + + const response = await app.request("/v1/webhook", { + body: JSON.stringify({ + events: ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"], + url: "https://manager.example.com/vortex/deposits" + }), + headers: { "Content-Type": "application/json", ...managerHeaders }, + method: "POST" + }); + expect(response.status).toBe(201); + const body = (await response.json()) as { id: string; events: string[]; quoteId: string | null }; + expect(body.events).toEqual(["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"]); + expect(body.quoteId).toBeNull(); + + // The transaction-family requirement still holds at the same HTTP surface. + const legacyWithoutTarget = await app.request("/v1/webhook", { + body: JSON.stringify({ url: "https://manager.example.com/vortex/tx" }), + headers: { "Content-Type": "application/json", ...managerHeaders }, + method: "POST" + }); + expect(legacyWithoutTarget.status).toBe(400); + }); +}); diff --git a/apps/frontend/public/og-image.png b/apps/frontend/public/og-image.png new file mode 100644 index 000000000..c9e103910 Binary files /dev/null and b/apps/frontend/public/og-image.png differ diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx index 107254fb1..7817eecd6 100644 --- a/apps/frontend/src/routes/__root.tsx +++ b/apps/frontend/src/routes/__root.tsx @@ -21,6 +21,10 @@ import { PersistentRampStateProvider } from "../contexts/rampState"; import { Language } from "../translations/helpers"; import { wagmiConfig } from "../wagmiConfig"; +const SITE_URL = "https://www.vortexfinance.co"; +const SITE_DESCRIPTION = + "Buy and sell crypto. Fast, secure, best rates. Vortex handles KYC, compliance and settlement through fiat and stablecoin routes."; + const GTM_ID = "GTM-T8JZSLD8"; const GTM_SNIPPET = `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': @@ -45,7 +49,18 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()( rel: "stylesheet" } ], - meta: [{ charSet: "utf-8" }, { content: "width=device-width, initial-scale=1.0", name: "viewport" }, { title: "Vortex" }], + meta: [ + { charSet: "utf-8" }, + { content: "width=device-width, initial-scale=1.0", name: "viewport" }, + { title: "Vortex" }, + { content: SITE_DESCRIPTION, name: "description" }, + { content: "Vortex", property: "og:title" }, + { content: SITE_DESCRIPTION, property: "og:description" }, + { content: "website", property: "og:type" }, + { content: SITE_URL, property: "og:url" }, + { content: `${SITE_URL}/og-image.png`, property: "og:image" }, + { content: "summary_large_image", name: "twitter:card" } + ], scripts: [{ children: GTM_SNIPPET }] }), shellComponent: RootDocument diff --git a/biome.json b/biome.json index e45e3c4a7..9c7e46e81 100644 --- a/biome.json +++ b/biome.json @@ -23,6 +23,7 @@ "!**/gql/**", "!**/lottie/**", "!**/storybook-static/**", + "!contracts/monerium-forwarder/lib/**", "!contracts/relayer/artifacts/**", "!contracts/relayer/cache/**", "!contracts/relayer/ignition/deployments/**", diff --git a/bun.lock b/bun.lock index c5a4b32fd..be70e1efb 100644 --- a/bun.lock +++ b/bun.lock @@ -322,6 +322,13 @@ "typescript": "catalog:", }, }, + "contracts/monerium-forwarder": { + "name": "@vortexfi/contracts-monerium-forwarder", + "version": "0.1.0", + "devDependencies": { + "viem": "catalog:", + }, + }, "contracts/relayer": { "name": "@vortexfi/contracts-relayer", "version": "1.0.0", @@ -2208,6 +2215,8 @@ "@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + "@vortexfi/contracts-monerium-forwarder": ["@vortexfi/contracts-monerium-forwarder@workspace:contracts/monerium-forwarder"], + "@vortexfi/contracts-relayer": ["@vortexfi/contracts-relayer@workspace:contracts/relayer"], "@vortexfi/kyc": ["@vortexfi/kyc@workspace:packages/kyc"], @@ -4736,7 +4745,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], @@ -4880,12 +4889,8 @@ "@graphql-tools/executor-graphql-ws/@graphql-tools/executor-common": ["@graphql-tools/executor-common@0.0.6", "", { "dependencies": { "@envelop/core": "^5.3.0", "@graphql-tools/utils": "^10.9.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow=="], - "@graphql-tools/executor-graphql-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@graphql-tools/executor-legacy-ws/@graphql-tools/utils": ["@graphql-tools/utils@11.2.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-eu9h1R3j/wWc4rvmYJF5AKtlwniDzstrZ/c6KSz+HdI+n7I7iog9xyKmBfpUwSbG1TqPNZBzWjFMkzdYOKq6Bg=="], - "@graphql-tools/executor-legacy-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@graphql-tools/git-loader/@graphql-tools/utils": ["@graphql-tools/utils@11.2.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-eu9h1R3j/wWc4rvmYJF5AKtlwniDzstrZ/c6KSz+HdI+n7I7iog9xyKmBfpUwSbG1TqPNZBzWjFMkzdYOKq6Bg=="], "@graphql-tools/github-loader/sync-fetch": ["sync-fetch@0.6.0-2", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A=="], @@ -4914,8 +4919,6 @@ "@graphql-tools/url-loader/sync-fetch": ["sync-fetch@0.6.0-2", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A=="], - "@graphql-tools/url-loader/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "@inquirer/core/mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], @@ -4994,8 +4997,6 @@ "@polkadot/x-ws/@polkadot/x-global": ["@polkadot/x-global@14.0.3", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-MzMEynJ7HMTy/plLmdyP8rv14RS/6s29HZodUG9aCOscBnEiEDxVEax/ztRJqxhhQuHeYdx0LYDwVbdQDTkqNw=="], - "@polkadot/x-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@reown/appkit/@walletconnect/universal-provider": ["@walletconnect/universal-provider@2.23.7", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/jsonrpc-http-connection": "1.0.8", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "3.0.2", "@walletconnect/sign-client": "2.23.7", "@walletconnect/types": "2.23.7", "@walletconnect/utils": "2.23.7", "es-toolkit": "1.44.0", "events": "3.3.0" } }, "sha512-6UicU/Mhr/1bh7MNoajypz7BhigORbHpP1LFTf8FYLQGDqzmqHMqmMH2GDAImtaY2sFTi2jBvc22tLl8VMze/A=="], "@reown/appkit/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -5064,8 +5065,6 @@ "@solana/errors/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], - "@solana/rpc-subscriptions-channel-websocket/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@solana/rpc-transport-http/undici-types": ["undici-types@7.28.0", "", {}, "sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ=="], "@storybook/csf-plugin/unplugin": ["unplugin@1.16.1", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w=="], @@ -5162,6 +5161,8 @@ "@walletconnect/jsonrpc-utils/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "@walletconnect/jsonrpc-ws-connection/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + "@walletconnect/modal-core/valtio": ["valtio@1.11.2", "", { "dependencies": { "proxy-compare": "2.5.1", "use-sync-external-store": "1.2.0" }, "peerDependencies": { "@types/react": ">=16.8", "react": ">=16.8" }, "optionalPeers": ["@types/react", "react"] }, "sha512-1XfIxnUXzyswPAPXo1P3Pdx2mq/pIqZICkWN60Hby0d9Iqb+MEIpqgYVlbflvHdrp2YR/q3jyKWRPJJ100yxaw=="], "@walletconnect/modal-ui/lit": ["lit@2.8.0", "", { "dependencies": { "@lit/reactive-element": "^1.6.0", "lit-element": "^3.3.0", "lit-html": "^2.8.0" } }, "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA=="], @@ -5270,8 +5271,6 @@ "elliptic/bn.js": ["bn.js@4.12.4", "", {}, "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g=="], - "engine.io-client/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "escodegen/esprima": ["esprima@2.7.3", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-OarPfz0lFCiW4/AV2Oy1Rp9qu0iusTKqykwTspGCZtPxmF81JR4MmIebvF1F9+UOKth2ZubLQ4XGGaU+hSn99A=="], "escodegen/estraverse": ["estraverse@1.9.3", "", {}, "sha512-25w1fMXQrGdoquWnScXZGckOv+Wes+JDnuN/+7ex3SauFRS72r2lFDec0EKPt2YD1wUJ/IrfEex+9yp4hfSOJA=="], @@ -5314,8 +5313,6 @@ "ethers/tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="], - "ethers/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "ethjs-unit/bn.js": ["bn.js@4.11.6", "", {}, "sha512-XWwnNNFCuuSQ0m3r3C4LE3EiORltHd9M05pq6FOlVeiophzRbMo50Sbz1ehl8K3Z+jw9+vmgnXefY1hz8X+2wA=="], "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -5360,6 +5357,8 @@ "hardhat/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "hardhat/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "http-basic/concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="], @@ -5380,8 +5379,6 @@ "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], - "jsdom/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "json-rpc-engine/@metamask/safe-event-emitter": ["@metamask/safe-event-emitter@2.0.0", "", {}, "sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q=="], "keccak/node-addon-api": ["node-addon-api@2.0.2", "", {}, "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA=="], @@ -5534,8 +5531,6 @@ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "smoldot/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "solc/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "solc/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], @@ -5556,8 +5551,6 @@ "storybook/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "storybook/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "strip-dirs/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], @@ -5604,8 +5597,6 @@ "viem/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], - "viem/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], @@ -5634,8 +5625,6 @@ "web3-providers-http/cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], - "web3-providers-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "web3-utils/ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="], "web3-validator/ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="], @@ -5982,8 +5971,6 @@ "graphql-config/@graphql-tools/url-loader/@graphql-tools/wrap": ["@graphql-tools/wrap@11.1.17", "", { "dependencies": { "@graphql-tools/delegate": "^12.0.18", "@graphql-tools/schema": "^10.0.29", "@graphql-tools/utils": "^11.0.0", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-eY5dh9ZewdzSbYNtrfhXvpaKvMyGNCGrH2VBMTs5hnyE9tncsmZjcaVRW+CGiUJUIp184D+FYrK7tmW/hUm0dQ=="], - "graphql-config/@graphql-tools/url-loader/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "graphql-config/minimatch/brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "hardhat/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], diff --git a/contracts/README.md b/contracts/README.md index 23423fb7c..ae5a70c9b 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -15,3 +15,4 @@ This directory contains smart-contract projects managed as Bun workspaces. ## Current projects - `relayer/` - Relayer contract project (Hardhat) +- `monerium-forwarder/` - Attestor-linked forwarder for the Monerium B2B onramp (Foundry) diff --git a/contracts/monerium-forwarder/.gitignore b/contracts/monerium-forwarder/.gitignore new file mode 100644 index 000000000..83f939c72 --- /dev/null +++ b/contracts/monerium-forwarder/.gitignore @@ -0,0 +1,3 @@ +out/ +cache/ +broadcast/ diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md new file mode 100644 index 000000000..0d7517c65 --- /dev/null +++ b/contracts/monerium-forwarder/README.md @@ -0,0 +1,46 @@ +# Monerium B2B Onramp Forwarder + +Foundry project for the attestor-linked forwarder (Monerium B2B zero-touch onramp): +per-client EIP-1167 clones whose EIP-1271 `isValidSignature` accepts only the fixed +Monerium link message from the Vortex attestor, with an immutable EURe→EURC→USDC +conversion policy and client-controlled recovery. + +- Spec: [docs/architecture-monerium-b2b-onramp.md](../../docs/architecture-monerium-b2b-onramp.md) §2 +- Parameter values (slippage, delays, caps, fee) are decided in + [docs/adr-0005-monerium-b2b-onramp.md](../../docs/adr-0005-monerium-b2b-onramp.md) — + that table, not values hardcoded in tests or scripts, is authoritative. + +```bash +git submodule update --init # once per clone: fetches lib/forge-std +forge build +forge test # unit tests (mocks) +ETH_RPC_URL=... forge test # + mainnet fork tests (pending, task 2) +``` + +Linted by `forge fmt`/forge-lint, not Biome (the TypeScript in `script/` is Biome-governed). + +## Config manifest (D3) + +`script/generate-manifest.ts` emits a versioned JSON manifest for a factory deployment +(bytecode hashes, all immutables, per-clone config, deploy-tx provenance); +`script/verify-manifest.ts` re-checks every field against live chain state and exits +nonzero with a field-level diff on mismatch. Runnable by third parties from a repo +checkout (`bun install` once for viem): + +```bash +bun script/generate-manifest.ts [outFile] [--logs-rpc ] +bun script/verify-manifest.ts [--logs-rpc ] +``` + +`--logs-rpc`: many free public RPCs refuse historical `eth_getLogs` ("archive" gating); +pass a logs-capable endpoint for the ForwarderDeployed enumeration — all other reads, +including per-forwarder deploy provenance via transaction receipts, work on any full +node. Published manifests live in `manifests/`. + +**The manifest is consistency evidence, NOT a trust root** (re-review R01): it is +produced by Vortex from the same chain state it attests to, so a verifier pass proves +only that the deployment has not silently changed since publication — not that it was +honest. Independent verification of contract behavior requires the verified source on a +block explorer. Client-authorized config changes (destination/fallback rotation by the +client's own fallbackAddress) are reported as expected transitions, not failures +(re-review R07). diff --git a/contracts/monerium-forwarder/foundry.lock b/contracts/monerium-forwarder/foundry.lock new file mode 100644 index 000000000..31b0fe2e2 --- /dev/null +++ b/contracts/monerium-forwarder/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.2", + "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b" + } + } +} \ No newline at end of file diff --git a/contracts/monerium-forwarder/foundry.toml b/contracts/monerium-forwarder/foundry.toml new file mode 100644 index 000000000..0835e8dbf --- /dev/null +++ b/contracts/monerium-forwarder/foundry.toml @@ -0,0 +1,14 @@ +[profile.default] +src = "src" +out = "out" +test = "test" +libs = ["lib"] +solc_version = "0.8.26" +optimizer = true +optimizer_runs = 10000 + +[profile.default.fuzz] +runs = 512 + +# Mainnet fork tests (task 2) read this endpoint from env: +# ETH_RPC_URL must be set; fork tests are skipped without it. diff --git a/contracts/monerium-forwarder/lib/forge-std b/contracts/monerium-forwarder/lib/forge-std new file mode 160000 index 000000000..bf647bd60 --- /dev/null +++ b/contracts/monerium-forwarder/lib/forge-std @@ -0,0 +1 @@ +Subproject commit bf647bd6046f2f7da30d0c2bf435e5c76a780c1b diff --git a/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json new file mode 100644 index 000000000..713fbbeef --- /dev/null +++ b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json @@ -0,0 +1,67 @@ +{ + "chainId": 11155111, + "factory": { + "address": "0xcBE354e847bF597513148918E7EbDff72aC75842", + "immutables": { + "CAP_CEILING": "50000000000000000000000", + "implementation": "0x7e1c653CaAFCa44258d8680B09F42a33475504a9", + "MIN_SWAP_FLOOR": "1000000000000000000" + }, + "operational": { + "globalPaused": false, + "guardian": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", + "minSwapAmount": "25000000000000000000", + "perSwapCap": "10000000000000000000000" + }, + "runtimeBytecodeHash": "0x3a651b091d179f24618becebeba26698ac13e366899e08f5f03ba8cbac879e98" + }, + "forwarders": [ + { + "address": "0xD7444AB7270A142227Fe659D63873ABdc8AF9b72", + "clientMutable": { + "destination": "0x1111111111111111111111111111111111111111", + "fallbackAddress": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1" + }, + "deploy": { + "blockNumber": "11293035", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000002", + "txHash": "0x90e67a4f9698c9d009687eaaedd2d8b89eebfa104a2edb4c7923fa7a033f2787" + }, + "guardianMutable": { + "feeBps": 0 + }, + "immutables": { + "isForwarder": true + }, + "runtimeBytecodeHash": "0x215c88056e67f5925d5660d5eb02a45dca8e69b50db65bacb5ba9ed44d63f364" + } + ], + "generatedAt": "2026-07-17T17:11:57.612Z", + "implementation": { + "address": "0x7e1c653CaAFCa44258d8680B09F42a33475504a9", + "immutables": { + "ATTESTOR": "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", + "EURC": "0x539fA90D0a29eB2a513f6b88fEd30b529dF071ca", + "EURE": "0x67b34b93ac295c985e856E5B8A20D83026b580Eb", + "FACTORY": "0xcBE354e847bF597513148918E7EbDff72aC75842", + "FEE_RECIPIENT": "0x3333333333333333333333333333333333333333", + "LINK_HASH_191": "0xb77c35c892a1b24b10a2ce49b424e578472333ee8d2456234fff90626332c50f", + "LINK_MESSAGE": "I hereby declare that I am the address owner.", + "MAX_FEE_BPS": 100, + "MAX_ORACLE_AGE": "187200", + "ORACLE": "0x337dd479435aE2593c9B023B48617278c6AB34E3", + "ORACLE_DECIMALS": 8, + "POOL_FEE_EURC_USDC": 500, + "POOL_FEE_EURE_EURC": 500, + "RECOVERY_HASH": "0x0000000000000000000000000000000000000000000000000000000000000000", + "ROUTER": "0x2222222222222222222222222222222222222222", + "SLIPPAGE_BPS": 100, + "SWEEP_DELAY": "5184000", + "TRIGGER_DELAY": "86400", + "USDC": "0xEc262C76Ff70330BBa90e2c477B185d6665c4aA2" + }, + "runtimeBytecodeHash": "0x0c362c605fc7a795c4cab02fca0751e27817caf84017296d8fa2325dde6f1d11" + }, + "manifestVersion": 2, + "purpose": "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). NOT a trust root (re-review R01): this file is produced by Vortex from the same chain state it attests to, so it proves only that the deployment has not silently changed since publication — not that it was honest. Verify contract behavior independently against the verified source on a block explorer." +} diff --git a/contracts/monerium-forwarder/package.json b/contracts/monerium-forwarder/package.json new file mode 100644 index 000000000..d507051b1 --- /dev/null +++ b/contracts/monerium-forwarder/package.json @@ -0,0 +1,13 @@ +{ + "description": "Attestor-linked forwarder contracts for the Monerium B2B zero-touch onramp (Foundry)", + "devDependencies": { + "viem": "catalog:" + }, + "name": "@vortexfi/contracts-monerium-forwarder", + "private": true, + "scripts": { + "compile": "forge build", + "test": "forge test" + }, + "version": "0.1.0" +} diff --git a/contracts/monerium-forwarder/script/generate-manifest.ts b/contracts/monerium-forwarder/script/generate-manifest.ts new file mode 100644 index 000000000..26cca7bad --- /dev/null +++ b/contracts/monerium-forwarder/script/generate-manifest.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env bun +import { writeFileSync } from "node:fs"; +import { Address, createPublicClient, http, isAddress, PublicClient } from "viem"; +import { + enumerateForwarders, + MANIFEST_PURPOSE, + MANIFEST_VERSION, + Manifest, + readCoreState, + readForwarderEntry +} from "./manifest-core"; + +/** + * Emits a versioned JSON config manifest for a VortexForwarderFactory deployment + * (Monerium B2B onramp, implementation plan D3): factory + implementation runtime + * bytecode hashes, all implementation-level immutables, and per-clone config with + * deploy transaction provenance (ForwarderDeployed events). + * + * The manifest is CONSISTENCY EVIDENCE, NOT A TRUST ROOT (re-review R01): it is + * produced by Vortex from the same chain state it attests to. Publishing it lets + * third parties detect silent changes (via verify-manifest.ts) — it does not prove + * the deployment was honest in the first place. + * + * Usage: + * bun script/generate-manifest.ts [outFile] [--logs-rpc ] + * + * Without [outFile] the manifest is printed to stdout (progress goes to stderr). + * --logs-rpc: many free public RPCs refuse historical eth_getLogs ("archive" gating); + * pass a logs-capable endpoint here for the ForwarderDeployed enumeration — every + * other read still goes through . + */ + +function parseArgs(argv: string[]): { factoryAddress: Address; logsRpcUrl?: string; outFile?: string; rpcUrl: string } { + const positional: string[] = []; + let logsRpcUrl: string | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--logs-rpc") { + logsRpcUrl = argv[++i]; + } else { + positional.push(argv[i]); + } + } + const [factoryAddress, rpcUrl, outFile] = positional; + if (!factoryAddress || !rpcUrl) { + console.error("Usage: bun script/generate-manifest.ts [outFile] [--logs-rpc ]"); + process.exit(2); + } + if (!isAddress(factoryAddress)) { + console.error(`error: ${factoryAddress} is not a valid address`); + process.exit(2); + } + return { factoryAddress: factoryAddress as Address, logsRpcUrl, outFile, rpcUrl }; +} + +async function main(): Promise { + const { factoryAddress, logsRpcUrl, outFile, rpcUrl } = parseArgs(process.argv.slice(2)); + + const client = createPublicClient({ transport: http(rpcUrl) }); + const logsClient: PublicClient = logsRpcUrl ? createPublicClient({ transport: http(logsRpcUrl) }) : client; + + console.error(`reading deployment state for factory ${factoryAddress} ...`); + const core = await readCoreState(client, factoryAddress); + const deployed = await enumerateForwarders(logsClient, factoryAddress); + const forwarders = []; + for (const { deploy, forwarder } of deployed) { + forwarders.push(await readForwarderEntry(client, factoryAddress, forwarder, deploy)); + } + const manifest: Manifest = { + ...core, + forwarders, + generatedAt: new Date().toISOString(), + manifestVersion: MANIFEST_VERSION, + purpose: MANIFEST_PURPOSE + }; + console.error( + `chainId ${manifest.chainId}, implementation ${manifest.implementation.address}, ${manifest.forwarders.length} forwarder(s)` + ); + + const json = `${JSON.stringify(manifest, null, 2)}\n`; + if (outFile) { + writeFileSync(outFile, json); + console.error(`manifest written to ${outFile}`); + } else { + process.stdout.write(json); + } +} + +main().catch(error => { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/contracts/monerium-forwarder/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts new file mode 100644 index 000000000..da1707f7f --- /dev/null +++ b/contracts/monerium-forwarder/script/manifest-core.ts @@ -0,0 +1,421 @@ +import { Address, getAddress, Hex, keccak256, PublicClient, parseAbi, parseAbiItem, parseEventLogs } from "viem"; + +/** + * Shared chain-reading core for generate-manifest.ts / verify-manifest.ts. + * + * R01 (docs/architecture-monerium-b2b-onramp.md §5): the manifest produced from + * this state is CONSISTENCY EVIDENCE, NOT A TRUST ROOT. It is generated by Vortex from + * the same chain state it attests to, so it cannot prove the deployment was honest — + * only that the deployment has not silently changed since the manifest was published. + * Independent verification of WHAT the contracts do requires reading the verified + * source on a block explorer. + */ + +export const MANIFEST_VERSION = 2; + +export const MANIFEST_PURPOSE = + "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). " + + "NOT a trust root (re-review R01): this file is produced by Vortex from the same chain state it attests to, " + + "so it proves only that the deployment has not silently changed since publication — not that it was honest. " + + "Verify contract behavior independently against the verified source on a block explorer."; + +// ------------------------------------------------------------------ ABI surface +// Mirrors contracts/monerium-forwarder/src/VortexForwarder.sol + VortexForwarderFactory.sol. + +export const factoryAbi = parseAbi([ + "function implementation() view returns (address)", + "function MIN_SWAP_FLOOR() view returns (uint256)", + "function CAP_CEILING() view returns (uint256)", + "function guardian() view returns (address)", + "function globalPaused() view returns (bool)", + "function minSwapAmount() view returns (uint256)", + "function perSwapCap() view returns (uint256)", + "function isForwarder(address forwarder) view returns (bool)" +]); + +export const forwarderDeployedEvent = parseAbiItem( + "event ForwarderDeployed(address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt)" +); + +export const forwarderConfigAbi = parseAbi([ + "function destination() view returns (address)", + "function fallbackAddress() view returns (address)", + "function feeBps() view returns (uint16)" +]); + +export const implementationAbi = parseAbi([ + "function EURE() view returns (address)", + "function EURC() view returns (address)", + "function USDC() view returns (address)", + "function ROUTER() view returns (address)", + "function ORACLE() view returns (address)", + "function ORACLE_DECIMALS() view returns (uint8)", + "function FACTORY() view returns (address)", + "function ATTESTOR() view returns (address)", + "function FEE_RECIPIENT() view returns (address)", + "function MAX_ORACLE_AGE() view returns (uint256)", + "function SLIPPAGE_BPS() view returns (uint16)", + "function MAX_FEE_BPS() view returns (uint16)", + "function SWEEP_DELAY() view returns (uint256)", + "function TRIGGER_DELAY() view returns (uint256)", + "function POOL_FEE_EURE_EURC() view returns (uint24)", + "function POOL_FEE_EURC_USDC() view returns (uint24)", + "function LINK_HASH_191() view returns (bytes32)", + "function RECOVERY_HASH() view returns (bytes32)", + "function LINK_MESSAGE() view returns (string)" +]); + +// ------------------------------------------------------------------ manifest shape + +/** Implementation-level immutables shared by all clones (plan §2.1). */ +export interface ImplementationImmutables { + ATTESTOR: string; + EURC: string; + EURE: string; + FACTORY: string; + FEE_RECIPIENT: string; + LINK_HASH_191: Hex; + LINK_MESSAGE: string; + MAX_FEE_BPS: number; + MAX_ORACLE_AGE: string; + ORACLE: string; + ORACLE_DECIMALS: number; + POOL_FEE_EURC_USDC: number; + POOL_FEE_EURE_EURC: number; + RECOVERY_HASH: Hex; + ROUTER: string; + SLIPPAGE_BPS: number; + SWEEP_DELAY: string; + TRIGGER_DELAY: string; + USDC: string; +} + +export interface ForwarderManifestEntry { + address: string; + /** + * Mutable ONLY by the client's fallbackAddress (contract `onlyFallback`). A drift here + * is an owner-authorized state transition, not an incident (re-review R07): the + * verifier reports it as EXPECTED-TRANSITION and the manifest should be regenerated. + */ + clientMutable: { + destination: string; + fallbackAddress: string; + }; + deploy: { + blockNumber: string; + salt: Hex; + txHash: Hex; + }; + /** Guardian-adjustable under the contract's bounded, timelocked fee policy. */ + guardianMutable: { + feeBps: number; + }; + /** Factory registration is fixed for the lifetime of the clone. Mismatch = incident. */ + immutables: { + isForwarder: boolean; + }; + /** keccak256 of the clone's runtime code; must equal the EIP-1167 code for `implementation.address`. */ + runtimeBytecodeHash: Hex; +} + +export interface CoreState { + chainId: number; + factory: { + address: string; + immutables: { + CAP_CEILING: string; + MIN_SWAP_FLOOR: string; + implementation: string; + }; + /** + * Guardian-tunable within the immutable bounds (registry P6/P7) plus role/pause + * state. Drift here is legitimate operation: the verifier reports NOTICE, not + * failure. + */ + operational: { + globalPaused: boolean; + guardian: string; + minSwapAmount: string; + perSwapCap: string; + }; + runtimeBytecodeHash: Hex; + }; + implementation: { + address: string; + immutables: ImplementationImmutables; + runtimeBytecodeHash: Hex; + }; +} + +export interface Manifest extends CoreState { + forwarders: ForwarderManifestEntry[]; + generatedAt: string; + manifestVersion: number; + purpose: string; +} + +// ------------------------------------------------------------------ helpers + +/** Runtime code of a standard EIP-1167 minimal proxy pointing at `implementation`. */ +export function eip1167RuntimeCode(implementation: Address): Hex { + return `0x363d3d373d3d3d363d73${implementation.slice(2).toLowerCase()}5af43d82803e903d91602b57fd5bf3` as Hex; +} + +async function codeHash(client: PublicClient, address: Address): Promise { + const code = await client.getCode({ address }); + if (!code || code === "0x") { + throw new Error(`no contract code at ${address}`); + } + return keccak256(code); +} + +function read( + client: PublicClient, + abi: typeof factoryAbi | typeof implementationAbi | typeof forwarderConfigAbi, + address: Address, + functionName: string, + args: unknown[] = [] +): Promise { + // biome-ignore lint/suspicious/noExplicitAny: generic dispatcher over three hand-pinned ABIs + return client.readContract({ abi, address, args, functionName } as any) as Promise; +} + +// ------------------------------------------------------------------ deploy provenance + +export interface DeployProvenance { + blockNumber: bigint; + salt: Hex; + txHash: Hex; +} + +const LOG_CHUNK = 50_000n; + +/** + * Finds the factory's deploy block by binary search over getCode (needs an archive + * RPC); falls back to 0 when historical getCode is unavailable. + */ +async function findDeployBlock(client: PublicClient, factory: Address, latest: bigint): Promise { + try { + let lo = 0n; + let hi = latest; + while (lo < hi) { + const mid = (lo + hi) / 2n; + const code = await client.getCode({ address: factory, blockNumber: mid }); + if (code && code !== "0x") { + hi = mid; + } else { + lo = mid + 1n; + } + } + return lo; + } catch { + return 0n; + } +} + +/** + * Enumerates all forwarders ever deployed by the factory from its ForwarderDeployed + * events, oldest first. Needs an RPC that serves historical eth_getLogs (many free + * endpoints gate this behind archive plans — pass a logs-capable RPC to the scripts + * via --logs-rpc in that case). + */ +export async function enumerateForwarders( + client: PublicClient, + factoryAddress: Address +): Promise<{ deploy: DeployProvenance; forwarder: Address }[]> { + const factory = getAddress(factoryAddress); + const latest = await client.getBlockNumber(); + // biome-ignore lint/suspicious/noExplicitAny: viem log generics collapse across the two getLogs call shapes below + let rawLogs: any[]; + try { + // Single full-range query first — cheap when the provider allows it. + rawLogs = await client.getLogs({ address: factory, event: forwarderDeployedEvent, fromBlock: 0n, toBlock: latest }); + } catch { + const start = await findDeployBlock(client, factory, latest); + rawLogs = []; + for (let from = start; from <= latest; from += LOG_CHUNK) { + const to = from + LOG_CHUNK - 1n > latest ? latest : from + LOG_CHUNK - 1n; + rawLogs.push( + ...(await client.getLogs({ address: factory, event: forwarderDeployedEvent, fromBlock: from, toBlock: to })) + ); + } + } + return rawLogs + .filter(log => log.blockNumber !== null && log.transactionHash !== null) + .map(log => ({ + deploy: { + blockNumber: log.blockNumber as bigint, + salt: log.args.salt as Hex, + txHash: log.transactionHash as Hex + }, + forwarder: getAddress(log.args.forwarder as Address) + })); +} + +/** + * Re-derives a forwarder's deploy provenance from its deploy transaction receipt: the + * receipt must be successful and contain the factory's ForwarderDeployed event for the + * forwarder. Works on any full node (receipts by hash are not archive-gated), which is + * what lets third parties verify provenance through free public RPCs. + */ +export async function resolveDeployProvenance( + client: PublicClient, + factoryAddress: Address, + forwarderAddress: Address, + txHash: Hex +): Promise { + const receipt = await client.getTransactionReceipt({ hash: txHash }); + if (receipt.status !== "success") { + throw new Error(`deploy tx ${txHash} did not succeed`); + } + const events = parseEventLogs({ abi: [forwarderDeployedEvent], logs: receipt.logs }).filter( + log => + log.address.toLowerCase() === factoryAddress.toLowerCase() && + (log.args.forwarder as Address).toLowerCase() === forwarderAddress.toLowerCase() + ); + if (events.length === 0) { + throw new Error(`deploy tx ${txHash} contains no ForwarderDeployed event for ${forwarderAddress} from ${factoryAddress}`); + } + return { blockNumber: receipt.blockNumber, salt: events[0].args.salt as Hex, txHash }; +} + +// ------------------------------------------------------------------ state readers + +/** Reads chainId + factory + implementation sections from live chain state. */ +export async function readCoreState(client: PublicClient, factoryAddress: Address): Promise { + const factory = getAddress(factoryAddress); + const chainId = await client.getChainId(); + + const [implementation, minSwapFloor, capCeiling, guardian, globalPaused, minSwapAmount, perSwapCap] = await Promise.all([ + read
(client, factoryAbi, factory, "implementation"), + read(client, factoryAbi, factory, "MIN_SWAP_FLOOR"), + read(client, factoryAbi, factory, "CAP_CEILING"), + read
(client, factoryAbi, factory, "guardian"), + read(client, factoryAbi, factory, "globalPaused"), + read(client, factoryAbi, factory, "minSwapAmount"), + read(client, factoryAbi, factory, "perSwapCap") + ]); + + const [ + eure, + eurc, + usdc, + router, + oracle, + oracleDecimals, + implFactory, + attestor, + feeRecipient, + maxOracleAge, + slippageBps, + maxFeeBps, + sweepDelay, + triggerDelay, + poolFeeEureEurc, + poolFeeEurcUsdc, + linkHash191, + recoveryHash, + linkMessage + ] = await Promise.all([ + read
(client, implementationAbi, implementation, "EURE"), + read
(client, implementationAbi, implementation, "EURC"), + read
(client, implementationAbi, implementation, "USDC"), + read
(client, implementationAbi, implementation, "ROUTER"), + read
(client, implementationAbi, implementation, "ORACLE"), + read(client, implementationAbi, implementation, "ORACLE_DECIMALS"), + read
(client, implementationAbi, implementation, "FACTORY"), + read
(client, implementationAbi, implementation, "ATTESTOR"), + read
(client, implementationAbi, implementation, "FEE_RECIPIENT"), + read(client, implementationAbi, implementation, "MAX_ORACLE_AGE"), + read(client, implementationAbi, implementation, "SLIPPAGE_BPS"), + read(client, implementationAbi, implementation, "MAX_FEE_BPS"), + read(client, implementationAbi, implementation, "SWEEP_DELAY"), + read(client, implementationAbi, implementation, "TRIGGER_DELAY"), + read(client, implementationAbi, implementation, "POOL_FEE_EURE_EURC"), + read(client, implementationAbi, implementation, "POOL_FEE_EURC_USDC"), + read(client, implementationAbi, implementation, "LINK_HASH_191"), + read(client, implementationAbi, implementation, "RECOVERY_HASH"), + read(client, implementationAbi, implementation, "LINK_MESSAGE") + ]); + + return { + chainId, + factory: { + address: factory, + immutables: { + CAP_CEILING: capCeiling.toString(), + implementation: getAddress(implementation), + MIN_SWAP_FLOOR: minSwapFloor.toString() + }, + operational: { + globalPaused, + guardian: getAddress(guardian), + minSwapAmount: minSwapAmount.toString(), + perSwapCap: perSwapCap.toString() + }, + runtimeBytecodeHash: await codeHash(client, factory) + }, + implementation: { + address: getAddress(implementation), + immutables: { + ATTESTOR: getAddress(attestor), + EURC: getAddress(eurc), + EURE: getAddress(eure), + FACTORY: getAddress(implFactory), + FEE_RECIPIENT: getAddress(feeRecipient), + LINK_HASH_191: linkHash191, + LINK_MESSAGE: linkMessage, + MAX_FEE_BPS: Number(maxFeeBps), + MAX_ORACLE_AGE: maxOracleAge.toString(), + ORACLE: getAddress(oracle), + ORACLE_DECIMALS: Number(oracleDecimals), + POOL_FEE_EURC_USDC: Number(poolFeeEurcUsdc), + POOL_FEE_EURE_EURC: Number(poolFeeEureEurc), + RECOVERY_HASH: recoveryHash, + ROUTER: getAddress(router), + SLIPPAGE_BPS: Number(slippageBps), + SWEEP_DELAY: sweepDelay.toString(), + TRIGGER_DELAY: triggerDelay.toString(), + USDC: getAddress(usdc) + }, + runtimeBytecodeHash: await codeHash(client, implementation) + } + }; +} + +/** Reads one forwarder's live per-clone config + code hash into a manifest entry. */ +export async function readForwarderEntry( + client: PublicClient, + factoryAddress: Address, + forwarderAddress: Address, + deploy: DeployProvenance +): Promise { + const factory = getAddress(factoryAddress); + const forwarder = getAddress(forwarderAddress); + const [destination, fallbackAddress, feeBps, isForwarder, forwarderCodeHash] = await Promise.all([ + read
(client, forwarderConfigAbi, forwarder, "destination"), + read
(client, forwarderConfigAbi, forwarder, "fallbackAddress"), + read(client, forwarderConfigAbi, forwarder, "feeBps"), + read(client, factoryAbi, factory, "isForwarder", [forwarder]), + codeHash(client, forwarder) + ]); + return { + address: forwarder, + clientMutable: { + destination: getAddress(destination), + fallbackAddress: getAddress(fallbackAddress) + }, + deploy: { + blockNumber: deploy.blockNumber.toString(), + salt: deploy.salt, + txHash: deploy.txHash + }, + guardianMutable: { + feeBps: Number(feeBps) + }, + immutables: { + isForwarder + }, + runtimeBytecodeHash: forwarderCodeHash + }; +} diff --git a/contracts/monerium-forwarder/script/verify-manifest.test.ts b/contracts/monerium-forwarder/script/verify-manifest.test.ts new file mode 100644 index 000000000..420901354 --- /dev/null +++ b/contracts/monerium-forwarder/script/verify-manifest.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test"; +import { severityFor } from "./verify-manifest"; + +describe("manifest diff severity", () => { + it("treats guardian fee changes as notices", () => { + expect(severityFor("forwarders.0x123.guardianMutable.feeBps")).toBe("NOTICE"); + }); + + it("keeps client changes expected and immutable changes fatal", () => { + expect(severityFor("forwarders.0x123.clientMutable.destination")).toBe("EXPECTED-TRANSITION"); + expect(severityFor("forwarders.0x123.immutables.isForwarder")).toBe("FAIL"); + expect(severityFor("forwarders.0x123.runtimeBytecodeHash")).toBe("FAIL"); + }); +}); diff --git a/contracts/monerium-forwarder/script/verify-manifest.ts b/contracts/monerium-forwarder/script/verify-manifest.ts new file mode 100644 index 000000000..b08b84993 --- /dev/null +++ b/contracts/monerium-forwarder/script/verify-manifest.ts @@ -0,0 +1,214 @@ +#!/usr/bin/env bun +import { readFileSync } from "node:fs"; +import { Address, createPublicClient, Hex, http, keccak256, PublicClient } from "viem"; +import { + eip1167RuntimeCode, + enumerateForwarders, + ForwarderManifestEntry, + MANIFEST_VERSION, + Manifest, + readCoreState, + readForwarderEntry, + resolveDeployProvenance +} from "./manifest-core"; + +/** + * Re-checks every field of a published config manifest against live chain state and + * exits nonzero with a field-level diff on mismatch. Self-contained: needs only this + * repo checkout (`bun install` once for viem), the manifest file, and any public RPC — + * runnable by third parties: + * + * bun script/verify-manifest.ts [--logs-rpc ] + * + * Deploy provenance is verified from each forwarder's deploy transaction RECEIPT (the + * receipt must contain the factory's ForwarderDeployed event), which works on any full + * node. Only the completeness check — "no forwarders were deployed that the manifest + * does not list" — needs historical eth_getLogs; many free RPCs gate that behind + * archive plans, so pass --logs-rpc with a logs-capable endpoint or accept a NOTICE + * that completeness was not checked. + * + * What a PASS means — and what it does not (re-review R01): the manifest is + * CONSISTENCY EVIDENCE, NOT A TRUST ROOT. A pass proves the deployment still matches + * what Vortex published, i.e. nothing changed silently. It does NOT prove the + * published configuration was correct or honest — for that, read the verified + * contract source on a block explorer. + * + * Severity classes: + * FAIL immutable/bytecode/deploy-provenance mismatch -> exit 1 + * EXPECTED-TRANSITION clientMutable fields (destination/fallbackAddress) changed by + * the client's own fallbackAddress (`onlyFallback` in the + * contract). Owner-authorized, not an incident (re-review R07); + * regenerate + republish the manifest. exit 0 + * NOTICE guardian-tunable forwarder/factory parameters, a stale + * forwarder list (new deployments since publication), or a + * skipped completeness check. exit 0 + */ + +export type Severity = "FAIL" | "EXPECTED-TRANSITION" | "NOTICE"; + +interface Diff { + actual: string; + expected: string; + path: string; + severity: Severity; +} + +function flatten(value: unknown, prefix: string, out: Map): void { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const [key, child] of Object.entries(value)) { + flatten(child, prefix ? `${prefix}.${key}` : key, out); + } + return; + } + out.set(prefix, String(value)); +} + +export function severityFor(path: string): Severity { + if (path.includes(".clientMutable.")) return "EXPECTED-TRANSITION"; + if (path.includes(".guardianMutable.")) return "NOTICE"; + if (path.includes(".operational.")) return "NOTICE"; + return "FAIL"; +} + +function diffSection(path: string, expected: unknown, actual: unknown, diffs: Diff[]): void { + const expectedFlat = new Map(); + const actualFlat = new Map(); + flatten(expected, path, expectedFlat); + flatten(actual, path, actualFlat); + for (const key of new Set([...expectedFlat.keys(), ...actualFlat.keys()])) { + const expectedValue = expectedFlat.get(key) ?? ""; + const actualValue = actualFlat.get(key) ?? ""; + if (expectedValue !== actualValue) { + diffs.push({ actual: actualValue, expected: expectedValue, path: key, severity: severityFor(key) }); + } + } +} + +async function verifyForwarder( + client: PublicClient, + manifest: Manifest, + entry: ForwarderManifestEntry, + expectedCloneHash: Hex, + diffs: Diff[] +): Promise { + const factory = manifest.factory.address as Address; + const forwarder = entry.address as Address; + let live: ForwarderManifestEntry; + try { + // Provenance from the deploy tx receipt: must contain the factory's + // ForwarderDeployed event for this forwarder (works on any full node). + const provenance = await resolveDeployProvenance(client, factory, forwarder, entry.deploy.txHash); + live = await readForwarderEntry(client, factory, forwarder, provenance); + } catch (error) { + diffs.push({ + actual: `<${error instanceof Error ? error.message : String(error)}>`, + expected: "verifiable forwarder", + path: `forwarders.${entry.address}`, + severity: "FAIL" + }); + return; + } + diffSection(`forwarders.${entry.address}`, entry, live, diffs); + if (live.runtimeBytecodeHash.toLowerCase() !== expectedCloneHash.toLowerCase()) { + diffs.push({ + actual: live.runtimeBytecodeHash, + expected: expectedCloneHash, + path: `forwarders.${entry.address}.runtimeBytecodeHash (EIP-1167 for implementation)`, + severity: "FAIL" + }); + } +} + +async function checkCompleteness(logsClient: PublicClient, manifest: Manifest, diffs: Diff[]): Promise { + try { + const deployed = await enumerateForwarders(logsClient, manifest.factory.address as Address); + const listed = new Set(manifest.forwarders.map(entry => entry.address.toLowerCase())); + for (const { forwarder } of deployed) { + if (!listed.has(forwarder.toLowerCase())) { + // Deployed after publication: stale manifest, not tampering — regenerate. + diffs.push({ actual: forwarder, expected: "", path: `forwarders.${forwarder}`, severity: "NOTICE" }); + } + } + } catch { + diffs.push({ + actual: "", + expected: "ForwarderDeployed enumeration", + path: "forwarders.", + severity: "NOTICE" + }); + } +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const positional: string[] = []; + let logsRpcUrl: string | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--logs-rpc") { + logsRpcUrl = argv[++i]; + } else { + positional.push(argv[i]); + } + } + const [manifestFile, rpcUrl] = positional; + if (!manifestFile || !rpcUrl) { + console.error("Usage: bun script/verify-manifest.ts [--logs-rpc ]"); + process.exit(2); + } + + const manifest = JSON.parse(readFileSync(manifestFile, "utf8")) as Manifest; + if (manifest.manifestVersion !== MANIFEST_VERSION) { + console.error(`error: manifest version ${manifest.manifestVersion} is not supported (expected ${MANIFEST_VERSION})`); + process.exit(1); + } + + const client = createPublicClient({ transport: http(rpcUrl) }); + const logsClient: PublicClient = logsRpcUrl ? createPublicClient({ transport: http(logsRpcUrl) }) : client; + + console.error(`re-reading live state for factory ${manifest.factory.address} ...`); + const core = await readCoreState(client, manifest.factory.address as Address); + + const diffs: Diff[] = []; + diffSection("chainId", manifest.chainId, core.chainId, diffs); + diffSection("factory", manifest.factory, core.factory, diffs); + diffSection("implementation", manifest.implementation, core.implementation, diffs); + + // Every clone's runtime code must be the EIP-1167 proxy for the published + // implementation — checked against live state below. + const expectedCloneHash = keccak256(eip1167RuntimeCode(manifest.implementation.address as Address)); + for (const entry of manifest.forwarders) { + await verifyForwarder(client, manifest, entry, expectedCloneHash, diffs); + } + await checkCompleteness(logsClient, manifest, diffs); + + for (const diff of diffs) { + console.log(`[${diff.severity}] ${diff.path}: manifest=${diff.expected} live=${diff.actual}`); + } + + const failures = diffs.filter(diff => diff.severity === "FAIL").length; + const transitions = diffs.filter(diff => diff.severity === "EXPECTED-TRANSITION").length; + const notices = diffs.filter(diff => diff.severity === "NOTICE").length; + + if (failures > 0) { + console.log(`VERIFICATION FAILED: ${failures} mismatch(es), ${transitions} expected transition(s), ${notices} notice(s)`); + process.exit(1); + } + if (transitions > 0 || notices > 0) { + console.log( + `VERIFICATION PASSED with ${transitions} owner-authorized transition(s) and ${notices} notice(s) — ` + + "regenerate and republish the manifest to fold them in (consistency evidence only, NOT a trust root — R01)" + ); + return; + } + console.log( + `VERIFICATION PASSED: all ${manifest.forwarders.length} forwarder(s), implementation and factory match the manifest ` + + "(consistency evidence only, NOT a trust root — R01)" + ); +} + +if (import.meta.main) { + main().catch(error => { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol new file mode 100644 index 000000000..88689bbba --- /dev/null +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); +} + +/// Uniswap V3 SwapRouter02-style multi-hop interface (no deadline field; registry P10 +/// tracks the final router pin — if classic SwapRouter is chosen, add the deadline). +interface ISwapRouter02 { + struct ExactInputParams { + bytes path; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + } + + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); +} + +interface AggregatorV3Interface { + function decimals() external view returns (uint8); + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} + +interface IVortexForwarderFactory { + function guardian() external view returns (address); + function isKeeper(address account) external view returns (bool); + function globalPaused() external view returns (bool); + function minSwapAmount() external view returns (uint256); + function perSwapCap() external view returns (uint256); + function MIN_SWAP_FLOOR() external view returns (uint256); +} + +/// @title VortexForwarder +/// @notice Per-client forwarding account for the Monerium B2B onramp +/// (docs/architecture-monerium-b2b-onramp.md §2). +/// Deployed as an EIP-1167 clone by VortexForwarderFactory; the clone address is +/// linked to the client's Monerium profile, EURe mints land here, and the only +/// ways assets can ever leave are: +/// 1. the pinned EURe -> EURC -> USDC swap (oracle-checked minOut, output to self), +/// 2. USDC to the client's `destination` (plus fee <= feeBps to FEE_RECIPIENT), +/// 3. EURe to the client's `fallbackAddress` (delayed permissionless sweep), +/// 4. anything, by the client's `fallbackAddress` itself (`sweep`). +/// Vortex (guardian/keeper) can execute the policy, pause it, and nothing else. +/// @dev EIP-1271 is deliberately constrained to the fixed Monerium link message hash +/// signed by ATTESTOR and bound to this clone's address — it must never validate +/// redeem orders (that would hand Vortex fiat-payout power; see variant doc §3.2). +contract VortexForwarder { + // ---------------------------------------------------------------- constants + + bytes4 private constant EIP1271_MAGIC = 0x1626ba7e; + bytes4 private constant EIP1271_FAIL = 0xffffffff; + uint16 private constant BPS = 10_000; + + string public constant LINK_MESSAGE = "I hereby declare that I am the address owner."; + + // ------------------------------------------------------------- immutables + // Immutables live in the implementation's code and are shared by all clones. + + IERC20 public immutable EURE; + IERC20 public immutable EURC; + IERC20 public immutable USDC; + ISwapRouter02 public immutable ROUTER; + AggregatorV3Interface public immutable ORACLE; // Chainlink EUR/USD + uint8 public immutable ORACLE_DECIMALS; + IVortexForwarderFactory public immutable FACTORY; + address public immutable ATTESTOR; // signs the Monerium link attestation + address public immutable FEE_RECIPIENT; + uint256 public immutable MAX_ORACLE_AGE; // registry P8 + uint16 public immutable SLIPPAGE_BPS; // registry P1 + uint16 public immutable MAX_FEE_BPS; // registry P2 + uint256 public immutable SWEEP_DELAY; // registry P3 + uint256 public immutable TRIGGER_DELAY; // registry P4 + uint24 public immutable POOL_FEE_EURE_EURC; // registry P10 + uint24 public immutable POOL_FEE_EURC_USDC; // registry P10 + + /// @dev EIP-191 personal-message hash and raw keccak of LINK_MESSAGE. Monerium's + /// exact hashing scheme is a G0 spike output (task 4); accepting both is safe + /// because both encode only the fixed link message. + bytes32 public immutable LINK_HASH_191; + + /// @dev Monerium issuer-recovery message hash (registry T1). bytes32(0) = disabled. + /// When enabled, allows Monerium's recovery burn to validate against this + /// contract; payout is constrained by Monerium to the client's own verified + /// bank account, so this grants Vortex no disposal power. + bytes32 public immutable RECOVERY_HASH; + + struct ImmutableConfig { + address eure; + address eurc; + address usdc; + address router; + address oracle; + address attestor; + address feeRecipient; + uint256 maxOracleAge; + uint16 slippageBps; + uint16 maxFeeBps; + uint256 sweepDelay; + uint256 triggerDelay; + uint24 poolFeeEureEurc; + uint24 poolFeeEurcUsdc; + bytes32 recoveryHash; + } + + // ---------------------------------------------------------------- storage + // Per-clone state, set once by the factory in the deployment transaction. + + bool public initialized; + address public destination; // client's payout address (may be a CEX deposit address) + address public fallbackAddress; // client's self-custodied recovery address (mandatory) + uint16 public feeBps; // guardian-adjustable within MAX_FEE_BPS; increases timelocked (P11) + + /// @dev P11 fee timelock state: a pending increase and when it may be applied. + /// effectiveAt == 0 means no increase is pending. Decreases never pend. + uint16 public pendingFeeBps; + uint64 public pendingFeeBpsEffectiveAt; + + bool public clientPaused; // set by fallbackAddress only + bool public guardianPaused; // set by guardian only (protective-only; cannot block fallback paths) + + /// @dev R03 marker: when the EURe balance first crossed minSwapAmount with no + /// successful swap since. Start time for TRIGGER_DELAY and SWEEP_DELAY. + uint64 public strandedSince; + + uint256 private _reentrancyGuard; + + // ----------------------------------------------------------------- events + + event Initialized(address destination, address fallbackAddress, uint16 feeBps); + event FeeBpsDecreased(uint16 previous, uint16 current); + event FeeBpsIncreaseAnnounced(uint16 current, uint16 pending, uint64 effectiveAt); + event FeeBpsIncreaseApplied(uint16 previous, uint16 current); + event FeeBpsIncreaseCancelled(uint16 pending); + event Poked(uint64 strandedSince); + event SwapExecuted(address indexed caller, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded); + event StrandedEureSwept(address indexed caller, uint256 amount); + event DestinationUpdated(address previous, address current); + event FallbackAddressUpdated(address previous, address current); + event ClientPausedSet(bool paused); + event GuardianPausedSet(bool paused); + event TokenSwept(address indexed token, address indexed to, uint256 amount); + + // ----------------------------------------------------------------- errors + + error AlreadyInitialized(); + error NotFactory(); + error NotFallbackAddress(); + error NotGuardian(); + error NotAuthorizedYet(); + error Paused(); + error ZeroAddress(); + error InvalidConfigAddress(); + error FeeTooHigh(); + error BelowMinimum(); + error StalePrice(); + error InvalidPrice(); + error InsufficientOutput(); + error Overspend(); + error NotStranded(); + error NoPendingFee(); + error DelayNotElapsed(); + error TransferFailed(); + error Reentrancy(); + + // ------------------------------------------------------------ constructor + + constructor(ImmutableConfig memory cfg) { + EURE = IERC20(cfg.eure); + EURC = IERC20(cfg.eurc); + USDC = IERC20(cfg.usdc); + ROUTER = ISwapRouter02(cfg.router); + ORACLE = AggregatorV3Interface(cfg.oracle); + ORACLE_DECIMALS = AggregatorV3Interface(cfg.oracle).decimals(); + FACTORY = IVortexForwarderFactory(msg.sender); + ATTESTOR = cfg.attestor; + FEE_RECIPIENT = cfg.feeRecipient; + MAX_ORACLE_AGE = cfg.maxOracleAge; + SLIPPAGE_BPS = cfg.slippageBps; + MAX_FEE_BPS = cfg.maxFeeBps; + SWEEP_DELAY = cfg.sweepDelay; + TRIGGER_DELAY = cfg.triggerDelay; + POOL_FEE_EURE_EURC = cfg.poolFeeEureEurc; + POOL_FEE_EURC_USDC = cfg.poolFeeEurcUsdc; + RECOVERY_HASH = cfg.recoveryHash; + + LINK_HASH_191 = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n45", LINK_MESSAGE)); + + // Brick the implementation itself; only clones can be initialized. + initialized = true; + } + + // ------------------------------------------------------------- modifiers + + modifier nonReentrant() { + if (_reentrancyGuard != 0) revert Reentrancy(); + _reentrancyGuard = 1; + _; + _reentrancyGuard = 0; + } + + modifier onlyFallback() { + if (msg.sender != fallbackAddress) revert NotFallbackAddress(); + _; + } + + modifier onlyGuardian() { + if (msg.sender != FACTORY.guardian()) revert NotGuardian(); + _; + } + + // ---------------------------------------------------------- initialization + + /// @notice Called by the factory in the same transaction as clone deployment. + function initialize(address destination_, address fallbackAddress_, uint16 feeBps_) external { + if (msg.sender != address(FACTORY)) revert NotFactory(); + if (initialized) revert AlreadyInitialized(); + _validateConfigAddress(destination_); + _validateConfigAddress(fallbackAddress_); + if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh(); + + initialized = true; + destination = destination_; + fallbackAddress = fallbackAddress_; + feeBps = feeBps_; + emit Initialized(destination_, fallbackAddress_, feeBps_); + } + + // -------------------------------------------------------------- EIP-1271 + + /// @notice Constrained EIP-1271: validates ONLY the fixed Monerium link message + /// (and, if enabled via RECOVERY_HASH, Monerium's recovery message), + /// signed by ATTESTOR over keccak256(chainid, address(this), hash). + /// Binding to chainid + address(this) prevents replay across clones AND + /// across chains (review r1 P2); restricting to ATTESTOR prevents third + /// parties from linking this address to a foreign Monerium profile. + /// Only the EIP-191 hash variant is accepted: G0 sandbox validation + /// (2026-07-17) confirmed Monerium presents the EIP-191 hash, so the + /// raw-keccak fallback was removed to minimize surface. + function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { + bool isLink = hash == LINK_HASH_191; + bool isRecovery = (RECOVERY_HASH != bytes32(0) && hash == RECOVERY_HASH); + if (!isLink && !isRecovery) return EIP1271_FAIL; + if (signature.length != 65) return EIP1271_FAIL; + + bytes32 r; + bytes32 s; + uint8 v; + // solhint-disable-next-line no-inline-assembly + assembly { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 0x20)) + v := byte(0, calldataload(add(signature.offset, 0x40))) + } + // Reject malleable signatures (high-s) and invalid v. + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) return EIP1271_FAIL; + if (v != 27 && v != 28) return EIP1271_FAIL; + + bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(this), hash)); + address signer = ecrecover(bound, v, r, s); + if (signer == address(0) || signer != ATTESTOR) return EIP1271_FAIL; + return EIP1271_MAGIC; + } + + // ------------------------------------------------------------ stranding marker (R03) + + /// @notice Permissionless. Records when the EURe balance first crossed the swap + /// threshold (start time for TRIGGER_DELAY / SWEEP_DELAY), and clears the + /// marker if the balance dropped back below it. + function poke() external { + // Armed against the IMMUTABLE floor, not the guardian-tunable minSwapAmount: + // otherwise the guardian could raise minSwapAmount above a client's balance and + // a poke() would clear the marker, permanently disabling the un-pausable + // dead-man sweep (review r1, finding F1 — breach of plan invariant §2.3.5). + uint256 balance = EURE.balanceOf(address(this)); + if (balance >= FACTORY.MIN_SWAP_FLOOR()) { + if (strandedSince == 0) { + strandedSince = uint64(block.timestamp); + emit Poked(strandedSince); + } + } else if (strandedSince != 0) { + strandedSince = 0; + emit Poked(0); + } + } + + // ------------------------------------------------------------------ swap + + /// @notice Convert EURe held by this contract to USDC and forward to `destination`. + /// Callable by guardian/keepers any time; by anyone once the stranding + /// marker is older than TRIGGER_DELAY (liveness fallback). + function swapAndForward() external nonReentrant { + if (clientPaused || guardianPaused || FACTORY.globalPaused()) revert Paused(); + + bool privileged = msg.sender == FACTORY.guardian() || FACTORY.isKeeper(msg.sender); + if (!privileged) { + if (strandedSince == 0) revert NotAuthorizedYet(); + if (block.timestamp - strandedSince < TRIGGER_DELAY) revert NotAuthorizedYet(); + } + + uint256 eureBefore = EURE.balanceOf(address(this)); + if (eureBefore < FACTORY.minSwapAmount()) revert BelowMinimum(); + uint256 amountIn = eureBefore; + uint256 cap = FACTORY.perSwapCap(); + if (amountIn > cap) amountIn = cap; + + uint256 minOut = _minOut(amountIn); + uint256 usdcBefore = USDC.balanceOf(address(this)); + + _approve(EURE, address(ROUTER), amountIn); + ROUTER.exactInput( + ISwapRouter02.ExactInputParams({ + path: abi.encodePacked( + address(EURE), POOL_FEE_EURE_EURC, address(EURC), POOL_FEE_EURC_USDC, address(USDC) + ), + recipient: address(this), + amountIn: amountIn, + amountOutMinimum: minOut + }) + ); + _approve(EURE, address(ROUTER), 0); + + uint256 usdcReceived = USDC.balanceOf(address(this)) - usdcBefore; + if (usdcReceived < minOut) revert InsufficientOutput(); + if (eureBefore - EURE.balanceOf(address(this)) > amountIn) revert Overspend(); + + uint256 fee = 0; + if (feeBps > 0) { + fee = (usdcReceived * feeBps) / BPS; + if (fee > 0) _transfer(USDC, FEE_RECIPIENT, fee); + } + + // Full-balance sweep: unsolicited USDC goes to the client's destination too (R09). + uint256 forwarded = USDC.balanceOf(address(this)); + _transfer(USDC, destination, forwarded); + + // Re-arm instead of clearing when a perSwapCap remainder stays behind (review r1 + // P2): otherwise the remainder's dead-man/permissionless timers would silently + // restart from zero only after a fresh poke(). + strandedSince = EURE.balanceOf(address(this)) >= FACTORY.MIN_SWAP_FLOOR() ? uint64(block.timestamp) : 0; + emit SwapExecuted(msg.sender, amountIn, usdcReceived, fee, forwarded); + } + + /// @dev minOut = amountIn * price * (1 - slippage), rescaled EURe(18) -> USDC(6). + /// Scale denominator: 10^(18 + oracleDecimals - 6). Floor rounding: conservative + /// direction; error < 1 unit of USDC. Assumes USDC/USD = 1 within SLIPPAGE_BPS + /// (documented assumption A4; PRD v2 §7.3). + function _minOut(uint256 amountIn) internal view returns (uint256) { + (, int256 answer,, uint256 updatedAt,) = ORACLE.latestRoundData(); + if (answer <= 0) revert InvalidPrice(); + if (updatedAt == 0 || block.timestamp - updatedAt > MAX_ORACLE_AGE) revert StalePrice(); + return (amountIn * uint256(answer) * (BPS - SLIPPAGE_BPS)) / (10 ** (12 + uint256(ORACLE_DECIMALS))) / BPS; + } + + // -------------------------------------------------------------- recovery + + /// @notice Permissionless dead-man sweep: after SWEEP_DELAY of stranding, anyone may + /// move the full EURe balance to the client's fallbackAddress. Deliberately + /// NOT gated on pause flags: recovery must work during incidents. Never + /// targets `destination` (CEX rule — variant doc §6). + function sweepStrandedEure() external nonReentrant { + if (strandedSince == 0) revert NotStranded(); + if (block.timestamp - strandedSince < SWEEP_DELAY) revert DelayNotElapsed(); + uint256 balance = EURE.balanceOf(address(this)); + _transfer(EURE, fallbackAddress, balance); + strandedSince = 0; + emit StrandedEureSwept(msg.sender, balance); + } + + // ------------------------------------------------------- client (fallback) authority + + function setDestination(address destination_) external onlyFallback { + _validateConfigAddress(destination_); + emit DestinationUpdated(destination, destination_); + destination = destination_; + } + + function setFallbackAddress(address fallbackAddress_) external onlyFallback { + _validateConfigAddress(fallbackAddress_); + emit FallbackAddressUpdated(fallbackAddress, fallbackAddress_); + fallbackAddress = fallbackAddress_; + } + + function setClientPaused(bool paused) external onlyFallback { + clientPaused = paused; + emit ClientPausedSet(paused); + } + + /// @notice Client exit hatch: move any token (incl. EURe/USDC/unsolicited) anywhere. + /// Works while paused — guardian pause must never trap client funds. + function sweep(address token, address to) external onlyFallback nonReentrant { + if (to == address(0)) revert ZeroAddress(); + uint256 balance = IERC20(token).balanceOf(address(this)); + _transfer(IERC20(token), to, balance); + if (token == address(EURE) && strandedSince != 0) { + strandedSince = 0; + emit Poked(0); + } + emit TokenSwept(token, to, balance); + } + + // ----------------------------------------------------------- guardian authority + + /// @notice Protective-only: blocks swaps (compliance holds, dormancy gate — R05). + /// Cannot move funds, change config, or block fallback paths. + function setGuardianPaused(bool paused) external onlyGuardian { + guardianPaused = paused; + emit GuardianPausedSet(paused); + } + + /// @dev P11: fee increases take effect only this long after their on-chain + /// announcement, so a client whose SEPA transfer is already in flight under + /// the current fee cannot be minted-and-swapped under a silently higher one. + /// Decreases are immediate — they only ever favor the client. + uint256 public constant FEE_INCREASE_TIMELOCK = 24 hours; + + /// @notice Guardian fee adjustment (P11), always bounded by the immutable + /// MAX_FEE_BPS. A decrease (or re-stating the current value) applies + /// immediately and cancels any pending increase; an increase is announced + /// and becomes applicable only after FEE_INCREASE_TIMELOCK. Announcing + /// again replaces the pending increase and restarts its clock. + function setFeeBps(uint16 newFeeBps) external onlyGuardian { + if (newFeeBps > MAX_FEE_BPS) revert FeeTooHigh(); + if (newFeeBps <= feeBps) { + if (pendingFeeBpsEffectiveAt != 0) { + emit FeeBpsIncreaseCancelled(pendingFeeBps); + pendingFeeBps = 0; + pendingFeeBpsEffectiveAt = 0; + } + if (newFeeBps != feeBps) { + emit FeeBpsDecreased(feeBps, newFeeBps); + feeBps = newFeeBps; + } + } else { + pendingFeeBps = newFeeBps; + pendingFeeBpsEffectiveAt = uint64(block.timestamp + FEE_INCREASE_TIMELOCK); + emit FeeBpsIncreaseAnnounced(feeBps, newFeeBps, pendingFeeBpsEffectiveAt); + } + } + + /// @notice Applies an announced fee increase once its timelock has elapsed. + /// Permissionless: the announcement is the authorization; anyone may + /// finalize it (the keeper does so as part of its cycle if needed). + function applyFeeBps() external { + if (pendingFeeBpsEffectiveAt == 0) revert NoPendingFee(); + if (block.timestamp < pendingFeeBpsEffectiveAt) revert DelayNotElapsed(); + emit FeeBpsIncreaseApplied(feeBps, pendingFeeBps); + feeBps = pendingFeeBps; + pendingFeeBps = 0; + pendingFeeBpsEffectiveAt = 0; + } + + // ---------------------------------------------------------------- helpers + + function _validateConfigAddress(address account) internal view { + if (account == address(0)) revert ZeroAddress(); + if ( + account == address(EURE) || account == address(EURC) || account == address(USDC) + || account == address(ROUTER) || account == address(this) + ) revert InvalidConfigAddress(); + } + + function _transfer(IERC20 token, address to, uint256 amount) internal { + if (amount == 0) return; + (bool success, bytes memory data) = address(token).call(abi.encodeCall(IERC20.transfer, (to, amount))); + if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } + + function _approve(IERC20 token, address spender, uint256 amount) internal { + (bool success, bytes memory data) = address(token).call(abi.encodeCall(IERC20.approve, (spender, amount))); + if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } +} diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol new file mode 100644 index 000000000..0e12fde06 --- /dev/null +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {VortexForwarder} from "./VortexForwarder.sol"; + +/// @title VortexForwarderFactory +/// @notice Deploys per-client VortexForwarder clones (EIP-1167, CREATE2) and holds the +/// guardian role plus bounded operational parameters shared by all clones +/// (implementation plan §2.2; parameter bounds are registry P6/P7). +contract VortexForwarderFactory { + address public immutable implementation; + + /// @dev Immutable bounds for the operational parameters (R10): the guardian can + /// tune values only inside [floor, ceiling]; the bounds themselves never move. + uint256 public immutable MIN_SWAP_FLOOR; + uint256 public immutable CAP_CEILING; + + address public guardian; + address public pendingGuardian; + mapping(address => bool) public isKeeper; + bool public globalPaused; + + uint256 public minSwapAmount; // registry P6 + uint256 public perSwapCap; // registry P7 + + mapping(address => bool) public isForwarder; + + event ForwarderDeployed( + address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt + ); + event KeeperSet(address indexed keeper, bool enabled); + event GlobalPausedSet(bool paused); + event MinSwapAmountSet(uint256 value); + event PerSwapCapSet(uint256 value); + event GuardianTransferStarted(address indexed current, address indexed pending); + event GuardianTransferred(address indexed previous, address indexed current); + + error NotGuardian(); + error NotPendingGuardian(); + error OutOfBounds(); + error CloneFailed(); + + modifier onlyGuardian() { + if (msg.sender != guardian) revert NotGuardian(); + _; + } + + constructor( + VortexForwarder.ImmutableConfig memory cfg, + uint256 minSwapFloor, + uint256 capCeiling, + uint256 initialMinSwapAmount, + uint256 initialPerSwapCap + ) { + guardian = msg.sender; + implementation = address(new VortexForwarder(cfg)); + MIN_SWAP_FLOOR = minSwapFloor; + CAP_CEILING = capCeiling; + _setMinSwapAmount(initialMinSwapAmount); + _setPerSwapCap(initialPerSwapCap); + } + + // ------------------------------------------------------------- deployment + + /// @notice Deploy and initialize a client forwarder in one transaction. The clone + /// address is deterministic (CREATE2) so it can be communicated/linked + /// reliably; predict it with `predictAddress` before deploying. + function deployForwarder(address destination, address fallbackAddress, uint16 feeBps, bytes32 salt) + external + onlyGuardian + returns (address forwarder) + { + forwarder = _cloneDeterministic(implementation, salt); + VortexForwarder(forwarder).initialize(destination, fallbackAddress, feeBps); + isForwarder[forwarder] = true; + emit ForwarderDeployed(forwarder, destination, fallbackAddress, feeBps, salt); + } + + function predictAddress(bytes32 salt) external view returns (address) { + bytes32 initCodeHash = keccak256(_cloneInitCode(implementation)); + return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash))))); + } + + // ------------------------------------------------------------- governance + + function setKeeper(address keeper, bool enabled) external onlyGuardian { + isKeeper[keeper] = enabled; + emit KeeperSet(keeper, enabled); + } + + function setGlobalPaused(bool paused) external onlyGuardian { + globalPaused = paused; + emit GlobalPausedSet(paused); + } + + function setMinSwapAmount(uint256 value) external onlyGuardian { + _setMinSwapAmount(value); + } + + function setPerSwapCap(uint256 value) external onlyGuardian { + _setPerSwapCap(value); + } + + /// @dev Two-step transfer: guardian is load-bearing for every clone's pause and + /// keeper gating, so a fat-fingered transfer must not be possible. + function transferGuardian(address newGuardian) external onlyGuardian { + pendingGuardian = newGuardian; + emit GuardianTransferStarted(guardian, newGuardian); + } + + function acceptGuardian() external { + if (msg.sender != pendingGuardian) revert NotPendingGuardian(); + emit GuardianTransferred(guardian, msg.sender); + guardian = msg.sender; + pendingGuardian = address(0); + } + + // ---------------------------------------------------------------- helpers + + function _setMinSwapAmount(uint256 value) internal { + if (value < MIN_SWAP_FLOOR || value > perSwapCap && perSwapCap != 0) revert OutOfBounds(); + minSwapAmount = value; + emit MinSwapAmountSet(value); + } + + function _setPerSwapCap(uint256 value) internal { + if (value > CAP_CEILING || value < minSwapAmount) revert OutOfBounds(); + perSwapCap = value; + emit PerSwapCapSet(value); + } + + /// @dev Standard EIP-1167 minimal proxy init code for `target`. + function _cloneInitCode(address target) internal pure returns (bytes memory) { + return abi.encodePacked( + hex"3d602d80600a3d3981f3363d3d373d3d3d363d73", target, hex"5af43d82803e903d91602b57fd5bf3" + ); + } + + function _cloneDeterministic(address target, bytes32 salt) internal returns (address instance) { + bytes memory initCode = _cloneInitCode(target); + // solhint-disable-next-line no-inline-assembly + assembly { + instance := create2(0, add(initCode, 0x20), mload(initCode), salt) + } + if (instance == address(0)) revert CloneFailed(); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol new file mode 100644 index 000000000..aff5591ec --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; + +interface IUniswapV3Factory { + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address); +} + +interface IERC20Meta { + function balanceOf(address) external view returns (uint256); + function decimals() external view returns (uint8); +} + +/// Mainnet fork tests against the real tokens, pools, router, and oracle. +/// Skipped when ETH_RPC_URL is not set. Addresses below are build-time pins — +/// re-verify per registry P10/G0 before any deployment. +contract VortexForwarderForkTest is Test { + // EURe V2 (Monerium, verified via CoinGecko + Etherscan 2026-07-10). V1 is deprecated. + address constant EURE_V2 = 0x39b8B6385416f4cA36a20319F70D28621895279D; + address constant EURE_V1_DEPRECATED = 0x3231Cb76718CDeF2155FC47b5286d82e6eDA273f; + address constant EURC = 0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant SWAP_ROUTER_02 = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; + address constant UNIV3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; + // Chainlink EUR/USD proxy — verify against data.chain.link before deploy (registry P8). + address constant CHAINLINK_EUR_USD = 0xb49f677943BC038e9857d61E7d053CaA2C1734C1; + + VortexForwarderFactory factory; + VortexForwarder fwd; + + address attestor = vm.addr(0xA11CE); + address destination = makeAddr("destination"); + address fallbackAddr = makeAddr("fallbackAddr"); + address keeper = makeAddr("keeper"); + + bool forked; + + function setUp() public { + string memory rpc = vm.envOr("ETH_RPC_URL", string("")); + if (bytes(rpc).length == 0) return; // tests will self-skip + vm.createSelectFork(rpc); + forked = true; + + factory = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: EURE_V2, + eurc: EURC, + usdc: USDC, + router: SWAP_ROUTER_02, + oracle: CHAINLINK_EUR_USD, + attestor: attestor, + feeRecipient: makeAddr("feeRecipient"), + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: 60 days, + triggerDelay: 24 hours, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + factory.setKeeper(keeper, true); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + } + + modifier onlyForked() { + vm.skip(!forked); + _; + } + + function test_fork_oracleIsEurUsdWithPlausiblePrice() public onlyForked { + assertEq(fwd.ORACLE_DECIMALS(), 8, "EUR/USD feed should have 8 decimals"); + (, int256 answer,, uint256 updatedAt,) = fwd.ORACLE().latestRoundData(); + // Plausibility band: a wrong feed address fails loudly here. + assertGt(answer, 0.8e8, "EUR/USD implausibly low"); + assertLt(answer, 1.6e8, "EUR/USD implausibly high"); + assertGt(updatedAt, 0); + } + + function test_fork_pinnedPathUsesV2AndPoolsExist() public onlyForked { + assertEq(address(fwd.EURE()), EURE_V2); + assertTrue(address(fwd.EURE()) != EURE_V1_DEPRECATED, "route must never touch deprecated V1 EURe"); + + // Both hops of the pinned path must exist on-chain with the pinned fee tiers. + address hop1 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURE_V2, EURC, fwd.POOL_FEE_EURE_EURC()); + address hop2 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURC, USDC, fwd.POOL_FEE_EURC_USDC()); + assertTrue(hop1 != address(0), "EURe/EURC pool missing at pinned fee tier"); + assertTrue(hop2 != address(0), "EURC/USDC pool missing at pinned fee tier"); + // The V2 pool must actually hold V2 tokens (stale-pool trap check). + assertGt(IERC20Meta(EURE_V2).balanceOf(hop1), 0, "pinned hop1 pool holds no V2 EURe"); + } + + function test_fork_swapAndForward_executesWithinOracleBounds() public onlyForked { + uint256 amountIn = 1_000e18; + deal(EURE_V2, address(fwd), amountIn); // stdStorage balance override + + (, int256 answer,,,) = fwd.ORACLE().latestRoundData(); + uint256 fair = (amountIn * uint256(answer)) / 1e20; // 6-dec USDC at oracle rate + + vm.prank(keeper); + fwd.swapAndForward(); + + uint256 received = IERC20Meta(USDC).balanceOf(destination); + assertGe(received, (fair * 9_900) / 10_000, "below oracle-bounded minOut"); + assertLe(received, (fair * 10_300) / 10_000, "implausibly above oracle rate"); + assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 0, "EURe left behind"); + assertEq(IERC20Meta(USDC).balanceOf(address(fwd)), 0, "USDC left behind"); + } + + function test_fork_perSwapCapLeavesRemainder() public onlyForked { + deal(EURE_V2, address(fwd), 12_000e18); // cap is 10k + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 2_000e18); + assertGt(IERC20Meta(USDC).balanceOf(destination), 0); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol new file mode 100644 index 000000000..b959d0b9f --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; +import {MockERC20, MockOracle, MockRouter} from "./VortexForwarder.t.sol"; + +/// Randomized action handler. Ghost variables track every token unit entering the +/// system so the invariants below can assert exit-path exhaustiveness (plan §2.3.1): +/// EURe may sit in the forwarder, be consumed by the router, or reach the client's +/// fallback; USDC may only reach destination + feeRecipient; nothing else, ever. +contract ForwarderHandler is Test { + VortexForwarderFactory public factory; + VortexForwarder public fwd; + MockERC20 public eure; + MockERC20 public usdc; + MockERC20 public eurc; + MockOracle public oracle; + MockRouter public router; + + address public destination = makeAddr("destination"); + address public fallbackAddr = makeAddr("fallbackAddr"); + address public keeper = makeAddr("keeper"); + address public rando = makeAddr("rando"); + address public feeRecipient = makeAddr("feeRecipient"); + + uint256 public ghostEureMinted; + uint256 public ghostUsdcPaidByRouter; + uint256 public fallbackSweepFailures; + uint16 public immutable INITIAL_FEE_BPS = 50; + + constructor() { + eure = new MockERC20("EURe", 18); + eurc = new MockERC20("EURC", 6); + usdc = new MockERC20("USDC", 6); + oracle = new MockOracle(); + router = new MockRouter(eure, usdc); + + factory = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(router), + oracle: address(oracle), + attestor: vm.addr(0xA11CE), + feeRecipient: feeRecipient, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: 60 days, + triggerDelay: 24 hours, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + factory.setKeeper(keeper, true); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, INITIAL_FEE_BPS, bytes32(uint256(1)))); + ghostExpectedFeeBps = INITIAL_FEE_BPS; + } + + // ------------------------------------------------------------- actions + + function fund(uint96 raw) external { + uint256 amount = bound(uint256(raw), 0, 20_000e18); + eure.mint(address(fwd), amount); + ghostEureMinted += amount; + } + + function doPoke() external { + fwd.poke(); + } + + function warp(uint32 raw) external { + vm.warp(block.timestamp + bound(uint256(raw), 1, 90 days)); + } + + /// Router pays a randomized amount around the fair oracle value; underpayment + /// exercises the minOut revert path, overpayment the happy path. + function keeperSwap(uint96 raw) external { + _swapAs(keeper, raw); + } + + function randoSwap(uint96 raw) external { + _swapAs(rando, raw); + } + + function _swapAs(address caller, uint96 raw) internal { + oracle.set(1.14e8, block.timestamp); + uint256 balance = eure.balanceOf(address(fwd)); + uint256 amountIn = balance > 10_000e18 ? 10_000e18 : balance; + uint256 fair = (amountIn * 1.14e8) / 1e20; + // 95%..105% of fair value; below 99% the swap must revert on minOut. + uint256 payout = bound(uint256(raw), (fair * 95) / 100, (fair * 105) / 100); + router.setNextOut(payout); + + uint256 routerUsdcBefore = usdc.totalMinted(); + vm.prank(caller); + try fwd.swapAndForward() { + ghostUsdcPaidByRouter += usdc.totalMinted() - routerUsdcBefore; + } catch {} + } + + function sweepStranded() external { + try fwd.sweepStrandedEure() {} catch {} + } + + function guardianPause(bool paused) external { + fwd.setGuardianPaused(paused); // handler deployed the factory -> handler is guardian + } + + /// P11 ghost model: what feeBps is allowed to be right now. Decreases apply + /// immediately; increases only after their announced timelock elapses AND + /// someone calls applyFeeBps. + uint16 public ghostExpectedFeeBps; + + function guardianSetFee(uint16 raw) external { + uint16 newFee = raw % 120; // exercise the FeeTooHigh branch too (MAX is 100) + try fwd.setFeeBps(newFee) { + if (newFee <= ghostExpectedFeeBps) { + ghostExpectedFeeBps = newFee; // decrease/cancel: immediate + } + // increase: pending only — ghost updates when applyFee succeeds + } catch {} + } + + function applyFee() external { + try fwd.applyFeeBps() { + ghostExpectedFeeBps = fwd.feeBps(); // apply succeeded past its timelock + } catch {} + } + + function clientPause(bool paused) external { + vm.prank(fallbackAddr); + fwd.setClientPaused(paused); + } + + /// The client exit hatch must NEVER fail, including while paused (plan §2.3.4). + function clientSweepEure() external { + vm.prank(fallbackAddr); + try fwd.sweep(address(eure), fallbackAddr) {} + catch { + fallbackSweepFailures++; + } + } + + function randoTriesPrivilegedCalls(uint8 selector) external { + vm.startPrank(rando); + if (selector % 5 == 0) try fwd.setDestination(rando) {} catch {} + if (selector % 5 == 1) try fwd.setGuardianPaused(true) {} catch {} + if (selector % 5 == 2) try fwd.setFallbackAddress(rando) {} catch {} + if (selector % 5 == 3) try fwd.sweep(address(eure), rando) {} catch {} + if (selector % 5 == 4) try fwd.setFeeBps(99) {} catch {} + vm.stopPrank(); + } +} + +contract VortexForwarderInvariantTest is Test { + ForwarderHandler handler; + + function setUp() public { + handler = new ForwarderHandler(); + targetContract(address(handler)); + } + + /// Exit-path exhaustiveness for EURe: every unit ever minted into the forwarder is + /// either still there, consumed by the router (swap), or at the client's fallback. + function invariant_eureConservation() public view { + uint256 accounted = handler.eure().balanceOf(address(handler.fwd())) + + handler.eure().balanceOf(address(handler.router())) + handler.eure().balanceOf(handler.fallbackAddr()); + assertEq(accounted, handler.ghostEureMinted(), "EURe leaked to an unexpected address"); + } + + /// Exit-path exhaustiveness for USDC: everything the router ever paid ends up + /// split between destination and feeRecipient; the forwarder retains nothing. + function invariant_usdcOnlyReachesDestinationAndFee() public view { + uint256 accounted = + handler.usdc().balanceOf(handler.destination()) + handler.usdc().balanceOf(handler.feeRecipient()); + assertEq(accounted, handler.ghostUsdcPaidByRouter(), "USDC leaked to an unexpected address"); + assertEq(handler.usdc().balanceOf(address(handler.fwd())), 0, "forwarder retained USDC"); + } + + /// Config changes only through their authorized paths: feeBps moves exclusively + /// via the guardian's timelocked setter (P11 ghost model tracks every legal + /// transition — a rando call or an early apply can never move it), and it never + /// exceeds MAX_FEE_BPS; destination/fallback never change without their owner. + function invariant_configIntegrity() public view { + assertEq(handler.fwd().feeBps(), handler.ghostExpectedFeeBps(), "feeBps moved outside the guardian timelock path"); + assertLe(handler.fwd().feeBps(), 100, "feeBps exceeded MAX_FEE_BPS"); + assertEq(handler.fwd().destination(), handler.destination()); + assertEq(handler.fwd().fallbackAddress(), handler.fallbackAddr()); + } + + /// Guardian/global pause must never block the client's exit hatch. + function invariant_fallbackSweepNeverBlocked() public view { + assertEq(handler.fallbackSweepFailures(), 0, "client exit hatch was blocked"); + } + + /// The stranding marker never points into the future. + function invariant_strandedSinceNotInFuture() public view { + assertLe(handler.fwd().strandedSince(), block.timestamp); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol new file mode 100644 index 000000000..049f341f4 --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -0,0 +1,622 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VortexForwarder, IERC20, ISwapRouter02} from "../src/VortexForwarder.sol"; +import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; + +contract MockERC20 { + string public name; + uint8 public decimals; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(string memory name_, uint8 decimals_) { + name = name_; + decimals = decimals_; + } + + uint256 public totalMinted; + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalMinted += amount; + } + + function transfer(address to, uint256 amount) external returns (bool) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } +} + +contract MockOracle { + int256 public answer = 1.14e8; // EUR/USD + uint256 public updatedAt = block.timestamp; + uint8 public constant decimals = 8; + + function set(int256 answer_, uint256 updatedAt_) external { + answer = answer_; + updatedAt = updatedAt_; + } + + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { + return (1, answer, updatedAt, updatedAt, 1); + } +} + +contract MockRouter { + MockERC20 public immutable eure; + MockERC20 public immutable usdc; + uint256 public nextOut; + + constructor(MockERC20 eure_, MockERC20 usdc_) { + eure = eure_; + usdc = usdc_; + } + + function setNextOut(uint256 v) external { + nextOut = v; + } + + function exactInput(ISwapRouter02.ExactInputParams calldata params) external payable returns (uint256) { + eure.transferFrom(msg.sender, address(this), params.amountIn); + require(nextOut >= params.amountOutMinimum, "Too little received"); + usdc.mint(params.recipient, nextOut); + return nextOut; + } +} + +/// Malicious router that tries to re-enter swapAndForward during the swap. +contract MockReentrantRouter { + function exactInput(ISwapRouter02.ExactInputParams calldata) external payable returns (uint256) { + VortexForwarder(msg.sender).swapAndForward(); // must revert via reentrancy guard + return 0; + } +} + +contract VortexForwarderTest is Test { + MockERC20 eure; + MockERC20 eurc; + MockERC20 usdc; + MockOracle oracle; + MockRouter router; + VortexForwarderFactory factory; + VortexForwarder fwd; + + uint256 attestorPk = 0xA11CE; + address attestor; + address feeRecipient = makeAddr("feeRecipient"); + address destination = makeAddr("destination"); + address fallbackAddr = makeAddr("fallbackAddr"); + address keeper = makeAddr("keeper"); + address rando = makeAddr("rando"); + + uint256 constant TRIGGER_DELAY = 24 hours; + uint256 constant SWEEP_DELAY = 60 days; + + function setUp() public { + attestor = vm.addr(attestorPk); + eure = new MockERC20("EURe", 18); + eurc = new MockERC20("EURC", 6); + usdc = new MockERC20("USDC", 6); + oracle = new MockOracle(); + router = new MockRouter(eure, usdc); + + factory = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(router), + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: SWEEP_DELAY, + triggerDelay: TRIGGER_DELAY, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, // MIN_SWAP_FLOOR + 50_000e18, // CAP_CEILING + 25e18, // minSwapAmount + 10_000e18 // perSwapCap + ); + factory.setKeeper(keeper, true); + fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + } + + // ---------------------------------------------------------------- helpers + + function _attest(address forwarder, bytes32 hash) internal view returns (bytes memory) { + bytes32 bound = keccak256(abi.encodePacked(block.chainid, forwarder, hash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); + return abi.encodePacked(r, s, v); + } + + function _fund(uint256 amount) internal { + eure.mint(address(fwd), amount); + } + + // ---------------------------------------------------------------- EIP-1271 + + function test_linkSignature_valid_eip191Only() public view { + bytes32 h191 = fwd.LINK_HASH_191(); + assertEq(fwd.isValidSignature(h191, _attest(address(fwd), h191)), bytes4(0x1626ba7e)); + // Raw-keccak variant was removed after G0 sandbox validation confirmed Monerium + // presents the EIP-191 hash; it must now be rejected even with a valid attestor sig. + bytes32 hRaw = keccak256(bytes("I hereby declare that I am the address owner.")); + assertEq(fwd.isValidSignature(hRaw, _attest(address(fwd), hRaw)), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsCrossChainReplay() public { + bytes32 h = fwd.LINK_HASH_191(); + bytes memory sig = _attest(address(fwd), h); // bound to current chainid + vm.chainId(999); + assertEq(fwd.isValidSignature(h, sig), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsMalleatedAndMalformed() public view { + bytes32 h = fwd.LINK_HASH_191(); + bytes memory good = _attest(address(fwd), h); + // Malleate: s' = n - s, v' = flipped — same ECDSA validity, must be rejected. + (bytes32 r, bytes32 s, uint8 v) = (bytes32(0), bytes32(0), 0); + assembly { + r := mload(add(good, 0x20)) + s := mload(add(good, 0x40)) + v := byte(0, mload(add(good, 0x60))) + } + uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + bytes memory malleated = abi.encodePacked(r, bytes32(n - uint256(s)), v == 27 ? uint8(28) : uint8(27)); + assertEq(fwd.isValidSignature(h, malleated), bytes4(0xffffffff)); + // Wrong lengths. + assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s)), bytes4(0xffffffff)); + assertEq(fwd.isValidSignature(h, abi.encodePacked(good, uint8(1))), bytes4(0xffffffff)); + // Bad v. + assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s, uint8(29))), bytes4(0xffffffff)); + } + + function test_recoveryHash_enabledBranch() public { + bytes32 recoveryHash = keccak256("monerium-recovery-message-placeholder"); + VortexForwarderFactory f2 = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(router), + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: SWEEP_DELAY, + triggerDelay: TRIGGER_DELAY, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: recoveryHash + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(8)))); + // Recovery hash validates with attestor binding; link still validates; others fail. + bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(fwd2), recoveryHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(attestorPk, bound); + assertEq(fwd2.isValidSignature(recoveryHash, abi.encodePacked(r, s, v)), bytes4(0x1626ba7e)); + bytes32 h191 = fwd2.LINK_HASH_191(); + bytes32 bound191 = keccak256(abi.encodePacked(block.chainid, address(fwd2), h191)); + (v, r, s) = vm.sign(attestorPk, bound191); + assertEq(fwd2.isValidSignature(h191, abi.encodePacked(r, s, v)), bytes4(0x1626ba7e)); + bytes32 evil = keccak256("anything else"); + bytes32 boundEvil = keccak256(abi.encodePacked(block.chainid, address(fwd2), evil)); + (v, r, s) = vm.sign(attestorPk, boundEvil); + assertEq(fwd2.isValidSignature(evil, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); + } + + function test_linkHash191_matchesEip191OfFixedMessage() public view { + bytes memory msg_ = bytes("I hereby declare that I am the address owner."); + assertEq(msg_.length, 45); + assertEq(fwd.LINK_HASH_191(), keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n45", msg_))); + } + + function test_linkSignature_rejectsForeignHash() public view { + bytes32 evil = keccak256("Send EUR 100000 to DE00ATTACKER at 2026-07-17T00:00Z"); + assertEq(fwd.isValidSignature(evil, _attest(address(fwd), evil)), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsWrongSigner() public { + bytes32 h = fwd.LINK_HASH_191(); + bytes32 bound = keccak256(abi.encodePacked(block.chainid, address(fwd), h)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xBAD, bound); + assertEq(fwd.isValidSignature(h, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); + } + + function test_linkSignature_rejectsCrossCloneReplay() public { + VortexForwarder other = + VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(2)))); + bytes32 h = fwd.LINK_HASH_191(); + // Signature bound to `fwd` must not validate on `other`. + assertEq(other.isValidSignature(h, _attest(address(fwd), h)), bytes4(0xffffffff)); + } + + // ---------------------------------------------------------------- init + + function test_initialize_onlyFactory_andOnce() public { + vm.expectRevert(VortexForwarder.NotFactory.selector); + fwd.initialize(rando, rando, 0); + + vm.prank(address(factory)); + vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); + fwd.initialize(rando, rando, 0); + } + + function test_implementation_isBricked() public { + VortexForwarder impl = VortexForwarder(factory.implementation()); + vm.prank(address(factory)); + vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); + impl.initialize(rando, rando, 0); + } + + // ---------------------------------------------------------------- swap + + function test_swapAndForward_happyPath_forwardsToDestination() public { + _fund(1_000e18); + // minOut = 1000 * 1.14 * 0.99 = 1128.6 USDC + router.setNextOut(1_130e6); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(usdc.balanceOf(destination), 1_130e6); + assertEq(eure.balanceOf(address(fwd)), 0); + assertEq(eure.allowance(address(fwd), address(router)), 0); + } + + function test_swapAndForward_enforcesOracleMinOut() public { + _fund(1_000e18); + router.setNextOut(1_100e6); // below 1128.6 -> router-side minOut check fires + vm.prank(keeper); + vm.expectRevert("Too little received"); + fwd.swapAndForward(); + } + + function test_swapAndForward_revertsOnStaleOracle() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + oracle.set(1.14e8, block.timestamp); + skip(53 hours); // just past the 52h P8 window + vm.prank(keeper); + vm.expectRevert(VortexForwarder.StalePrice.selector); + fwd.swapAndForward(); + } + + function test_swapAndForward_publicOnlyAfterTriggerDelay() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.swapAndForward(); + + fwd.poke(); + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.swapAndForward(); + + skip(TRIGGER_DELAY + 1); + oracle.set(1.14e8, block.timestamp); + vm.prank(rando); + fwd.swapAndForward(); + assertEq(usdc.balanceOf(destination), 1_130e6); + } + + function test_swapAndForward_revertsOnZeroOrNegativePrice() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + oracle.set(0, block.timestamp); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InvalidPrice.selector); + fwd.swapAndForward(); + oracle.set(-1, block.timestamp); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InvalidPrice.selector); + fwd.swapAndForward(); + } + + /// Review r1 P2: a perSwapCap remainder must keep its stranding timers armed — + /// the swap re-arms the marker rather than clearing it when balance stays >= floor. + function test_swapAndForward_reArmsMarkerForCapRemainder() public { + _fund(15_000e18); // cap is 10k + fwd.poke(); + assertGt(fwd.strandedSince(), 0); + router.setNextOut(11_290e6); + skip(1 hours); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(eure.balanceOf(address(fwd)), 5_000e18); + assertEq(fwd.strandedSince(), block.timestamp, "remainder must stay armed (fresh timestamp)"); + } + + function test_swapAndForward_respectsPerSwapCap() public { + _fund(15_000e18); // cap is 10k + // minOut for 10k at 1.14*0.99 = 11286 USDC + router.setNextOut(11_290e6); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(eure.balanceOf(address(fwd)), 5_000e18); // remainder awaits next execution + } + + function test_swapAndForward_feeSkim() public { + VortexForwarder feeFwd = + VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 50, bytes32(uint256(3)))); + eure.mint(address(feeFwd), 1_000e18); + router.setNextOut(1_130e6); + vm.prank(keeper); + feeFwd.swapAndForward(); + uint256 fee = (1_130e6 * 50) / 10_000; + assertEq(usdc.balanceOf(feeRecipient), fee); + assertEq(usdc.balanceOf(destination), 1_130e6 - fee); + } + + function test_swapAndForward_pausedByGuardianOrClientOrGlobal() public { + _fund(1_000e18); + router.setNextOut(1_130e6); + + fwd.setGuardianPaused(true); // test contract is factory guardian + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swapAndForward(); + fwd.setGuardianPaused(false); + + vm.prank(fallbackAddr); + fwd.setClientPaused(true); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swapAndForward(); + vm.prank(fallbackAddr); + fwd.setClientPaused(false); + + factory.setGlobalPaused(true); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swapAndForward(); + } + + function test_unsolicitedUsdc_forwardedWithNextSwap() public { + usdc.mint(address(fwd), 500e6); // unsolicited direct transfer (R09) + _fund(1_000e18); + router.setNextOut(1_130e6); + vm.prank(keeper); + fwd.swapAndForward(); + assertEq(usdc.balanceOf(destination), 1_130e6 + 500e6); + } + + function test_reentrantRouter_blockedByGuard() public { + MockReentrantRouter evil = new MockReentrantRouter(); + VortexForwarderFactory f2 = new VortexForwarderFactory( + VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: address(evil), + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h + slippageBps: 100, + maxFeeBps: 100, + sweepDelay: SWEEP_DELAY, + triggerDelay: TRIGGER_DELAY, + poolFeeEureEurc: 500, + poolFeeEurcUsdc: 500, + recoveryHash: bytes32(0) + }), + 1e18, + 50_000e18, + 25e18, + 10_000e18 + ); + f2.setKeeper(keeper, true); + VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(7)))); + eure.mint(address(fwd2), 1_000e18); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.Reentrancy.selector); + fwd2.swapAndForward(); + } + + // ---------------------------------------------------------------- recovery + + function test_sweepStrandedEure_afterDelay_toFallbackOnly() public { + _fund(500e18); + fwd.poke(); + + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.sweepStrandedEure(); + + skip(SWEEP_DELAY + 1); + vm.prank(rando); // permissionless + fwd.sweepStrandedEure(); + assertEq(eure.balanceOf(fallbackAddr), 500e18); + assertEq(fwd.strandedSince(), 0); + } + + /// Review r1 F1 regression: raising the tunable minSwapAmount above a stranded + /// balance must NOT let a poke() clear the marker — the dead-man sweep is armed + /// against the immutable MIN_SWAP_FLOOR and must survive any guardian action. + function test_guardianCannotDisarmDeadManSweep_byRaisingMinSwap() public { + _fund(500e18); + fwd.poke(); + assertGt(fwd.strandedSince(), 0); + + factory.setMinSwapAmount(1_000e18); // guardian raises threshold above balance + fwd.poke(); // anyone can poke; marker must survive + assertGt(fwd.strandedSince(), 0, "guardian disarmed the dead-man sweep"); + + skip(SWEEP_DELAY + 1); + fwd.sweepStrandedEure(); + assertEq(eure.balanceOf(fallbackAddr), 500e18); + } + + function test_fallbackSweep_worksWhilePaused() public { + _fund(500e18); + fwd.setGuardianPaused(true); + vm.prank(fallbackAddr); + fwd.sweep(address(eure), fallbackAddr); + assertEq(eure.balanceOf(fallbackAddr), 500e18); + } + + function test_fallbackEureSweep_resetsDeadManTimer() public { + _fund(500e18); + fwd.poke(); + skip(SWEEP_DELAY + 1); + + vm.prank(fallbackAddr); + fwd.sweep(address(eure), fallbackAddr); + assertEq(fwd.strandedSince(), 0); + + _fund(500e18); + vm.expectRevert(VortexForwarder.NotStranded.selector); + fwd.sweepStrandedEure(); + } + + function test_fallbackAuthority_gated() public { + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotFallbackAddress.selector); + fwd.setDestination(rando); + + address newDest = makeAddr("newDest"); + vm.prank(fallbackAddr); + fwd.setDestination(newDest); + assertEq(fwd.destination(), newDest); + } + + function test_guardianPause_gated() public { + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotGuardian.selector); + fwd.setGuardianPaused(true); + } + + // ---------------------------------------------------------------- factory + + function test_predictAddress_matchesDeployment() public { + bytes32 salt = bytes32(uint256(42)); + address predicted = factory.predictAddress(salt); + address deployed = factory.deployForwarder(destination, fallbackAddr, 0, salt); + assertEq(predicted, deployed); + } + + function test_factory_paramBounds() public { + vm.expectRevert(VortexForwarderFactory.OutOfBounds.selector); + factory.setPerSwapCap(60_000e18); // above ceiling + + vm.expectRevert(VortexForwarderFactory.OutOfBounds.selector); + factory.setMinSwapAmount(0.5e18); // below floor + + vm.expectRevert(VortexForwarderFactory.OutOfBounds.selector); + factory.setMinSwapAmount(20_000e18); // above current cap + } + + function test_feeBps_cappedAtMax() public { + vm.expectRevert(VortexForwarder.FeeTooHigh.selector); + factory.deployForwarder(destination, fallbackAddr, 101, bytes32(uint256(9))); + } + + // ------------------------------------------------------- fee timelock (P11) + + function test_setFeeBps_onlyGuardianAndCapped() public { + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotGuardian.selector); + fwd.setFeeBps(10); + + vm.expectRevert(VortexForwarder.FeeTooHigh.selector); + fwd.setFeeBps(101); // above MAX_FEE_BPS, even for the guardian + } + + function test_setFeeBps_increaseIsTimelocked() public { + fwd.setFeeBps(50); + // Announced, not applied: swaps in the window still use the old fee. + assertEq(fwd.feeBps(), 0); + assertEq(fwd.pendingFeeBps(), 50); + assertEq(fwd.pendingFeeBpsEffectiveAt(), uint64(block.timestamp + fwd.FEE_INCREASE_TIMELOCK())); + + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.applyFeeBps(); + + vm.warp(block.timestamp + 24 hours); + vm.prank(rando); // apply is permissionless — the announcement is the authorization + fwd.applyFeeBps(); + assertEq(fwd.feeBps(), 50); + assertEq(fwd.pendingFeeBps(), 0); + assertEq(fwd.pendingFeeBpsEffectiveAt(), 0); + + vm.expectRevert(VortexForwarder.NoPendingFee.selector); + fwd.applyFeeBps(); + } + + function test_setFeeBps_decreaseIsImmediateAndCancelsPending() public { + // Raise to 50 through the timelock first. + fwd.setFeeBps(50); + vm.warp(block.timestamp + 24 hours); + fwd.applyFeeBps(); + + // Announce a further increase, then decrease before it applies: the decrease + // is immediate and the pending increase is cancelled. + fwd.setFeeBps(80); + fwd.setFeeBps(25); + assertEq(fwd.feeBps(), 25); + assertEq(fwd.pendingFeeBpsEffectiveAt(), 0); + vm.warp(block.timestamp + 24 hours); + vm.expectRevert(VortexForwarder.NoPendingFee.selector); + fwd.applyFeeBps(); + } + + function test_setFeeBps_reannounceReplacesAndRestartsClock() public { + fwd.setFeeBps(50); + vm.warp(block.timestamp + 12 hours); + fwd.setFeeBps(80); // replaces the pending 50 and restarts the 24h clock + assertEq(fwd.pendingFeeBps(), 80); + + vm.warp(block.timestamp + 12 hours + 1); // 24h after FIRST announcement only + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.applyFeeBps(); + + vm.warp(block.timestamp + 12 hours); + fwd.applyFeeBps(); + assertEq(fwd.feeBps(), 80); + } + + function test_setFeeBps_restatingCurrentCancelsWithoutChange() public { + fwd.setFeeBps(50); + fwd.setFeeBps(0); // re-state the current value: cancel-only gesture + assertEq(fwd.feeBps(), 0); + assertEq(fwd.pendingFeeBpsEffectiveAt(), 0); + } + + function test_swapDuringPendingIncrease_usesOldFee() public { + fwd.setFeeBps(50); // pending, not applied + _fund(1_000e18); + router.setNextOut(1_140e6); + vm.prank(keeper); + fwd.swapAndForward(); + // Zero fee taken: the announced-but-unapplied increase never touches a swap. + assertEq(usdc.balanceOf(feeRecipient), 0); + assertEq(usdc.balanceOf(destination), 1_140e6); + } +} diff --git a/docs/README.md b/docs/README.md index f40111798..b767fa270 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,13 +20,19 @@ The smaller set of general project documents stays directly in `docs/`: | [`adr-0002-alfredpay-fee-collection.md`](adr-0002-alfredpay-fee-collection.md) | Accepted decision on Alfredpay fee collection and sequential EVM fee distribution | | [`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 | | [`architecture-email-notifications.md`](architecture-email-notifications.md) | Current transactional/auth email architecture: queue, dispatch, producers | | [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | +| [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md) | Current end-to-end architecture of the B2B EUR onramp: onboarding, deposit-to-payout, batching, fees, data model | | [`operations-demo-environment.md`](operations-demo-environment.md) | Setup and runbook for the sandbox sales-demo account | | [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | +| [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md) | Launch gates, deploy checklist, and terms inputs for the B2B onramp pilot | +| [`operations-monerium-b2b-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) | Focused white-label Monerium profile, address, IBAN, and payment interface reference | | [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | +| [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md) | Phase-2 proposal for the consumer (Safe + passkey) Monerium onramp; the B2B variant shipped | | [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | | [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md new file mode 100644 index 000000000..b582acc8f --- /dev/null +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -0,0 +1,152 @@ +# ADR 0005: Monerium B2B Zero-Touch Onramp + +**Status:** Accepted (selected 2026-07-17; parameters finalized and documents consolidated +2026-08-26). This ADR is the single source of truth for the *decisions and risk +acceptances* of the B2B EUR → USDC onramp. How the system works lives in +[`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); security +invariants and the threat model in +[`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md); +launch gates and terms inputs in +[`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md); procedures in +[`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). The +consumer-flow design this grew out of remains a phase-2 proposal: +[`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md). + +## Context + +Partner-sourced business clients (SulPayments' OTC corporates, KYB'd under the partner's +FINMA/VQF licence with per-customer reliance attestations to Monerium) need EUR → USDC +on Ethereum with **zero Vortex-side interaction**: no app, no wallet ceremony, no digital +signature. Onboarding is paperwork only. The single blocker in the consumer design was +Monerium's link signature — connecting an IBAN to an on-chain address requires that +address to approve the fixed ownership message `"I hereby declare that I am the address +owner."` via EIP-1271. + +## Decision: the attestor-constrained forwarder + +Each client gets a dedicated minimal forwarding contract (`VortexForwarder`, an EIP-1167 +clone of one immutable implementation, deployed via a CREATE2 factory and initialized +atomically) whose `isValidSignature` accepts **exactly one construction**: the Vortex +attestor key's signature over `keccak256(chainid ‖ address(this) ‖ hash)` where `hash` +is the EIP-191 hash of the fixed ownership message. Vortex can therefore complete the +Monerium link with no client involvement, while the attestor key is provably not a means +of access to funds. + +**Why the naive alternative is unsafe:** Monerium validates *redeem orders* ("Send EUR +`` to `` …") through the same EIP-1271 interface. A general-purpose +validation key would let its holder redeem the client's EURe to an arbitrary IBAN — +a fiat theft path and unambiguous custody. The whole design follows from closing it. + +Supporting decisions, all in force: + +- **Conversion policy** (unchanged from the consumer design): pinned EURe→EURC→USDC + Uniswap v3 route, contract-constructed calldata (never caller-supplied — the swap is + permissionless after the trigger delay), Chainlink EUR/USD minimum-output bound with + staleness ceiling, exact approvals, atomic delta checks, fee skim to an immutable + treasury. +- **No upgradeability, ever.** Immutable-and-migratable: evolution (new pools, new + routes, contract fixes) happens by deploying a new implementation + factory and + migrating clients clone-by-clone — never by mutating deployed code. The custody + argument depends on it. +- **Mandatory self-custodied `fallbackAddress`** for every client (Tier C "no fallback" + dropped 2026-07-17 — condition of Monerium's acceptance; Tier B "partner-held + recovery key" rejected 2026-07-14 — the partner declines custody-like powers). All + emergency flows point to it: client `sweep`/config functions plus the permissionless + dead-man sweep. +- **Never send raw EURe to a CEX destination** — EURe recovery targets are the + fallback address only. +- **No on-contract redeem validator.** Redemption = withdraw to fallback, then redeem + normally; Monerium's issuer recovery is the break-glass backstop (see T1 below). +- **EIP-191 hash only, chainid-bound** (the raw-keccak variant was removed after the G0 + sandbox validation; chainid binding closes cross-chain replay — review r1). +- **Three distinct Vortex keys** (attestor / keeper / guardian), none able to move or + redirect funds; the keeper runs on exactly one backend (the mykobo flow variant). +- **Managed-profile integration:** each client is a managed child profile under the + partner manager (KYB mirror, credentials, read API, webhook tenancy). The flow is + quoteless and deliberately **not** part of `ramp_states` — evaluated and rejected + 2026-08-26 (N:M deposit↔execution batching, no quote, phase-machinery mismatch); a + read-level projection is the path if unified history is ever wanted. Tables stay + `monerium_*` (the legacy OAuth integration owns no tables; no collision). +- **Deposit webhooks as a generic event family** (`DEPOSIT_RECEIVED` / + `DEPOSIT_CONVERTED`) on the public webhook contract, delivered durably (outbox, + at-least-once) to the partner manager. A cap-split deposit emits one final + `DEPOSIT_CONVERTED` after all portions settle, with `conversions[]` and aggregate + attributed USDC rather than a misleading event per chunk. + +## Final parameters (decided 2026-08-26 unless noted) + +| ID | Parameter | Value | +|---|---|---| +| B1 | Service fee | **0 bps pilot / 15 bps GA starting point** (per client, guardian-adjustable) | +| B2 | Penny-test amount | 5 USDC | +| B3 | Processing SLA wording | **Same business day**; weekend mints execute within the 52 h oracle window at possibly wider spreads | +| B4 | Pilot volume limits | **€50k/client/day, paper/contractual only** (no backend enforcement in the pilot; GA revisit) | +| B5 | Partner liability | Tier A defaults: partner warrants destination correctness; rotation loss borne by the client; dormancy re-activation on written partner confirmation | +| B6 | Redemption-limitation disclosure | Mandatory in client terms (committed to Monerium); draft in the rollout doc | +| P1 | `SLIPPAGE_BPS` | 100 (1%) | +| P2 | `MAX_FEE_BPS` | 100 (1%), immutable | +| P3 | Dead-man sweep delay | 60 days | +| P4 | Permissionless trigger delay | 24 h | +| P5 | Dormancy window | 60 days | +| P6 | `minSwapAmount` | floor €25 (immutable) / operational **€250** | +| P7 | `perSwapCap` | operational **€25k** / ceiling €50k (re-measure liquidity at the deploy block before raising) | +| P8 | `MAX_ORACLE_AGE` | **52 h** (observed Chainlink EUR/USD weekend gaps up to 48 h; applied to configs 2026-08-26) | +| P9 | Notification confirmation depth | 32 blocks (implemented) | +| P10 | Router pin | SwapRouter02, 5 bps fee tiers; re-verify pools at the deploy block | +| P11 | Fee adjustability | Guardian `setFeeBps` within `MAX_FEE_BPS`; increases behind a 24 h announced timelock, decreases immediate (implemented) | +| T2 | Whitelabel MSA terms | Open — G1 negotiation (rollout doc), includes the per-IBAN suspension ask | +| T3 | KYB submission mechanism | Open, deliberately unbuilt — pilot corporates are approved by Monerium under partner KYC reliance and imported via the admin mapping; no identity-data submission path may exist until this settles (security-spec invariant 11) | +| T4 | Sandbox wire-format verifications | Webhook digest encoding, delivery id field, order-state vocabulary, and the EIP-191 link-hash variant were confirmed against the sandbox during G0; re-verify against production before first mainnet deposit | +| T1 | Issuer recovery message | **Resolved (verbal, 2026-08-26): identical to the link message** — already whitelisted, recovery works as built; `RECOVERY_HASH` stays 0; written confirmation folds into the G1 package | +| O1 | Client-migration tooling | Build when first needed; manual procedure in the runbook meanwhile | +| O2 | `FEE_RECIPIENT` treasury | **New dedicated Safe multisig** (immutable at implementation deploy); guardian key to hardware/multisig custody at GA | + +## Review history (details in git history) + +The consumer PRD went through an 18-finding architecture review (dispositioned in the +PRD's appendix) and a 12-finding re-review; the B2B build dispositioned the re-review as +follows — R01 manifest is consistency evidence, not a trust root (accepted); R03 +enforceable delay start via the on-chain `strandedSince` marker (resolved); R04 +snapshot-based attribution under per-forwarder advisory locks (resolved); R05 per-clone +protective-only guardian pause (resolved); R06 durable webhook persist-before-200 +(resolved); R07 client config changes reconciled as expected transitions (accepted); R09 +unsolicited-token rules incl. flagged unattributed inflows (resolved); R10 role/parameter +bound invariants as audit targets (resolved); R11 exit guarantees scoped to fallback-key +availability (accepted); R02/R08 consumer-only. A 10-finding internal code review (r1) +was fixed/dispositioned in July, and a 19-finding deep review (multi-lens + adversarial +verification) was fully fixed 2026-08-26, plus one attribution defect found by worked +example (oversized-deposit allocation). + +## Risks accepted (with their compensating controls) + +- **S0 — provisioning trust.** Vortex deploys and configures the contracts with no + client verification moment; the published manifest + verifier make deployments + *checkable*, not trustless. Accepted; heightened relative to the consumer flow. +- **S1 — Monerium credential control-plane.** Whitelabel credentials can re-link + addresses and move IBANs (redirecting *future* mints only). Cannot be prevented + client-side: association monitor is the detective control; Monerium-side + authorization requirements are the G1 ask; response = rotate + suspend (runbook). +- **CEX destination rotation.** Not verifiable on-chain; carried contractually (B5) + with penny test, dormancy gate, and minimum-forward diligence. Silent-loss risk + converts to a pause via the dormancy gate. +- **Fallback-key loss + broken destination** — ordinary self-custody residual, borne + by the client (terms; do not overpromise exits — R11). +- **Non-custody ≠ out of MiCA scope.** The constrained-attestor construction defeats + the custody definition, but exchange/transfer-service scoping is a separate G2 + question. Never present "no custody" as "no licence needed". +- **Stuck-state table** (route death, feed retirement, depeg beyond bound, blacklisted + destination): all fail-safe — swaps revert, funds accumulate as EURe, client exits + keep working; recovery is client-side sweep plus the issuer backstop. Accepted. +- **Operational residuals:** reorgs deeper than the watcher's 12-block lag; + financial-operation claim-crash windows require manual reconciliation; deposit + batching is intra-client only and pro-rata attribution never changes a client's + effective rate. + +## Consequences + +Zero-touch onboarding works end to end (validated against the Monerium sandbox: link +accepted, IBAN issued, no client interaction). Clients keep unilateral exits that no +Vortex failure can block. The cost: every rescue path must be designed in upfront +(no universal owner key), fee/venue changes are governed by timelocks and migrations +rather than admin switches, and Vortex accepts elevated provisioning trust plus a +control-plane risk at Monerium that only contract terms and monitoring can bound. diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index 5e729e9d4..2d9dd36b9 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -4,6 +4,24 @@ "currentDocumentedPaths": [ "/v1/api-credentials", "/v1/api-credentials/{credentialId}", + "/v1/auth/request-otp", + "/v1/auth/verify-otp", + "/v1/brl/createSubaccount", + "/v1/brl/getKycStatus", + "/v1/brl/getSelfieLivenessUrl", + "/v1/brl/getUploadUrls", + "/v1/brl/getUser", + "/v1/brl/getUserRemainingLimit", + "/v1/brl/kyb/attempt-status", + "/v1/brl/kyb/documents", + "/v1/brl/kyb/documents/{documentId}", + "/v1/brl/kyb/new-level-1/api", + "/v1/brl/kyb/new-level-1/web-sdk", + "/v1/brl/kyb/ubos", + "/v1/brl/kyc/import-token", + "/v1/brl/kyc/record-attempt", + "/v1/brl/newKyc", + "/v1/brl/validatePixKey", "/v1/domestic/alfredpayStatus", "/v1/domestic/createBusinessCustomer", "/v1/domestic/createIndividualCustomer", @@ -23,29 +41,13 @@ "/v1/domestic/submitKybRelatedPersonFile", "/v1/domestic/submitKycFile", "/v1/domestic/submitKycInformation", - "/v1/auth/request-otp", - "/v1/auth/verify-otp", - "/v1/brl/createSubaccount", - "/v1/brl/getKycStatus", - "/v1/brl/getSelfieLivenessUrl", - "/v1/brl/getUploadUrls", - "/v1/brl/getUser", - "/v1/brl/getUserRemainingLimit", - "/v1/brl/kyb/attempt-status", - "/v1/brl/kyb/documents", - "/v1/brl/kyb/documents/{documentId}", - "/v1/brl/kyb/new-level-1/api", - "/v1/brl/kyb/new-level-1/web-sdk", - "/v1/brl/kyb/ubos", - "/v1/brl/kyc/import-token", - "/v1/brl/kyc/record-attempt", - "/v1/brl/newKyc", - "/v1/brl/validatePixKey", "/v1/limits", "/v1/managed-profiles", "/v1/managed-profiles/{profileId}", "/v1/managed-profiles/{profileId}/api-credentials", "/v1/managed-profiles/{profileId}/api-credentials/{credentialId}", + "/v1/monerium-b2b/account", + "/v1/monerium-b2b/deposits", "/v1/onboarding/active-entity", "/v1/onboarding/requirements", "/v1/onboarding/status", @@ -53,9 +55,9 @@ "/v1/quotes", "/v1/quotes/best", "/v1/quotes/{id}", + "/v1/ramp-info", "/v1/ramp/history", "/v1/ramp/history/{walletAddress}", - "/v1/ramp-info", "/v1/ramp/register", "/v1/ramp/start", "/v1/ramp/update", @@ -100,7 +102,7 @@ "Vortex API" ], "metaDescription": "Vortex is a fiat-to-crypto on/off-ramp API supporting BRL, EUR, USD, MXN, COP, and ARS with USDC and USDT payouts across major EVM networks and AssetHub.", - "metaTitle": "Vortex API Overview — Fiat-to-Crypto On/Off-Ramp Gateway" + "metaTitle": "Vortex API Overview \u2014 Fiat-to-Crypto On/Off-Ramp Gateway" }, "slug": "overview", "source": "docs/api/pages/01-overview.md", @@ -119,7 +121,7 @@ "buy USDC API" ], "metaDescription": "Run your first fiat-to-crypto ramp with the Vortex Node.js SDK: install @vortexfi/sdk, create a quote, register the ramp, sign transactions, and track it to completion.", - "metaTitle": "Quick Start — Vortex Node.js SDK (@vortexfi/sdk)" + "metaTitle": "Quick Start \u2014 Vortex Node.js SDK (@vortexfi/sdk)" }, "slug": "quick-start-with-the-sdk", "source": "docs/api/pages/02-quick-start-with-the-sdk.md", @@ -157,7 +159,7 @@ "on-ramp process" ], "metaDescription": "The full Vortex ramp lifecycle: create a quote, register with ephemeral accounts, sign and update transactions, settle fiat, start the ramp, and track it to a terminal state.", - "metaTitle": "Ramp Lifecycle — Quote, Register, Sign, Start, Track" + "metaTitle": "Ramp Lifecycle \u2014 Quote, Register, Sign, Start, Track" }, "slug": "ramp-lifecycle", "source": "docs/api/pages/04-ramp-lifecycle.md", @@ -175,7 +177,7 @@ "secure key storage" ], "metaDescription": "Vortex never receives ephemeral secret keys. Learn what your integration must store, for how long, and how to recover in-flight ramps safely.", - "metaTitle": "Ephemeral Key Custody — Vortex Security Model" + "metaTitle": "Ephemeral Key Custody \u2014 Vortex Security Model" }, "slug": "ephemeral-key-custody", "source": "docs/api/pages/05-ephemeral-key-custody.md", @@ -193,7 +195,7 @@ "stablecoin conversion rates" ], "metaDescription": "Create and read Vortex quotes: request shapes for buys and sells, fee breakdowns, best-route selection, expiry handling, and partner pricing.", - "metaTitle": "Quotes And Pricing — Vortex API" + "metaTitle": "Quotes And Pricing \u2014 Vortex API" }, "slug": "quotes-and-pricing", "source": "docs/api/pages/06-quotes-and-pricing.md", @@ -211,7 +213,7 @@ "crypto payment webhooks" ], "metaDescription": "Subscribe to Vortex ramp lifecycle events, verify RSA-PSS webhook signatures, and handle retries and auto-deactivation correctly.", - "metaTitle": "Webhooks — Real-Time Ramp Events And Verification" + "metaTitle": "Webhooks \u2014 Real-Time Ramp Events And Verification" }, "slug": "webhooks", "source": "docs/api/pages/07-webhooks.md", @@ -229,7 +231,7 @@ "white-label ramp" ], "metaDescription": "Embed the hosted Vortex Widget for browser and mobile flows: session creation, quote handoff, theming, and the custody model it handles for you.", - "metaTitle": "Vortex Widget — Hosted Crypto On/Off-Ramp Checkout" + "metaTitle": "Vortex Widget \u2014 Hosted Crypto On/Off-Ramp Checkout" }, "slug": "widget-integration", "source": "docs/api/pages/08-widget-integration.md", @@ -250,7 +252,7 @@ "Brazil Mexico Colombia Argentina crypto" ], "metaDescription": "Corridor requirements for BRL, EUR, USD, MXN, COP, and ARS, including payment rails, KYC prerequisites, BRL token import, accounts, and limits.", - "metaTitle": "Fiat Corridors — PIX, SEPA, ACH, SPEI, CBU" + "metaTitle": "Fiat Corridors \u2014 PIX, SEPA, ACH, SPEI, CBU" }, "slug": "fiat-corridors", "source": "docs/api/pages/09-fiat-corridors.md", @@ -261,7 +263,7 @@ "seo": { "keywords": ["sandbox", "test environment", "crypto API testing", "test API keys", "mock KYC accounts"], "metaDescription": "Test Vortex integrations end-to-end in the sandbox: base URLs, pk_test_/sk_test_ keys, and pre-configured KYC-approved mock accounts.", - "metaTitle": "Sandbox — Test Your Vortex Integration" + "metaTitle": "Sandbox \u2014 Test Your Vortex Integration" }, "slug": "sandbox", "source": "docs/api/pages/10-sandbox.md", @@ -278,7 +280,7 @@ "payment integration launch" ], "metaDescription": "Everything to verify before taking a Vortex integration live: key handling, ephemeral custody, webhook verification, idempotency, and recovery runbooks.", - "metaTitle": "Production Checklist — Go Live With Vortex" + "metaTitle": "Production Checklist \u2014 Go Live With Vortex" }, "slug": "production-checklist", "source": "docs/api/pages/11-production-checklist.md", @@ -295,8 +297,8 @@ "crypto ramp API contract", "machine-readable API docs" ], - "metaDescription": "Build a production-quality Vortex client in any language — with or without an AI coding agent. Covers the full raw-API contract, signing rules, and mandatory client responsibilities.", - "metaTitle": "AI Agent Integration — Build A Vortex Client In Any Language" + "metaDescription": "Build a production-quality Vortex client in any language \u2014 with or without an AI coding agent. Covers the full raw-API contract, signing rules, and mandatory client responsibilities.", + "metaTitle": "AI Agent Integration \u2014 Build A Vortex Client In Any Language" }, "slug": "ai-agent-integration", "source": "docs/api/pages/12-ai-agent-integration.md", @@ -306,8 +308,8 @@ "order": 13, "seo": { "keywords": ["KYB", "business verification", "know your business", "KYB deep link", "business onboarding crypto"], - "metaDescription": "Send business users straight into KYB verification with a single Vortex Widget URL — no quote required. Flow, query parameters, and locking behavior.", - "metaTitle": "KYB Deep Link — Business Verification Without A Quote" + "metaDescription": "Send business users straight into KYB verification with a single Vortex Widget URL \u2014 no quote required. Flow, query parameters, and locking behavior.", + "metaTitle": "KYB Deep Link \u2014 Business Verification Without A Quote" }, "slug": "kyb-deep-link", "source": "docs/api/pages/13-kyb-deep-link.md", @@ -325,7 +327,7 @@ "KYC on behalf" ], "metaDescription": "Create and operate headless Vortex profiles for your own customers: provisioning, delegated KYC/KYB onboarding, child credentials, and ramping on their behalf.", - "metaTitle": "Managed Profiles — Onboard And Ramp For Your Customers" + "metaTitle": "Managed Profiles \u2014 Onboard And Ramp For Your Customers" }, "slug": "managed-profiles", "source": "docs/api/pages/14-managed-profiles.md", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 8988e6c3d..6b7fb2e70 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -948,6 +948,50 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/monerium-b2b/account": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the acting profile's EUR onramp account + * @description Returns the acting profile's business EUR onramp account: status, dedicated IBAN, forwarding contract, and payout configuration. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. + * + * **Auth:** `X-API-Key` or Supabase Bearer. + */ + get: operations["getMoneriumB2bAccount"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/monerium-b2b/deposits": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the acting profile's EUR deposits + * @description Returns the acting profile's EUR deposits newest first, with every allocated conversion portion and aggregate attributed USDC. A per-swap cap can split one deposit across multiple executions. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted. + * + * **Auth:** `X-API-Key` or Supabase Bearer. + */ + get: operations["listMoneriumB2bDeposits"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/onboarding/active-entity": { parameters: { query?: never; @@ -1859,9 +1903,9 @@ export interface paths { content: { "application/json": { events?: string[]; - /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific quote. The quote must have been created with your API key. */ + /** @description (required* one of two for transaction events: quoteId or sessionId; omit both for deposit events): Subscribe to events for a specific quote. The quote must have been created with your API key. */ quoteId?: string; - /** @description (required* one of two: quoteId or sessionId): Subscribe to events for a specific session */ + /** @description (required* one of two for transaction events: quoteId or sessionId; omit both for deposit events): Subscribe to events for a specific session */ sessionId?: string; /** @description Your HTTPS webhook endpoint URL. No embedded credentials; must resolve to a publicly routable address. */ url: string; @@ -2779,6 +2823,70 @@ export interface components { status: number; }; }; + MoneriumB2bAccount: { + accountId: string; + /** Format: date-time */ + createdAt: string; + /** @description The client's payout address on Ethereum. */ + destination: string; + /** + * Format: date-time + * @description Set while the account is dormancy-paused. + */ + dormantSince: string | null; + /** @description The client's self-custodied recovery address. */ + fallbackAddress: string; + feeBps: number; + /** @description The account's on-chain forwarding contract. */ + forwarderAddress: string; + /** @description The account's dedicated IBAN; null until issuance completes. */ + iban: string | null; + /** @enum {string} */ + status: "onboarding" | "active" | "suspended" | "closed"; + }; + MoneriumB2bAccountResponse: { + account: components["schemas"]["MoneriumB2bAccount"]; + }; + MoneriumB2bDeposit: { + /** @description Deposit amount in 18-decimal base units of the deposit currency. */ + amountRaw: string; + /** @description Conversion portions allocated to this deposit, oldest first. Empty while the deposit awaits conversion; multiple entries are returned when a per-swap cap splits the deposit. */ + conversions: { + /** @description EURe from this deposit consumed by the execution in 18-decimal base units. */ + eureInRaw: string; + executionId: string; + /** + * @description Execution status. + * @enum {string} + */ + status: "pending" | "confirmed" | "failed"; + /** @description The swap-and-forward transaction hash. */ + txHash: string | null; + /** @description Net USDC from this execution attributed to this deposit in 6-decimal base units. */ + usdcNetRaw: string; + }[]; + /** Format: date-time */ + createdAt: string; + currency: string; + depositId: string; + /** + * @description Deposit status (forward-only). + * @enum {string} + */ + status: "pending" | "minted" | "held" | "returned"; + /** @description The on-chain mint transaction, when observed. */ + txHash: string | null; + /** @description Aggregate net USDC attributed to this deposit so far in 6-decimal base units. */ + usdcNetRaw: string; + }; + MoneriumB2bDepositsResponse: { + deposits: components["schemas"]["MoneriumB2bDeposit"][]; + pagination: { + limit: number; + offset: number; + total: number; + }; + }; /** * @description Supported blockchain networks. * @enum {string} @@ -6404,6 +6512,99 @@ export interface operations { }; }; }; + getMoneriumB2bAccount: { + parameters: { + query?: never; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The acting profile's onramp account. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MoneriumB2bAccountResponse"]; + }; + }; + /** @description Missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Managed-profile authorization failed (foreign child, corridor or customer-type policy). */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No account exists for the acting profile. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listMoneriumB2bDeposits: { + parameters: { + query?: { + /** @description Page size (default 20, max 100). */ + limit?: number; + /** @description Rows to skip (default 0). */ + offset?: number; + }; + header?: { + /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ + "X-Managed-Profile-Id"?: components["parameters"]["ManagedProfileId"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The acting profile's deposits with conversion status. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MoneriumB2bDepositsResponse"]; + }; + }; + /** @description Missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Managed-profile authorization failed (foreign child, corridor or customer-type policy). */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No account exists for the acting profile. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; selectActiveCustomerEntity: { parameters: { query?: never; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index ced87f8ec..dbc43b835 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2196,8 +2196,12 @@ }, "type": "array" }, - "manager": { "$ref": "#/components/schemas/ManagedProfileManagerPolicy" }, - "pagination": { "$ref": "#/components/schemas/ManagedProfilePagination" } + "manager": { + "$ref": "#/components/schemas/ManagedProfileManagerPolicy" + }, + "pagination": { + "$ref": "#/components/schemas/ManagedProfilePagination" + } }, "required": ["manager", "managedProfiles", "pagination"], "type": "object" @@ -2322,16 +2326,25 @@ "description": "The authenticated manager's current policy. This policy is manager-scoped and applies to every managed child; corridors and customer types are not grants copied onto each child.", "properties": { "allowedCorridors": { - "items": { "enum": ["AR", "BR", "CO", "EU", "MX", "US"], "type": "string" }, + "items": { + "enum": ["AR", "BR", "CO", "EU", "MX", "US"], + "type": "string" + }, "type": "array", "uniqueItems": true }, "allowedCustomerTypes": { - "items": { "enum": ["individual", "business"], "type": "string" }, + "items": { + "enum": ["individual", "business"], + "type": "string" + }, "type": ["array", "null"], "uniqueItems": true }, - "profileId": { "format": "uuid", "type": "string" } + "profileId": { + "format": "uuid", + "type": "string" + } }, "required": ["profileId", "allowedCorridors", "allowedCustomerTypes"], "type": "object" @@ -2386,6 +2399,156 @@ "required": ["error"], "type": "object" }, + "MoneriumB2bAccount": { + "properties": { + "accountId": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "destination": { + "description": "The client's payout address on Ethereum.", + "type": "string" + }, + "dormantSince": { + "description": "Set while the account is dormancy-paused.", + "format": "date-time", + "type": ["string", "null"] + }, + "fallbackAddress": { + "description": "The client's self-custodied recovery address.", + "type": "string" + }, + "feeBps": { + "type": "integer" + }, + "forwarderAddress": { + "description": "The account's on-chain forwarding contract.", + "type": "string" + }, + "iban": { + "description": "The account's dedicated IBAN; null until issuance completes.", + "type": ["string", "null"] + }, + "status": { + "enum": ["onboarding", "active", "suspended", "closed"], + "type": "string" + } + }, + "required": [ + "accountId", + "createdAt", + "destination", + "dormantSince", + "fallbackAddress", + "feeBps", + "forwarderAddress", + "iban", + "status" + ], + "type": "object" + }, + "MoneriumB2bAccountResponse": { + "properties": { + "account": { + "$ref": "#/components/schemas/MoneriumB2bAccount" + } + }, + "required": ["account"], + "type": "object" + }, + "MoneriumB2bDeposit": { + "properties": { + "amountRaw": { + "description": "Deposit amount in 18-decimal base units of the deposit currency.", + "type": "string" + }, + "conversions": { + "description": "Conversion portions allocated to this deposit, oldest first. Empty while the deposit awaits conversion; multiple entries are returned when a per-swap cap splits the deposit.", + "items": { + "properties": { + "eureInRaw": { + "description": "EURe from this deposit consumed by the execution in 18-decimal base units.", + "type": "string" + }, + "executionId": { + "type": "string" + }, + "status": { + "description": "Execution status.", + "enum": ["pending", "confirmed", "failed"], + "type": "string" + }, + "txHash": { + "description": "The swap-and-forward transaction hash.", + "type": ["string", "null"] + }, + "usdcNetRaw": { + "description": "Net USDC from this execution attributed to this deposit in 6-decimal base units.", + "type": "string" + } + }, + "required": ["eureInRaw", "executionId", "status", "txHash", "usdcNetRaw"], + "type": "object" + }, + "type": "array" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "currency": { + "type": "string" + }, + "depositId": { + "type": "string" + }, + "status": { + "description": "Deposit status (forward-only).", + "enum": ["pending", "minted", "held", "returned"], + "type": "string" + }, + "txHash": { + "description": "The on-chain mint transaction, when observed.", + "type": ["string", "null"] + }, + "usdcNetRaw": { + "description": "Aggregate net USDC attributed to this deposit so far in 6-decimal base units.", + "type": "string" + } + }, + "required": ["amountRaw", "conversions", "createdAt", "currency", "depositId", "status", "txHash", "usdcNetRaw"], + "type": "object" + }, + "MoneriumB2bDepositsResponse": { + "properties": { + "deposits": { + "items": { + "$ref": "#/components/schemas/MoneriumB2bDeposit" + }, + "type": "array" + }, + "pagination": { + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": ["limit", "offset", "total"], + "type": "object" + } + }, + "required": ["deposits", "pagination"], + "type": "object" + }, "Networks": { "description": "Supported blockchain networks.", "enum": ["assethub", "arbitrum", "avalanche", "base", "bsc", "ethereum", "polygon", "moonbeam"], @@ -2539,10 +2702,14 @@ "type": "string" }, "documents": { - "items": { "$ref": "#/components/schemas/OnboardingDocumentRequirement" }, + "items": { + "$ref": "#/components/schemas/OnboardingDocumentRequirement" + }, "type": "array" }, - "flow": { "type": "string" }, + "flow": { + "type": "string" + }, "mode": { "enum": ["api", "hosted", "hybrid"], "type": "string" @@ -2555,9 +2722,13 @@ "enum": ["alfredpay", "avenia"], "type": "string" }, - "requirementsVersion": { "type": "string" }, + "requirementsVersion": { + "type": "string" + }, "steps": { - "items": { "$ref": "#/components/schemas/OnboardingRequirementStep" }, + "items": { + "$ref": "#/components/schemas/OnboardingRequirementStep" + }, "type": "array" } }, @@ -8023,6 +8194,110 @@ "tags": ["Managed Profiles"] } }, + "/v1/monerium-b2b/account": { + "get": { + "deprecated": false, + "description": "Returns the acting profile's business EUR onramp account: status, dedicated IBAN, forwarding contract, and payout configuration. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", + "operationId": "getMoneriumB2bAccount", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoneriumB2bAccountResponse" + } + } + }, + "description": "The acting profile's onramp account." + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Managed-profile authorization failed (foreign child, corridor or customer-type policy)." + }, + "404": { + "description": "No account exists for the acting profile." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "Get the acting profile's EUR onramp account", + "tags": ["Account Management"] + } + }, + "/v1/monerium-b2b/deposits": { + "get": { + "deprecated": false, + "description": "Returns the acting profile's EUR deposits newest first, with every allocated conversion portion and aggregate attributed USDC. A per-swap cap can split one deposit across multiple executions. This is the polling surface for payment-received / converted status; the deposit webhook events cover push delivery. A partner manager acts for a child via `X-Managed-Profile-Id` (EU corridor and business customer type policy applies), or the child's own credential authenticates directly. Strictly scoped to the acting profile; no account, profile, or IBAN selector is accepted.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", + "operationId": "listMoneriumB2bDeposits", + "parameters": [ + { + "$ref": "#/components/parameters/ManagedProfileId" + }, + { + "description": "Page size (default 20, max 100).", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Rows to skip (default 0).", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoneriumB2bDepositsResponse" + } + } + }, + "description": "The acting profile's deposits with conversion status." + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Managed-profile authorization failed (foreign child, corridor or customer-type policy)." + }, + "404": { + "description": "No account exists for the acting profile." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "List the acting profile's EUR deposits", + "tags": ["Account Management"] + } + }, "/v1/onboarding/active-entity": { "put": { "description": "Selects the authenticated profile's immutable active customer-entity type. Managed-child delegation is not supported.", @@ -10192,7 +10467,7 @@ }, "/v1/session/create": { "post": { - "description": "Creates a hosted Vortex Widget session and returns the URL to open for the user.\n\nThis single endpoint supports two mutually exclusive request shapes:\n\n- **Fixed quote** (`GetWidgetUrlLocked`) — pass a `quoteId` you created via `POST /v1/quotes`. The widget uses that exact quote and does not refresh it. If the quote expires before the user finishes, they must close the window and start over.\n\n- **Auto-refresh** (`GetWidgetUrlRefresh`) — pass the route parameters (`network`, `rampType`, `inputAmount`, plus `fiat` / `cryptoLocked` / `paymentMethod` as relevant for the direction). The widget creates and refreshes quotes on demand for the user.\n\nUse the example switcher below to see the request shape for each mode. `externalSessionId` is required in both modes and is echoed back in webhook payloads.", + "description": "Creates a hosted Vortex Widget session and returns the URL to open for the user.\n\nThis single endpoint supports two mutually exclusive request shapes:\n\n- **Fixed quote** (`GetWidgetUrlLocked`) \u2014 pass a `quoteId` you created via `POST /v1/quotes`. The widget uses that exact quote and does not refresh it. If the quote expires before the user finishes, they must close the window and start over.\n\n- **Auto-refresh** (`GetWidgetUrlRefresh`) \u2014 pass the route parameters (`network`, `rampType`, `inputAmount`, plus `fiat` / `cryptoLocked` / `paymentMethod` as relevant for the direction). The widget creates and refreshes quotes on demand for the user.\n\nUse the example switcher below to see the request shape for each mode. `externalSessionId` is required in both modes and is echoed back in webhook payloads.", "parameters": [], "requestBody": { "content": { @@ -10348,7 +10623,7 @@ "type": "array" }, "emoji": { - "description": "e.g. 🇩🇪", + "description": "e.g. \ud83c\udde9\ud83c\uddea", "type": "string" }, "name": { @@ -10626,17 +10901,17 @@ "properties": { "events": { "items": { - "description": "(optional): Array of event types to subscribe to. Defaults to all events if not specified. [\"TRANSACTION_CREATED\", \"STATUS_CHANGE\"]", + "description": "(optional): Array of event types to subscribe to. Transaction events [\"TRANSACTION_CREATED\", \"STATUS_CHANGE\"] are the default when omitted. The account-scoped deposit events [\"DEPOSIT_RECEIVED\", \"DEPOSIT_CONVERTED\"] must be requested explicitly, cannot be mixed with transaction events, require a profile-scoped secret credential, and take no quoteId/sessionId.", "type": "string" }, "type": "array" }, "quoteId": { - "description": "(required* one of two: quoteId or sessionId): Subscribe to events for a specific quote. The quote must have been created with your API key.", + "description": "(required* one of two for transaction events: quoteId or sessionId; omit both for deposit events): Subscribe to events for a specific quote. The quote must have been created with your API key.", "type": "string" }, "sessionId": { - "description": "(required* one of two: quoteId or sessionId): Subscribe to events for a specific session", + "description": "(required* one of two for transaction events: quoteId or sessionId; omit both for deposit events): Subscribe to events for a specific session", "type": "string" }, "url": { @@ -10706,22 +10981,44 @@ "headers": {} }, "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Managed-profile selection is unsupported on webhook endpoints.", "headers": {} }, "401": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } + }, "description": "Missing or invalid secret API key.", "headers": {} }, "403": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Direct managed-child credentials are unsupported, or supplied credentials conflict.", "headers": {} } }, - "security": [{ "SecretApiKey": [] }], + "security": [ + { + "SecretApiKey": [] + } + ], "summary": "Register Webhook", "tags": ["Webhooks"] } @@ -10763,22 +11060,44 @@ "headers": {} }, "400": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Managed-profile selection is unsupported on webhook endpoints.", "headers": {} }, "401": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiCredentialErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiCredentialErrorResponse" + } + } + }, "description": "Missing or invalid secret API key.", "headers": {} }, "403": { - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManagedSelectorErrorResponse" } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedSelectorErrorResponse" + } + } + }, "description": "Direct managed-child credentials are unsupported, or supplied credentials conflict.", "headers": {} } }, - "security": [{ "SecretApiKey": [] }], + "security": [ + { + "SecretApiKey": [] + } + ], "summary": "Delete Webhook", "tags": ["Webhooks"] } diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 2711eb169..0522f7cc2 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -6,6 +6,7 @@ You can subscribe to: - **Transaction creation** — a new ramp is registered. - **Status changes** — a ramp's status moves between `PENDING`, `COMPLETE`, and `FAILED`. +- **Deposit events** — for partner managers with business EUR onramp accounts: a client's EUR deposit was received (`DEPOSIT_RECEIVED`) or converted and forwarded (`DEPOSIT_CONVERTED`). See [Deposit Events](#deposit-events) — they follow account-scoped rules and durable delivery. ## Security Model @@ -36,7 +37,7 @@ Content-Type: application/json } ``` -The body must include **exactly one** of `quoteId` or `sessionId`. Use `sessionId` to subscribe to events from a Widget-hosted ramp instead of a partner-created quote. +For the transaction events, the body must include **exactly one** of `quoteId` or `sessionId`. Use `sessionId` to subscribe to events from a Widget-hosted ramp instead of a partner-created quote. Omitting `events` subscribes to the two transaction events only — deposit events are never a default. Store the returned webhook ID so you can delete it later. @@ -102,15 +103,96 @@ Status values: - `COMPLETE` — ramp completed successfully. - `FAILED` — ramp failed or timed out. +## Deposit Events + +Managers whose business clients hold EUR onramp accounts can subscribe to deposit events instead of polling `GET /v1/monerium-b2b/deposits`. These subscriptions follow account-scoped rules: + +- Register with your **manager profile's own secret key** (no `X-Managed-Profile-Id` header, no `quoteId`/`sessionId`) and an explicit `events` list containing only deposit events. Mixing them with transaction events is rejected, as is a partner-scoped credential. +- One subscription covers **all your managed children's accounts**; the payload identifies the child by `profileId` and the account by `accountId`. + +```json +{ + "url": "https://manager.example.com/vortex/deposits", + "events": ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"] +} +``` + +### `DEPOSIT_RECEIVED` + +Fired once when a client's EUR deposit has been matched to the corresponding on-chain mint. A provider-reported order without verified chain identity does not emit this event. + +```json +{ + "eventId": "deposit-received:9f6f6a7e-...", + "eventType": "DEPOSIT_RECEIVED", + "timestamp": "2025-01-15T10:35:00.000Z", + "payload": { + "accountId": "c2a5...", + "profileId": "7d1b...", + "depositId": "9f6f6a7e-...", + "amountRaw": "100000000000000000000", + "currency": "eur", + "status": "minted", + "txHash": "0x..." + } +} +``` + +`amountRaw` is in 18-decimal base units of the deposit currency. + +### `DEPOSIT_CONVERTED` + +Fired once per deposit after the full deposit has been converted and every contributing execution has reached a safe confirmation depth on chain. A deposit split by the per-swap cap still produces one final aggregate event. + +```json +{ + "eventId": "deposit-converted:9f6f6a7e-...", + "eventType": "DEPOSIT_CONVERTED", + "timestamp": "2025-01-15T10:41:00.000Z", + "payload": { + "accountId": "c2a5...", + "profileId": "7d1b...", + "depositId": "9f6f6a7e-...", + "amountRaw": "100000000000000000000", + "currency": "eur", + "status": "minted", + "txHash": "0x...", + "conversions": [ + { + "eureInRaw": "60000000000000000000", + "executionId": "e77a...", + "txHash": "0x...", + "usdcNetRaw": "64800000" + }, + { + "eureInRaw": "40000000000000000000", + "executionId": "f88b...", + "txHash": "0x...", + "usdcNetRaw": "43200000" + } + ], + "usdcNetRaw": "108000000" + } +} +``` + +Each `conversions[]` entry contains the EURe portion consumed and the net USDC attributed to this deposit by that execution. The payload-level `usdcNetRaw` is their aggregate. When one execution consumes several deposits, its output is divided proportionally by allocated EURe; floor dust goes to the largest allocation. + +### Delivery Semantics + +Deposit events are delivered **durably, at least once**: each event is persisted before sending and retried with growing backoff (1, 5, 15, 60, 180 minutes; abandoned after 6 attempts). Unlike transaction webhooks, a failing endpoint never deactivates the subscription — deliveries resume when your endpoint recovers, and outages lose nothing that has not exhausted its retries. Deduplicate on `eventId`; events are emitted only from subscription time forward (history is never replayed to a new subscription). + ## Retry Mechanism -Vortex automatically retries failed webhook deliveries: +Vortex automatically retries failed **transaction webhook** deliveries: - **Attempts**: up to 5 - **Backoff**: exponential (1s, 2s, 4s, 8s, 16s) - **Timeout**: 30 seconds per request - **Auto-deactivation**: after 5 consecutive failures, the webhook is disabled and must be re-registered. +Deposit events use the durable delivery semantics above instead. + Return `2xx` quickly. Do heavy work asynchronously after acknowledging the request. ## Verification diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 225a45469..2ffa400c5 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -12,10 +12,10 @@ This page is the integration walkthrough. The exact authorization contract — e Manager status is granted by Vortex, not self-service. During partner onboarding, Vortex enables your profile as a managed-profile manager and assigns: -- **Allowed corridors** — the countries (`BR`, `AR`, `CO`, `MX`, `US`) your children may operate in. +- **Allowed corridors** — the countries (`BR`, `AR`, `CO`, `MX`, `US`, `EU`) your children may operate in. - **Optional customer-type narrowing** — restrict children to `individual` or `business`; a null policy allows both wherever the corridor's canonical capability matrix does. -Every delegated operation re-checks this policy at request time, so a corridor removed from your manager record immediately blocks new mutations for children in that corridor (in-flight ramps continue). EUR is not available for managed children — its flows are bound to a verified login email. +Every delegated operation re-checks this policy at request time, so a corridor removed from your manager record immediately blocks new mutations for children in that corridor (in-flight ramps continue). Quoted EUR ramps are not available for managed children — those flows are bound to a verified login email. The `EU` corridor instead covers the dedicated business EUR onramp account surface (`GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` under delegation or a child credential), available to business children whose accounts Vortex provisions during partner onboarding. ## Create A Managed Child @@ -131,7 +131,7 @@ Register, sign, and start exactly as described in [Ramp Lifecycle](https://api-d Two things behave differently for managed children: - **Pricing** is resolved as: the child's own partner-pricing assignment if one exists, otherwise **your (the manager's) active assignment**, otherwise default Vortex pricing — identically for header-delegated calls and direct child credentials. Children automatically inherit your negotiated fees. -- **Webhooks are not supported for managed subjects** — registration returns `400 MANAGED_PROFILE_UNSUPPORTED` with the header and `403` with a child credential. Poll the child-scoped ramp status and history endpoints instead. +- **Transaction webhooks are not supported for managed subjects** — registration returns `400 MANAGED_PROFILE_UNSUPPORTED` with the header and `403` with a child credential. Poll the child-scoped ramp status and history endpoints instead. The exception is the deposit-event family for EUR onramp accounts: the **manager** subscribes with their own credential (no header) and receives `DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED` for all their children's accounts — see the Webhooks page. ## Common Errors diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index 07ea7fa42..dcc074ee7 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -12,6 +12,8 @@ A diff here means: check backward compatibility for live integrations, and keep ## packages/shared — partner wire contract (`src/endpoints`) ```text +ACCOUNT_WEBHOOK_EVENT_TYPES: readonly [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED] + AcceptedRecipientInvite: { id: string; invitation: { @@ -431,6 +433,56 @@ DeleteWebhookResponse: { success: boolean; } +DepositConvertedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_CONVERTED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + } & { + conversions: Array<{ + eureInRaw: string; + executionId: string; + txHash: null | string; + usdcNetRaw: string; + }>; + usdcNetRaw: string; + }; + timestamp: string; +} + +DepositReceivedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RECEIVED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + }; + timestamp: string; +} + +DepositStatus: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" } + +DepositWebhookPayloadBase: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; +} + DomesticAddFiatAccountRequest: { accountBankCode?: string; accountName?: string; @@ -1610,7 +1662,7 @@ RegisterRampResponse: { } RegisterWebhookRequest: { - events?: Array; + events?: Array; quoteId?: string; sessionId?: string; url: string; @@ -1618,7 +1670,7 @@ RegisterWebhookRequest: { RegisterWebhookResponse: { createdAt: string; - events: Array; + events: Array; id: string; isActive: boolean; quoteId: null | string; @@ -2370,6 +2422,40 @@ WebhookDeliveryAttempt: { maxAttempts: number; nextRetryAt?: Date; payload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_CONVERTED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + } & { + conversions: Array<{ + eureInRaw: string; + executionId: string; + txHash: null | string; + usdcNetRaw: string; + }>; + usdcNetRaw: string; + }; + timestamp: string; + } | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RECEIVED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + }; + timestamp: string; + } | { eventId: string; eventType: WebhookEventType.STATUS_CHANGE; payload: { @@ -2396,9 +2482,43 @@ WebhookDeliveryAttempt: { webhookId: string; } -WebhookEventType: enum WebhookEventType { STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } +WebhookEventType: enum WebhookEventType { DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } WebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_CONVERTED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + } & { + conversions: Array<{ + eureInRaw: string; + executionId: string; + txHash: null | string; + usdcNetRaw: string; + }>; + usdcNetRaw: string; + }; + timestamp: string; +} | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RECEIVED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + txHash: null | string; + }; + timestamp: string; +} | { eventId: string; eventType: WebhookEventType.STATUS_CHANGE; payload: { diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md new file mode 100644 index 000000000..a8a922f8a --- /dev/null +++ b/docs/architecture-monerium-b2b-onramp.md @@ -0,0 +1,396 @@ +# Monerium B2B Onramp Architecture + +Current end-to-end architecture of the quoteless EUR → USDC onramp for KYB'd corporate +clients. Normative security detail lives in +[`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md); +decisions, final parameters, and accepted risks in +[`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); +launch gates in [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md); +operator procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). + +## The shape in one paragraph + +Each corporate client is onboarded and KYB-approved by Monerium in Vortex's whitelabel +app (under the partner's KYC reliance) and owns a dedicated Monerium profile. Vortex +deploys one `VortexForwarder` contract clone per client, links it to that profile with an +attestor signature, and requests an IBAN **for the linked contract address** — the IBAN's +default mint destination *is* the forwarder. From then on the flow is passive on +Monerium's side: EUR received on the IBAN mints EURe to the forwarder, and Vortex's +keeper calls `swapAndForward()` on the contract, which swaps EURe → EURC → USDC on +Uniswap v3 (Chainlink-bounded minimum output) and transfers the USDC to the client's +fixed destination wallet, minus the configured fee to the treasury. The flow is +deliberately **not** a ramp: no quote, no `ramp_states` — the account is permanent and +repeatedly funded. Inside Vortex the client is a **managed child profile** under the +partner manager, which is what carries KYB records, API credentials, the read API, and +webhook tenancy. + +The module is dark by default. Unless `MONERIUM_B2B_ENABLED=true`, its public/admin +routes, raw-body webhook parser, and keeper worker are not mounted; account-scoped +deposit webhook registration is rejected. Existing generic outbox deliveries continue +to drain. Enabling it is fail-fast and requires the complete credential/key/RPC set, +the trusted factory address, and `FLOW_VARIANT=mykobo`. + +## System map + +```mermaid +flowchart LR + subgraph Partner["Partner (manager)"] + PAPI[Partner backend] + end + + subgraph Monerium + IBAN["Client IBAN\n(default destination = forwarder)"] + MAPI[Whitelabel API] + MWH[Monerium webhooks] + end + + subgraph Chain["Ethereum mainnet"] + FWD["VortexForwarder clone\n(one per client)"] + FACT[Factory + implementation] + UNI[Uniswap v3\nEURe-EURC-USDC] + LINK[Chainlink EUR/USD] + DEST[Client wallet] + TREAS[Treasury FEE_RECIPIENT] + end + + subgraph Vortex["Vortex API (keeper backend)"] + ADM["Admin API\n/v1/admin/monerium-b2b"] + INBOX[("monerium_webhook_events\n(durable inbox)")] + DP[Deposit processor] + MW[Mint watcher] + CE[Conversion executor] + ONB[Onboarding automation] + MONI[4 detection monitors] + OUTBOX[("webhook_deliveries\n(durable outbox)")] + READ["Read API\n/v1/monerium-b2b/*"] + end + + IBAN -- "SEPA in => EURe minted" --> FWD + MWH -- "order.*, iban.updated (HMAC)" --> INBOX + INBOX --> DP + MW -- "EURe Transfer logs" --> FWD + CE -- "swapAndForward()" --> FWD + FWD --> UNI + FWD -- "minOut check" --> LINK + FWD -- "USDC - fee" --> DEST + FWD -- fee --> TREAS + ONB -- "link address + request IBAN" --> MAPI + MONI -- "association / config reads" --> MAPI + OUTBOX -- "DEPOSIT_RECEIVED / DEPOSIT_CONVERTED" --> PAPI + PAPI -- "poll (delegation)" --> READ +``` + +Trust boundaries worth holding onto: **Monerium controls where EURe mints** (the IBAN's +linked default address — which is why the association monitor exists); **the contract +controls where funds can go** (fixed `destination`, fee to the immutable treasury, +fallback sweep — the keeper can only ever trigger, never redirect); **Vortex controls +timing and accounting**, nothing more. + +## Onboarding sequence (per client) + +```mermaid +sequenceDiagram + participant Op as Operator + participant Adm as Vortex admin API + participant K as Keeper (worker) + participant M as Monerium + participant C as Ethereum + + Note over M: Monerium onboards the corporate under partner reliance - profile "approved" + Op->>C: deployForwarder(destination, fallback, feeBps) via factory + Op->>Adm: POST /v1/admin/monerium-b2b/accounts + Adm->>C: verify clone against configured trusted factory + config read-back + Adm->>Adm: atomically commit managed child + KYB mirror + account + K->>M: POST /addresses (attestor-signed link) [exactly-once] + K->>M: POST /ibans for the forwarder address [exactly-once] + M-->>K: iban.updated webhook -> IBAN recorded + Op->>M: penny test (simulated/real small SEPA) + Op->>Adm: PATCH .../accounts/:id/status "active" (refused without IBAN) +``` + +Steps in prose: + +1. **Monerium onboards the corporate** under the partner's reliance attestation; the + profile arrives `approved`. (Vortex's KYB submission API is a deliberate 501 stub — + registry T3.) +2. **Operator deploys the forwarder clone** with the client's `destination`, mandatory + self-custodied `fallbackAddress`, and initial `feeBps`; manifest generated and + verified. +3. **Admin mapping** — one idempotent call provisions the managed child, mirrors the + approved KYB into `provider_customers` + `kyc_cases`, verifies the clone against the + configured trusted factory on chain, and creates the account row bound via + `vortex_profile_id`. All local records commit in one database transaction. +4. **Keeper automation** links the forwarder (attestor signature) and requests the IBAN, + each exactly-once through the profile-scoped `financial_operations` ledger; the + `iban.updated` webhook records the IBAN. +5. **Penny test**, then activation via the admin status endpoint. + +## Deposit-to-payout sequence + +```mermaid +sequenceDiagram + participant B as Client's bank + participant M as Monerium + participant F as Forwarder (chain) + participant V as Vortex keeper + participant P as Partner + + B->>M: SEPA transfer to the IBAN + M->>F: mint EURe (automatic, no API call) + M-->>V: order.created / order.updated webhook -> inbox -> deposit row + V->>F: (watcher) sees the Transfer log -> stamps chain identity + V->>V: DEPOSIT_RECEIVED -> outbox -> partner webhook + V->>F: swapAndForward() [execution row committed first] + F->>F: swap min(balance, perSwapCap) via Uniswap, Chainlink minOut + F->>P: USDC - fee to client wallet (fee to treasury) + V->>V: finalize from SwapExecuted event + Note over V: mint cursor reaches the swap block + V->>V: R04 attribution through the exact swap log position + Note over V: 32 blocks later + V->>P: DEPOSIT_CONVERTED -> outbox -> partner webhook +``` + +Vortex learns about a deposit through two complementary channels, which converge on the +same per-forwarder advisory lock: the **webhooks** carry the provider order accounting +(amount, order id, compliance holds), while the **mint watcher** proves the on-chain +mint identity. Only a settled, chain-indexed mint makes an account a conversion +candidate. A live balance by itself is deliberately insufficient: this prevents a swap +from outrunning the watcher's reorg window and becoming impossible to attribute safely. + +## How the mint watcher walks the chain + +The watcher is a poll-based log scanner with a **persisted cursor** so no block range is +ever skipped or double-processed across restarts: + +```mermaid +flowchart TD + A[cycle start] --> B["safeHead = latest - 12\n(reorg confirmation lag)"] + B --> C{cursor row exists?} + C -- no --> D["bootstrap: create cursor at safeHead\n(history is covered by webhook-recorded orders)"] + C -- yes --> E["fromBlock = cursor + 1\ntoBlock = min(safeHead, fromBlock + 2000)"] + E --> F["getLogs: EURe Transfer -> any known forwarder"] + F --> G["per log, under the forwarder lock:\nmatch to an open deposit (by tx hash + amount, else amount)\nor record a flagged unattr: row"] + G --> H["advance cursor to toBlock\n(only after processing)"] + H --> A +``` + +The mechanics that matter: + +- The cursor row (`monerium_chain_cursors` — one row per watcher and chain) stores + the **last fully processed block**. It advances only *after* every log in the range + was handled, so a crash mid-range means the next cycle re-scans the same range — and + re-scanning is harmless because each mint's identity `(chain_id, tx_hash, log_index)` + is a unique index: already-recorded mints are skipped. +- The scan stops **12 blocks below the head**: that identity is not reorg-stable + (a dropped transaction re-mines with a different block and log index), so only + settled blocks are read. Conversion intentionally inherits this ~2.5-minute safety + delay rather than acting on an unindexed live balance. +- Ranges are capped at 2,000 blocks per cycle, so after downtime the watcher catches up + in bounded chunks instead of one unbounded `getLogs`. +- On first run there is no cursor: it bootstraps at the current settled head and scans + only forward. Historic mints are outside the automatic path even when a webhook row + exists; back-filling their chain fields is a manual operation. The rollout therefore + requires zero EURe balances on mapped forwarders before first enablement. + +## Lifecycles + +```mermaid +stateDiagram-v2 + direction LR + state "Deposit (monerium_fiat_deposits)" as dep { + [*] --> pending + pending --> minted + pending --> held + pending --> returned + held --> minted + held --> returned + minted --> [*] + returned --> [*] + } +``` + +```mermaid +stateDiagram-v2 + direction LR + state "Execution (monerium_conversion_executions)" as exe { + [*] --> pending2 : row committed BEFORE broadcast + pending2 --> confirmed : receipt + SwapExecuted + pending2 --> failed : revert / never sent / stale + confirmed --> [*] + failed --> [*] : retried via a NEW row (backoff) + } +``` + +```mermaid +stateDiagram-v2 + direction LR + state "Account (monerium_accounts)" as acc { + [*] --> onboarding : admin mapping + onboarding --> active : penny test + admin PATCH (needs IBAN) + active --> suspended + suspended --> active + active --> closed + suspended --> closed + } +``` + +Deposit statuses are **forward-only** (a delayed or replayed webhook can never regress a +row). Account statuses follow only the arrows above; `closed` is terminal and a repeated +write of the current status is idempotent. A nonce-less execution row is a five-minute +pre-send reservation; expiry uses a compare-and-set so its original owner can no longer +broadcast. Once the swap nonce is persisted, time alone never fails the execution. +Recovery scans bounded 2,000-block pages from the pre-broadcast block and adopts only +one transaction matching the keeper sender, nonce, forwarder target, exact +`swapAndForward()` calldata, and emitted event; incomplete or ambiguous evidence stays +pending for manual reconciliation. The account additionally carries a `dormant_since` +marker (guardian-paused after 60 days without a conversion; conversion stops, the +protective stranding marker still arms). + +## Batching and large deposits + +Batching happens in both directions, automatically: + +- **A large deposit is chunked.** One `swapAndForward()` call converts at most + `perSwapCap`; the remainder stays on the forwarder and the keeper converts it on + subsequent cycles (one execution row per chunk) until the balance is below + `minSwapAmount`. A €120k deposit at a €25k cap becomes five executions a few minutes + apart. The cap is an availability/price-impact parameter, not a safety bound — the + oracle `minOut` is the safety bound. +- **Several small deposits merge.** The contract swaps the balance, not a deposit: two + €5k deposits sitting on the forwarder convert in a single execution, and R04 + attribution splits the USDC back across both deposit rows pro-rata. A deposit that + only partially fits under the cap is split into an allocation for this execution and + an outstanding remainder for the next. Partners receive one `DEPOSIT_CONVERTED` + event only after the whole deposit is allocated and every contributing execution is + deep enough; its `conversions[]` lists each portion and `usdcNetRaw` is the aggregate + of each swap's `usdcOut - fee`. It excludes unsolicited USDC that the contract sweeps + to the same destination alongside a swap. + +Allocation is intentionally deferred after the swap receipt. The mint watcher must +first advance through the execution block; the reconciler then includes deposits from +earlier blocks and only deposits whose `Transfer` log precedes `SwapExecuted` in the +same block. This exact boundary also captures a mint that lands between the executor's +balance read and its swap transaction without attributing a later mint to that swap. + +In normal operation merging is rare: the keeper runs every minute, so deposits share an +execution only when they arrive within about a minute of each other or during downtime. +And batching only ever merges deposits of the **same client** — every client has their +own forwarder, so cross-client funds never mix. + +## Fees + +- **Rate (`feeBps`)**: per-client, set at clone initialization and adjustable by the + guardian via `setFeeBps`, always capped by the implementation-immutable + `MAX_FEE_BPS`. Increases are announced on-chain and apply (permissionlessly) only + after the 24 h `FEE_INCREASE_TIMELOCK`, so a client whose SEPA transfer is already + in flight cannot be swapped under a silently higher fee; decreases are immediate + (registry P11). Swaps always use the currently applied fee — an announced increase + never touches a swap inside its window. +- **Destination (`FEE_RECIPIENT`)**: an immutable baked into the **implementation** + contract at deployment, shared by every clone of that implementation. Changing the + treasury address means deploying a new implementation + factory and using it for new + clones. There is no per-client fee destination and no setter. +- The database mirrors `fee_bps` on the account row for accounting and drift detection + only; the contract value is authoritative, and the config monitor reconciles + guardian fee changes (warn + version bump) while alarming on anything unauthorized. + +## Monitoring (detection-only) + +Four monitors run from the keeper worker (rate-limited to one pass per ~30 minutes), +read-only — no keys, no transactions: + +1. **Association monitor (the S1 detective control).** Per active account it re-reads + the Monerium-side state — `GET /addresses?profile=` and the IBAN list — and diffs it + against the database record. **Any** divergence is an error-level alert: the + forwarder no longer linked, an extra address linked to the profile, the IBAN moved + or unrecorded. This is the control for the structural risk that Vortex-held + whitelabel credentials can change associations at Monerium: those changes cannot be + prevented client-side, only detected fast. +2. **Executable-depth monitor.** QuoterV2 quotes on the pinned swap path vs Chainlink; + price impact past the slippage bound is an alert before clients feel it. +3. **Stranded-balance monitor.** Forwarders holding EURe with the stranding marker + armed too long — a keeper-outage signal (past the trigger delay, the permissionless + fallback is live; funds are never at risk, conversion is just late). +4. **Config reconciliation.** Re-reads per-clone config and bytecode: client-authorized + changes (destination/fallback) and guardian-authorized changes (feeBps, timelocked) + are reconciled into the DB with a version bump; bytecode or registration drift is a + should-be-impossible incident. + +## Data model — the Monerium B2B tables + +All tables below belong exclusively to this flow (the legacy Monerium OAuth/KYC +integration owns no tables of its own — it writes only `provider_customers` / +`kyc_cases`). Migration numbers in parentheses. + +```mermaid +erDiagram + profiles ||--o| monerium_accounts : "vortex_profile_id (managed child)" + monerium_accounts ||--o{ monerium_fiat_deposits : "account_id" + monerium_accounts ||--o{ monerium_conversion_executions : "account_id" + monerium_fiat_deposits ||--o{ monerium_deposit_allocations : "deposit_id" + monerium_conversion_executions ||--o{ monerium_deposit_allocations : "execution_id (R04)" + webhooks ||--o{ webhook_deliveries : "webhook_id (deposit events)" + + monerium_accounts { + uuid vortex_profile_id FK + string monerium_profile_id UK + string iban + string forwarder_address UK + string destination + string fallback_address + int fee_bps + enum status + } + monerium_fiat_deposits { + string monerium_order_id UK + decimal amount_raw + enum status + string tx_hash + int log_index + } + monerium_conversion_executions { + decimal eure_in_raw + decimal usdc_net_raw + string tx_hash + int nonce + int broadcast_block_number + int swap_log_index + enum status + } + monerium_deposit_allocations { + uuid deposit_id FK + uuid execution_id FK + decimal eure_in_raw + decimal usdc_net_raw + } +``` + +| Table | Purpose | +|---|---| +| `monerium_accounts` (069, 071) | One row per client account: Monerium profile UUID, IBAN, forwarder/destination/fallback addresses, `fee_bps`, lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | +| `monerium_fiat_deposits` (069, 070, 073, 076) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status, on-chain mint identity, and two webhook-emission markers | +| `monerium_conversion_executions` (069, 074, 075, 077) | One row per `swapAndForward()`, created before broadcast: EURe in, USDC gross + fee from the event, conversion net (`usdcOut - fee`, excluding unrelated USDC swept by `forwarded`), tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and `SwapExecuted` log index (allocation boundary), status | +| `monerium_deposit_allocations` (076) | N:M accounting join: the EURe portion and attributed net USDC for each deposit/execution pair | +| `monerium_webhook_events` (069) | Durable persist-before-200 inbox for Monerium deliveries, dedup by event id, 30-day retention after processing | +| `monerium_chain_cursors` (070) | Persisted block cursors for the mint watcher | +| `webhook_deliveries` (072) | Generic durable outbox for the deposit-event webhook family: one row per (webhook, event), claim-based dispatch with backoff, 30-day retention after settling | + +Rows created in **existing** tables per client: a `profiles` row (`kind = managed`) with +its `managed_profiles` relationship under the partner manager, a business +`customer_entities` row, a `provider_customers` row (`monerium`/`eur`, the Monerium +profile UUID) with an approved `kyb` `kyc_cases` row, `financial_operations` rows for +the exactly-once link/IBAN calls, and — registered by the partner — a user-owned +`webhooks` row subscribed to the deposit events. + +## Failure posture (pointers) + +Webhook deliveries survive crashes (persist-before-200 inbox); a late provider webhook +reconciles the exact same-account unattributed mint into the provider order, including +when that order row already exists, without duplicating chain identity or allocations; +provider onboarding calls are exactly-once (`financial_operations`) and their reads are +bound to the configured profile and chain; a broadcast whose hash was lost is recovered +from its persisted nonce/block plus an exact transaction-and-event match rather than +re-sent; all per-account writes serialize on one advisory lock; and the client always has two exits +that no operator failure can block — the fallback-address sweep and, past the trigger +delay, permissionless swap execution. Full invariants and threat model: +[`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md). diff --git a/docs/operations-monerium-b2b-rollout.md b/docs/operations-monerium-b2b-rollout.md new file mode 100644 index 000000000..8ba3cf968 --- /dev/null +++ b/docs/operations-monerium-b2b-rollout.md @@ -0,0 +1,148 @@ +# Monerium B2B Onramp — Rollout + +What still stands between the implemented system and a live pilot: the gates, the deploy +checklist, and the engineering inputs for terms drafting. Decisions and parameters are +final in [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); +procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md). + +## Gates + +**G1 — written approval package from Monerium.** Everything below exists only as +verbal/Telegram statements; consolidate into the MSA or a side letter: + +1. Attestor-pattern acceptance (verbally accepted, conditional on fallback capability — + mandatory by design, so the condition is met). +2. Redemption-limitation disclosure obligation (their request; our commitment — §Terms 1). +3. Issuer recovery backstop: burn from a linked address, payout only to the customer's + own external bank account, no fees, re-verification possible — **including the + 2026-08-26 statement that recovery validates the same ownership message as linking** + (which is why it works against the forwarder as built). +4. IBAN pinning: authorization requirements for `PATCH /ibans` / `POST /addresses` on + whitelabel profiles (the S1 preventive control). +5. OAuth→whitelabel profile portability and whether the whitelabel `client_id` + auto-accesses existing profiles. +6. SEPA recall / fraud loss allocation after conversion+forwarding. +7. Per-IBAN suspension capability for incident response. +8. Corporate KYB mechanism for direct (non-reliance) clients — not needed for the + SulPayments pilot, still an MSA item. +9. Advance notice of any change to the EIP-1271 ownership/link message (and the + recovery message): the forwarder whitelists their exact hashes, so an unannounced + change fail-closes new onboarding. + +**G2 — legal review** (not started): custody opinion on the attestor construction; MiCA +exchange/transfer-service scoping (non-custody is not the whole question); disclosure +enforceability; DPA with Monerium; sanctions screening for destinations; scope of the +bounded, pre-announced guardian fee power (P11). + +**G3 — external contract audit.** Parameters are final (ADR); the internal reviews and +the invariant suite are done, but this moves client funds. + +**G4 — pilot.** SulPayments agreement signed (terms inputs below), reliance +attestations per customer, 3–5 clients at **€50k/client/day** (paper control), fee 0. + +## Deploy checklist (mainnet bring-up) + +1. Apply database migrations from exactly one deployment instance. Migration execution + is not serialized across replicas; do not let multiple instances run the migrator + concurrently. Migrations 076/077 install allocation accounting and its exact + same-block boundary. Treat 076 as forward-only after activation: its `down` migration + refuses to discard any existing allocation rows, so restore from backup instead of + forcing a rollback once conversions have been attributed. +2. **Treasury first (O2):** create the dedicated fee Safe multisig — `FEE_RECIPIENT` is + immutable in the implementation. Confirm guardian key custody plan (EOA acceptable + for pilot; hardware/multisig at GA). +3. Re-verify the pinned pools and fee tiers at the deploy block (P10) and re-run the + liquidity baseline quote methodology (T6); confirm `perSwapCap` €25k still executes + within the slippage bound. +4. Deploy implementation + factory with the final parameters (ADR table: 52 h oracle + age, 100 bps slippage/fee cap, 60 d/24 h/60 d delays, €25 floor/€50k ceiling); set + operational `minSwapAmount` €250 and `perSwapCap` €25k; register the keeper key. +5. Verify factory + implementation source on the block explorer; generate, verify, and + publish the manifest. +6. Production whitelabel credentials from Monerium; configure the keeper backend (the + mykobo flow variant only): credentials, attestor/keeper/guardian keys (three distinct; + keeper funded), read RPC + private orderflow RPC, webhook secret, and + `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`. Keep `MONERIUM_B2B_ENABLED=false` until + every remaining gate is complete. +7. Register the webhook endpoint at Monerium (`profile.updated`, `iban.updated`, + `order.created`, `order.updated`). +8. Before first production onboarding, simulate a SEPA deposit end to end (dashboard → + Receive → "Simulate bank transfer") and re-verify the signed `webhook-id`, + `webhook-timestamp`, and `webhook-signature: v1,` fixture against a real + production delivery. +9. SulPayments side: manager profile configured (EU corridor, business type), secret + credential issued, deposit-event webhook registered and verifying signatures against + `GET /v1/public-key`. +10. Confirm every mapped forwarder has a zero EURe balance before the first enablement. + The mint cursor bootstraps at the current settled head and intentionally does not + convert historic, unindexed balances; reconcile any pre-existing balance manually. +11. Set `MONERIUM_B2B_ENABLED=true` on only the designated `mykobo` keeper backend and + restart. Startup must fail if any required B2B setting is absent. Confirm the routes, + raw webhook parser, and keeper are active before accepting a deposit. +12. Per client: runbook §1 (deploy clone → map → automated link/IBAN → penny test → + activate). + +## Terms & disclosure inputs (engineering-accurate; G2/partner own final wording) + +1. **Redemption limitation (B6 — mandatory, committed to Monerium).** Draft: + > EURe received at your dedicated forwarding address cannot be redeemed directly + > with Monerium from that address. If you need to redeem EURe (rather than receive + > the automatic USDC conversion), you must first withdraw it to your fallback + > address — from which you can redeem normally — or use Monerium's recovery + > process, which pays out only to your own verified bank account. + + (The recovery backstop is functional as built — T1 resolved — but keep it framed as + Monerium's process, subject to their verification.) +2. **Destination warranty & CEX rotation (B5 — Tier A accepted).** Client/partner + warrants the destination is valid and under the client's control and notifies Vortex + of changes before further deposits; client/partner bears rotation/closure/ + mis-crediting losses; CEX destinations carry an explicit rotation/minimum-deposit + attestation. Vortex's diligence consideration: 5 USDC penny test before activation, + the 60-day dormancy gate, minimum forward at or above the destination's minimum + deposit, and never sending unconverted EURe to the destination. Destination changes + are client-only (fallback key); Vortex cannot redirect funds. +3. **Dormancy re-confirmation (P5/B5).** Draft: + > If no conversion completes for 60 days, forwarding pauses automatically and + > resumes only after you (or the partner on your behalf, in writing) re-confirm your + > payout address. Deposits made while paused remain in your forwarding account and + > convert after re-confirmation; your fallback-address rights are unaffected. +4. **Fees (B1/P1/P2)** — disclose fee and conversion bound separately: + - Service fee: per-client percentage set at account creation (**pilot 0; GA + starting point 15 bps**), assessed on gross USDC output, contractual ceiling + equal to the on-chain cap (1%). Increases require a 24 h on-chain pre-announcement + (P11); decreases are immediate. + - Conversion bound (not a fee): each conversion delivers at least the Chainlink + EUR/USD reference rate minus 1%, or it does not execute (deposits wait and + retry). Enforced by the contract assuming an honest oracle; not a principal + guarantee under oracle failure or a stablecoin collapse beyond the bound. + - Batching never changes a client's effective rate: co-converted deposits split fee + and output pro-rata by amount. +5. **Processing SLA (B3 — decided: same business day).** Draft: + > Deposits at or above the minimum convert the same business day under normal + > market conditions. Conversions also execute on weekends; the EUR/USD reference + > rate updates less frequently outside FX market hours (staleness ceiling 52 h), so + > weekend conversions may execute at a rate up to that age — always within the + > conversion bound. Deposits below the minimum accumulate until it is reached. + + Include: the SLA is a service target, not a guarantee; keeper outages beyond 24 h + open a permissionless execution path, so conversion does not depend on Vortex. +6. **Vortex powers & self-custody disclosure.** What Vortex can do: deploy the account, + run the conversion, pause it, tune bounded parameters, adjust the fee within the + disclosed cap and timelock. What Vortex cannot do: move, redeem, or redirect funds — + every exit target is client-controlled, and pauses never block the fallback rights + or the delayed automatic sweep. Exit guarantees are scoped to the client's continued + control of their fallback key (loss of that key plus a broken destination is an + ordinary self-custody residual, borne by the client). Vortex cannot prevent inbound + SEPA to an issued IBAN; deposits during a pause accumulate safely as EURe. + +## Open items ledger + +| Item | Owner | Status | +|---|---|---| +| G1 package (9 items) | Marcel ↔ Monerium | All verbal; consolidate in writing | +| G2 legal scope | Counsel | Not started | +| G3 audit | External | After PR merge; params final | +| SulPayments agreement (terms above) | Marcel ↔ partner | Drafting inputs ready | +| Sandbox SEPA simulation + 3 TODO(sandbox) pins | Engineering (needs Marcel's sandbox login) | Open — only remaining engineering unknown | +| Fee Safe multisig creation | Ops | Before implementation deploy | +| GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md new file mode 100644 index 000000000..a8fdce910 --- /dev/null +++ b/docs/operations-monerium-b2b-runbook.md @@ -0,0 +1,615 @@ +# Monerium B2B Onramp — Operations Runbook + +All operator procedures for the B2B onramp in one place: onboarding, incident response, +alert triage, dormancy, and client migration. Architecture: +[`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); decisions +and parameters: [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); +security invariants: +[`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md). + +Ground rules that shape every procedure here: + +- **Vortex powers are delay-only.** Guardian/keeper can pause and execute the policy — + never move or redirect funds. There is no Vortex-side rescue path by design. +- **Pauses never trap client funds.** `fallbackAddress` functions (`sweep`, + `setDestination`, `setFallbackAddress`, `setClientPaused`) and the permissionless + dead-man sweep (`sweepStrandedEure`, after 60 days) work while paused. Do not promise + otherwise in comms. +- **Never send raw EURe to a CEX destination.** EURe recovery targets are + `fallbackAddress` only. +- **Treat allocation migrations as forward-only after use.** Run migrations from one + deployment instance only. Migration 076 refuses rollback when any + `monerium_deposit_allocations` row exists; take a database backup and roll forward + rather than deleting financial attribution records. + +## 1. Client onboarding + +Deploy → manifest → verify → map → (automated: link + IBAN) → penny test → activate. +One pass per client. Prerequisites: guardian key funded on the target chain; +`MONERIUM_B2B_ENABLED=true` and the complete `MONERIUM_B2B_*` env set on the one +`mykobo` keeper backend (including the trusted factory address, read/private RPCs, +webhook secret, and three keys); partner paperwork complete; the client +company onboarded and KYB-approved on Monerium's side (partner KYC reliance) with its +Monerium profile UUID at hand; the partner configured as a managed-profile manager +(`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type +`business`). + +### 1.1 Paperwork inputs (from the partner agreement) + +- `destination` — client's payout address. CEX deposit addresses allowed; validate: + EIP-55 checksum, not zero/dead/precompile/token/router (the contract re-rejects + token/router/self at init), warn-and-attest for contract addresses and CEX addresses + (rotation risk — terms). +- `fallbackAddress` — client's **self-custodied** recovery address. Mandatory, no + exceptions (Monerium acceptance condition). Must be distinct from custodial/CEX + addresses. +- `feeBps` — per-client; pilot `0`, GA starting point 15 bps (ADR B1). Adjustable + later via the guardian's timelocked setter. +- Signed terms including the redemption-limitation disclosure (rollout doc, Terms §1). + +### 1.2 Deploy the forwarder clone + +```bash +# predict, then deploy (guardian-only); salt = any unused bytes32, convention: client index +cast call $FACTORY "predictAddress(bytes32)(address)" $SALT --rpc-url $RPC +cast send $FACTORY "deployForwarder(address,address,uint16,bytes32)" \ + $DESTINATION $FALLBACK $FEE_BPS $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). +Record the forwarder address + deploy tx hash. + +### 1.3 Manifest: generate, verify, publish + +From `contracts/monerium-forwarder/`: + +```bash +bun script/generate-manifest.ts $FACTORY $RPC manifests/-$FACTORY.json +bun script/verify-manifest.ts manifests/-$FACTORY.json $RPC # must PASS +``` + +(Some free RPCs refuse historical `eth_getLogs`; add `--logs-rpc ` for the +event enumeration.) Publish the manifest (commit + public location). The manifest is +**consistency evidence, not a trust root**: it lets anyone detect silent changes; the +verified source on the block explorer is what proves the deployment honest — verify it +there as part of this step. + +### 1.4 Map the client to a managed profile + +One idempotent admin call creates the managed child (business entity under the partner +manager), imports the Monerium KYB approval, verifies the deployed clone on chain, and +records the account (status `onboarding`): + +``` +POST /v1/admin/monerium-b2b/accounts (Authorization: Bearer $ADMIN_SECRET) +{ + "managerProfileId": "", + "externalSubjectId": "", + "contactEmail": "", + "moneriumProfileId": "", + "forwarderAddress": "", + "destination": "", + "fallbackAddress": "", + "feeBps": 0 +} +``` + +Replaying the identical call is safe (200); divergent input is a 409, never an +overwrite. + +### 1.5 Link + IBAN (automated) + +The keeper's onboarding step picks up every mapped `onboarding` account and, +exactly-once via the profile-scoped `financial_operations` ledger: links the forwarder +with the attestor signature (`POST /addresses` — HTTP 201, `state: linked`, zero client +interaction), then requests IBAN issuance (`POST /ibans`, async 202). The IBAN lands on +the account row via the `iban.updated` webhook; from then on the association monitor +treats the DB record as the reference state. Nothing to do manually — verify the row +has its IBAN before the penny test, and check the logs if it stays empty for more than +a few cycles. + +### 1.6 Penny test + +Prove the destination actually credits contract-originated USDC transfers (CEXes can +rotate or mis-credit) before real volume flows: + +1. Send a small SEPA deposit to the new IBAN (sandbox: dashboard → Receive → "Simulate + bank transfer"). Target forward amount: 5 USDC (ADR B2). +2. The keeper converts automatically once the balance reaches `minSwapAmount`; for a + sub-minimum penny test, temporarily lower `minSwapAmount` (guardian, bounded by the + floor) or fund up to the minimum. +3. **Partner/client confirms credit at the destination** in writing (a terms diligence + commitment). + +### 1.7 Activate + +``` +PATCH /v1/admin/monerium-b2b/accounts//status (Authorization: Bearer $ADMIN_SECRET) +{ "status": "active" } +``` + +(Refused with 409 while no IBAN is recorded.) Confirm the next monitoring pass picks +the account up cleanly, then hand the IBAN to the client via the partner. + +Failure at any step: nothing is at risk — the forwarder holds no funds until the client +wires EUR, and every recovery path is live from deployment. + +## 2. Incident response + +### 2.1 Pause procedures + +Guardian key = `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`; `$FACTORY` from the published +manifest. + +```bash +# Per-clone pause (one client — compliance hold, dormancy, targeted issue) +cast send "setGuardianPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY +# Global pause (all clones — protocol-level incident) +cast send $FACTORY "setGlobalPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY +# Availability lever: reduce the per-swap cap (instant, bounded by immutables) +cast send $FACTORY "setPerSwapCap(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Both pauses block `swapAndForward` only; unpause = same call with `false`. + +### 2.2 Monerium IBAN suspension ask + +Per-IBAN suspension is **G1 item 7 — not yet contractual**; until the MSA settles it, +best-effort: contact Monerium support/emergency, identify the whitelabel partner +account and affected IBAN(s) + forwarder(s), ask for suspension of inbound SEPA +(deposits bounce to senders — NOT profile closure), record the ticket for the G1 +negotiation record. While unsuspended, inbound SEPA keeps minting EURe to the forwarder +— safe behind the contract invariants, but growing exposure. + +### 2.3 Client notification + +Clients have no Vortex UI; comms run through the partner plus direct email: notify the +partner ops contact first; email affected clients (**stop sending EUR to your IBAN until +further notice**; deposits already sent convert after resolution or are recoverable via +the fallback address — nothing is lost by pausing); status page entry if global. + +### 2.4 Critical-vulnerability sequence (the 02:00-UTC drill) + +Suspected vulnerability in `VortexForwarder`/factory: + +1. **Pause all** (`setGlobalPaused(true)`) — instant, protective-only, reversible. +2. **Ask Monerium to suspend affected IBANs** (§2.2) so no new EURe mints. +3. **Notify** partner + clients (§2.3). +4. **Assess.** Funds at risk = EURe balances on forwarders (stranded-balance monitor + output, or `cast call "balanceOf(address)" `); run the manifest + verifier against the live deployment. +5. **If funds must move: only clients can move them.** Instruct clients (via partner) + to sweep EURe with their fallback key: `sweep(EURE, )` from + `fallbackAddress` — provide exact calldata and a verification walkthrough. The + issuer recovery backstop (burn + payout to the client's own bank account; validates + the already-whitelisted ownership message) is the last resort. +6. **Ship the fix as a migration** (§5): new implementation + factory (new audit), new + clones, re-link, move IBANs, penny-test, republish the manifest. Old clones stay + paused; residual balances leave via fallback or dead-man sweep. +7. **Unpause / decommission** only contracts confirmed unaffected. + +### 2.5 Whitelabel-credential compromise (S1) + +On an unexplained `ASSOCIATION CHANGE` alert or any suspicion the Monerium credentials +leaked: treat as an active incident. First hour: (1) rotate the whitelabel client +secret at Monerium; (2) request IBAN suspension for affected accounts (§2.2); +(3) notify the partner to halt client sends; (4) global pause is optional — on-chain +funds are not at risk, only *future* mints can be redirected. Then reconcile: diff +Monerium-side links/IBANs against the DB for every account, treating the association +monitor's history as the timeline. Blast radius = deposit flow between the unauthorized +change and suspension. + +## 3. Alert triage (monitoring log lines → action) + +Monitors run from the keeper worker every ~30 min; lines are prefixed `monerium-b2b:`. + +| Log line contains | Meaning | Action | +|---|---|---| +| `PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Executable depth below even minimum-size swaps; swaps would revert on minOut | Global pause (§2.1); investigate pool state (LP exit, depeg); consider lowering `perSwapCap`; re-run the liquidity-baseline methodology before unpausing | +| `executable depth below perSwapCap` | Cap-sized swaps would revert; availability, not fund risk | Lower `perSwapCap` or accept keeper retries; watch for escalation | +| `ASSOCIATION CHANGE` | Monerium-side association diverged from the DB (IBAN moved, address linked) — the S1 detective control | §2.5 — potential credential compromise unless the change was an announced migration (§5) | +| `stranded EURe on forwarder` (warn ≥12h) | Keeper is not converting | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts) | +| `stranded EURe ... past TRIGGER_DELAY` | Permissionless trigger now live; SLA long broken | Escalate the keeper outage; anyone may call `swapAndForward()` (same policy applies); communicate the delay | +| `untrusted factory` / `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on trusted factory` | Should-be-impossible state | Full incident: global pause, verify `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, run the manifest verifier, compare against manifest history | +| `reconciled owner-authorized config change` | Client rotated destination/fallback, or a guardian fee change applied — expected, DB updated | No incident. Unexpected destination change → confirm with the partner; a surprise suggests a compromised fallback key (client should `setClientPaused(true)` and rotate) | +| `onboarding advance failed` (repeating for one account) | Link/IBAN automation stuck | Check the `financial_operations` row: `failed` retries itself; `unknown` needs manual reconciliation (compare Monerium-side state, then update the row) | +| `delivery ... abandoned after N attempts` | Partner webhook endpoint down > backoff horizon | Contact partner; deliveries are not retried after abandonment — partner should poll `GET /v1/monerium-b2b/deposits` to catch up | +| `MONERIUM_B2B_PRIVATE_RPC_URL is not set` | Keeper writes in the public mempool | Set the private orderflow RPC (operational finding on mainnet) | + +## 4. Dormancy gate + +Why: CEX rotation risk concentrates in dormant accounts — an exchange silently rotates +a deposit address; months later a deposit arrives and USDC would be forwarded to an +address the client no longer controls. The gate converts that silent loss into a pause. + +**Automatic:** an `active` account with no confirmed conversion for 60 days is paused +(`setGuardianPaused(true)` with the guardian key; log-only if the key is unset) and +`dormant_since` is recorded; the conversion executor skips it (the stranding marker +still arms — the dead-man sweep clock is unaffected). EURe arriving during dormancy +accumulates safely; past the sweep delay it flows to `fallbackAddress` automatically. + +**Re-confirmation (manual, via partner):** partner re-confirms in writing that the +destination is valid and client-controlled (ADR B5). If the destination changed, the +**client** updates it via their fallback key (`setDestination`) — Vortex cannot and must +not — and CEX destinations re-run the penny test. Archive the confirmation. + +**Un-pause (both steps, always):** + +```bash +cast send "setGuardianPaused(bool)" false --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +```sql +UPDATE monerium_accounts SET dormant_since = NULL WHERE forwarder_address = ''; +``` + +The DB flag, not the chain flag, gates the executor — un-pausing without clearing +`dormant_since` leaves the account skipped. Verify: a `SwapExecuted`, the execution row +`confirmed`, no stranded alert on the next pass. Never un-pause to "flush" a balance +without re-confirmation — that balance is exactly the rotation-risk scenario. + +## 5. Client migration to a new clone (manual; tooling = ADR O1, build when needed) + +For contract upgrades or config changes that require a new clone. **Announce first**: +record the migration (account id, old/new forwarder, window) so the association +monitor's alerts are expected, then: + +1. Deploy the new clone (§1.2) and verify it (`isForwarder` + config read-back — + Vortex tooling only ever targets factory clones). +2. Let the keeper drain the old clone (or client sweeps the remainder via fallback). +3. Link the new clone to the same Monerium profile (attestor flow — automated once the + account row's forwarder is repointed, or manual `POST /addresses`). +4. Move the IBAN: `PATCH /ibans/{iban}` with the new address — this is the + S1-sensitive operation; it must only ever happen inside an announced migration. +5. Update the `monerium_accounts` row (forwarder address), penny-test the new clone, + re-activate. + +There is no unlink at Monerium and no custodial parking position: EURe always mints to +the IBAN's current default address; the old clone stays linked but inert. + +## 6. Key compromise quick reference + +| Key | Blast radius | Response | +|---|---|---| +| Attestor | Can link addresses to profiles; never move funds (recovery payouts go only to the client's own bank account) | Rotate key; new forwarders need a new implementation (ATTESTOR is immutable); existing links unaffected | +| Keeper | `poke`/`swapAndForward` only (policy-constrained); worst case gas theft | Rotate; `setKeeper(old,false)` + `setKeeper(new,true)`; refund gas | +| Guardian | Pause/unpause, bounded params, timelocked fee — delay-only griefing | Two-step `transferGuardian`/`acceptGuardian`; audit pause + pending-fee state after | +| Whitelabel API credentials | Control-plane: can re-link/move IBANs (future mints only) — S1 | §2.5 full sequence | +| `ADMIN_SECRET` | Map/suspend accounts (mapping is bounded by on-chain clone verification) | Rotate; audit recent admin mutations | +| Webhook HMAC secret | Fabricated inbound order events (accounting noise; forward-only lattice + mint watcher bound the damage) | Rotate at both ends; reconcile deposits against chain | +| Client fallback key (client-side) | Full control of that client's funds/config | Client's own responsibility (terms); assist via partner: pause the account; client rotates `setFallbackAddress` if still in control | + +## 7. Local mainnet-fork integration exercise + +This exercise validates the deployed forwarder and live keeper backend together against +real Ethereum mainnet token, pool, router, and oracle state. It is an opt-in operator +exercise, not a hermetic automated test: it needs an archive-capable Ethereum RPC, a +local Postgres database, and the API environment. It must not run in the PR-blocking test +suite; `operations-testing.md` deliberately keeps fork tests out of CI. + +The procedure below is the cleaned-up path from a successful reference run. It assumes +archive access and a historical EURe holder are available from the outset. Use only the +standard public Anvil development keys for the local roles. + +### 7.1 Reference configuration and result + +| Item | Reference value | +|---|---| +| Fork block | `25876292` | +| Chain ID | `1` | +| Anvil RPC | `http://127.0.0.1:8545` | +| Archive proxy | `http://127.0.0.1:9545` | +| EURe V2 | `0x39b8B6385416f4cA36a20319F70D28621895279D` | +| EURC | `0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c` | +| USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | +| SwapRouter02 | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | +| Chainlink EUR/USD | `0xb49f677943BC038e9857d61E7d053CaA2C1734C1` | +| EURe holder | `0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c` | +| Factory | `0xbe613aa10f731ea38786a082a341fe1a1bc9e266` | +| Factory deployment tx | `0xf3531fbdb27ed9303974b282ed2df4c53765c0910e210c7cba182e6c7f25a368` | +| Forwarder | `0xe06103c9E374a1CD78f17417d1eA3AE4eBaC7CFD` | +| Forwarder deployment tx | `0x7d4f667d4de9b7a5d5aced2203a3f11dcd0b477e5d405d5b8a2317f2b56b7c4c` | +| Destination | `0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc` | +| Fallback address | `0x976EA74026E726554dB657fA54763abd0C3a0aa9` | +| Local account id | `5ebca15c-dadf-4eeb-aabb-e9c7462ff6b3` | +| Mock Monerium profile id | `f436dbeb-6012-4688-ab3b-d2446980c835` | +| Managed profile id | `c419d077-3e2b-488a-b228-359311c63324` | +| Mock IBAN | `DE12500105170648489890` | + +The reference deposit transferred 25 EURe to the forwarder in transaction +`0x727a53eb525e5851d8db38ea99c2f39633b6213de5757639d82e6c112e49079a`. +The live keeper confirmed conversion transaction +`0xb52f38073c41b5e8d2f89deab5c2b8536362acfd97903579113630fc02b58eb4`, +consumed the full 25 EURe, and forwarded `29.012924` USDC with a zero fee. These +addresses and hashes are evidence from that ephemeral run, not deployment pins; use the +receipts and addresses produced by each new run. + +### 7.2 Start an archive-backed fork + +To avoid placing `ALCHEMY_API_KEY` in Anvil's process arguments, run a local proxy from +`apps/api/` that reads the existing `.env`: + +```bash +bun -e ' +import "dotenv/config"; +const upstream = `https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`; +Bun.serve({ + hostname: "127.0.0.1", + port: 9545, + async fetch(request) { + return fetch(upstream, { + method: request.method, + headers: { "content-type": request.headers.get("content-type") ?? "application/json" }, + body: request.body + }); + } +}); +await new Promise(() => {}); +' +``` + +In another terminal, start the fixed fork: + +```bash +anvil \ + --fork-url http://127.0.0.1:9545 \ + --fork-block-number 25876292 \ + --chain-id 1 \ + --host 127.0.0.1 \ + --port 8545 \ + --no-rate-limit +``` + +Confirm the fork can read historical state before deploying anything: + +```bash +cast call 0x39b8B6385416f4cA36a20319F70D28621895279D \ + "balanceOf(address)(uint256)" \ + 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + --rpc-url http://127.0.0.1:8545 +``` + +At the reference block the holder had `191297573010983027041550` raw EURe units. +Fund its native balance locally and impersonate it; do not mutate its EURe storage: + +```bash +cast rpc anvil_setBalance \ + 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + 0x8AC7230489E80000 \ + --rpc-url http://127.0.0.1:8545 + +cast rpc anvil_impersonateAccount \ + 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + --rpc-url http://127.0.0.1:8545 +``` + +Before deploying the forwarder, send 5 EURe to an unrelated address and verify its +balance. This isolates basic fork/provider/ERC-20 failures from forwarder failures: + +```bash +cast send 0x39b8B6385416f4cA36a20319F70D28621895279D \ + "transfer(address,uint256)(bool)" \ + 0x1234567890123456789012345678901234567890 \ + 5000000000000000000 \ + --from 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + --unlocked \ + --rpc-url http://127.0.0.1:8545 +``` + +### 7.3 Deploy the factory and clone + +Build from `contracts/monerium-forwarder/` and deploy +`VortexForwarderFactory.sol:VortexForwarderFactory` with three distinct standard Anvil +accounts: account 0 as guardian, account 1 as keeper, and account 2 as attestor. Use +account 3 as the fee recipient. Anvil prints these public development keys and addresses +on startup. + +Use the ADR's canonical constructor values, not the older values that may appear in test +fixtures: + +| Constructor field | Value | +|---|---:| +| `MAX_ORACLE_AGE` | 52 hours | +| `SLIPPAGE_BPS` | 100 | +| `MAX_FEE_BPS` | 100 | +| `SWEEP_DELAY` | 60 days | +| `TRIGGER_DELAY` | 24 hours | +| `POOL_FEE_EURE_EURC` | 500 | +| `POOL_FEE_EURC_USDC` | 500 | +| `RECOVERY_HASH` | `bytes32(0)` | +| `MIN_SWAP_FLOOR` | `25e18` | +| `CAP_CEILING` | `50000e18` | +| Initial `minSwapAmount` | `250e18` | +| Initial `perSwapCap` | `25000e18` | + +After deployment, register Anvil account 1 as a keeper and lower the mutable minimum to +the immutable 25 EURe floor for this exercise: + +```bash +cast send "$FACTORY" "setKeeper(address,bool)" "$KEEPER" true \ + --private-key "$GUARDIAN_KEY" --rpc-url http://127.0.0.1:8545 + +cast send "$FACTORY" "setMinSwapAmount(uint256)" 25000000000000000000 \ + --private-key "$GUARDIAN_KEY" --rpc-url http://127.0.0.1:8545 +``` + +Deploy a zero-fee client clone as in §1.2. Use a fresh salt and record the predicted +address and receipt. Read back `destination()`, `fallbackAddress()`, `feeBps()`, and +`FACTORY()`, then require `factory.isForwarder(forwarder) == true` before continuing. + +### 7.4 Create the local account fixture + +This exercise does not create a real Monerium corporate. Generate a random UUID for +`moneriumProfileId`, then call the normal admin mapping endpoint with an existing active +managed-profile manager: + +```json +{ + "managerProfileId": "", + "externalSubjectId": "local-corporate-", + "contactEmail": "local-corporate-@example.com", + "moneriumProfileId": "", + "forwarderAddress": "", + "destination": "", + "fallbackAddress": "", + "feeBps": 0 +} +``` + +Send it to `POST /v1/admin/monerium-b2b/accounts` with +`Authorization: Bearer $ADMIN_SECRET`. This exercises the real forwarder registration +and config verification before creating the managed child, approved KYB mirror, and +`onboarding` account. + +Because the Monerium profile is intentionally fake, mock an IBAN directly on the new +local `monerium_accounts` row. Then activate through the real endpoint rather than +updating status directly: + +```http +PATCH /v1/admin/monerium-b2b/accounts//status +Authorization: Bearer +Content-Type: application/json + +{ "status": "active" } +``` + +The activation response must report `accountStatus: "active"`. The direct IBAN update +is a local fixture seam only; never use it outside an ephemeral local database. + +### 7.5 Start the keeper backend + +The Monerium B2B worker is owned by the `mykobo` flow variant. A default `monerium` +backend serves the API but deliberately does not start this worker. The simplest +reproduction is one `mykobo` backend that serves both the admin API and keeper: + +```bash +FLOW_VARIANT=mykobo \ +PORT=3000 \ +MONERIUM_B2B_RPC_URL=http://127.0.0.1:8545 \ +MONERIUM_B2B_PRIVATE_RPC_URL=http://127.0.0.1:8545 \ +MONERIUM_B2B_GUARDIAN_PRIVATE_KEY="$ANVIL_ACCOUNT_0_KEY" \ +MONERIUM_B2B_KEEPER_PRIVATE_KEY="$ANVIL_ACCOUNT_1_KEY" \ +MONERIUM_B2B_ATTESTOR_PRIVATE_KEY="$ANVIL_ACCOUNT_2_KEY" \ +bun run --cwd apps/api dev +``` + +The log must contain `Starting Monerium B2B keeper worker`. Before this first start, +verify every mapped forwarder has zero EURe balance. Then wait for one worker cycle and +verify `monerium_chain_cursors` contains `eure-mints:1` before sending the deposit; +otherwise the watcher's first-run bootstrap intentionally starts at the current settled +head and treats earlier chain history and balances as out of scope. + +### 7.6 Send and settle the deposit + +Transfer exactly 25 EURe from the impersonated holder to the clone: + +```bash +cast send 0x39b8B6385416f4cA36a20319F70D28621895279D \ + "transfer(address,uint256)(bool)" \ + "$FORWARDER" \ + 25000000000000000000 \ + --from 0x0cC2CaeD31490B546c741BD93dbba8Ab387f7F2c \ + --unlocked \ + --rpc-url http://127.0.0.1:8545 +``` + +Mine 13 blocks so the transfer is beyond the watcher's 12-block reorg safety depth: + +```bash +cast rpc anvil_mine 0xd --rpc-url http://127.0.0.1:8545 +``` + +Wait for the next worker cycle. A direct transfer has no matching Monerium order, so the +expected path is deliberately `unattr:` rather than an attributed customer deposit. The +log should show an unattributed EURe mint followed by an execution allocation. + +Verify the durable records: + +```sql +SELECT monerium_order_id, amount_raw, status, tx_hash, log_index, block_number +FROM monerium_fiat_deposits +WHERE account_id = ''; + +SELECT eure_in_raw, usdc_gross_raw, fee_raw, usdc_net_raw, destination, + tx_hash, nonce, broadcast_block_number, block_number, swap_log_index, status, error +FROM monerium_conversion_executions +WHERE account_id = ''; + +SELECT deposit_id, execution_id, eure_in_raw, usdc_net_raw +FROM monerium_deposit_allocations +WHERE deposit_id IN ( + SELECT id FROM monerium_fiat_deposits WHERE account_id = '' +); +``` + +Required results: + +- One `minted` deposit with an `unattr:` order id and the real transfer hash and log index. +- One allocation joining that deposit and execution with the 25 EURe input and the + attributed net USDC. +- One `confirmed` execution with the 25 EURe input, zero fee, non-null + nonce/hash/block/swap-log-index, destination matching the clone, and `error IS NULL`. +- The forwarder's EURe balance is zero. +- The destination's USDC balance increased by `usdc_net_raw`. +- The conversion receipt contains `SwapExecuted` from the clone and a USDC `Transfer` + from the clone to the configured destination. + +### 7.7 What this exercise validates + +- An archive-backed mainnet fork can execute the real EURe V2 proxy and emit the + canonical `Transfer` event consumed by the watcher. +- Factory construction deploys the implementation with the canonical parameter values, + registers the keeper, and creates an initialized EIP-1167 clone. +- Successful admin provisioning reads back the clone's factory registration and config + before creating the managed child plus approved KYB mirror. +- The account activation success path works once an IBAN is present. +- Only the `mykobo` backend owns and starts the B2B keeper worker. +- The persisted chain cursor detects the transfer after it is moved beyond the 12-block + safety depth. +- A direct transfer to a known forwarder is durably recorded as an unattributed mint, + not silently presented as a Monerium customer order. +- The executor's durable path leaves a confirmed execution with its nonce, transaction + hash, block number, swap log index, amounts, and destination recorded; allocation is + added only after the mint cursor covers that execution block. +- The real contract accepts the current Chainlink EUR/USD answer and swaps successfully + through the pinned EURe -> EURC -> USDC 5-bps Uniswap V3 path. +- Keeper authorization, the 25 EURe minimum, allowance reset, full EURe consumption, + zero-fee accounting, and forwarding to the immutable per-client destination work + together. +- Cursor-gated snapshot allocation links the observed deposit to the confirmed execution + at the exact `SwapExecuted` log boundary and assigns the full USDC output. + +### 7.8 What this exercise does not validate + +- It does not make a SEPA transfer or ask Monerium to mint EURe. The input is an ordinary + ERC-20 transfer from an impersonated historical holder. +- It does not create or approve a corporate in Monerium, link the forwarder through the + attestor/EIP-1271 flow, request an IBAN, or process a real `iban.updated` webhook. The + Monerium profile UUID and IBAN are local fixtures. +- It does not test webhook HMAC verification, durable inbox deduplication, Monerium order + state transitions, amount/hash matching, or the attributed-deposit path. The tested + mint is intentionally unattributed. +- It does not prove that a bank or CEX credits the destination. It proves only the + on-chain USDC balance increase. +- It does not test invalid admin authentication or the activation rejection before an + IBAN is present. It also does not exercise provisioning rejection for an unregistered + or misconfigured forwarder; only authenticated successful provisioning and activation + run. +- It does not test out-of-bounds factory parameters or prove their rejection; the run + deploys only the canonical valid parameter set. +- It does not test fees above zero, fee-increase timelocks, per-swap-cap batching, + sub-minimum accumulation, pause controls, dormancy, permissionless triggering, + stranded-fund sweeping, fallback-key recovery, or client config rotation. +- It does not test stale/invalid oracle answers, insufficient liquidity, excess price + impact, slippage reverts, router failure, token transfer failure, or depeg behavior. +- It does not test reorg replacement, duplicate-log replay, concurrent executors, + advisory-lock contention, process crashes before/after broadcast, lost transaction + hashes, nonce replacement, or restart recovery. +- It does not test production key custody, private orderflow, production RPC behavior, + source verification, deployment manifests, or independent bytecode verification. +- It does not validate manager notifications, webhook outbox delivery, email delivery, + or the 32-block client-notification confirmation policy. +- It does not constitute a clean monitoring pass. In the reference run the association + monitor received the expected provider `403` for the fake profile, and the large-size + executable-depth quote timed out once; neither monitor was part of the conversion + success criterion. diff --git a/docs/operations-monerium-interface.md b/docs/operations-monerium-interface.md new file mode 100644 index 000000000..4f039c730 --- /dev/null +++ b/docs/operations-monerium-interface.md @@ -0,0 +1,151 @@ +# Monerium Interface + +## Monerium White-Label API + +All calls are server-to-server using `client_credentials`; users remain entirely within Vortex. ([Whitelabel: Authentication](https://docs.monerium.com/whitelabel#authentication)) + +The Vortex transport is `packages/shared/src/services/monerium/moneriumApiService.ts` — the single +Monerium transport in the repo; the Monerium B2B onramp consumes it through the narrow adapter +`apps/api/src/api/services/monerium-b2b/monerium-api.ts`. It establishes the sole Monerium +integration baseline but does not by itself re-enable EUR ramp registration or settlement. It +caches client-credential tokens in memory, requests API v2, retries once after `401`, and applies +a 10-second timeout to every call. Credentials use `MONERIUM_WHITELABEL_CLIENT_ID` and +`MONERIUM_WHITELABEL_CLIENT_SECRET`. + +| Operation | Endpoint / sequence | Commentary | Source | +|---|---|---|---| +| Authenticate | `POST /auth/token` | Send `grant_type=client_credentials`, `client_id`, `client_secret`. Token expires after 1 hour. | [Whitelabel: Authentication](https://docs.monerium.com/whitelabel#authentication) | +| Check user status | `GET /profiles/{profileId}` | Returns top-level state: `created`, `incomplete`, `pending`, `approved`, or `rejected`. | [API: Profile](https://docs.monerium.com/api/#tag/profiles/operation/profile) | +| Check KYC/KYB status | `GET /profiles/{profileId}` | Inspect `state`, `details.state`, `form.state`, and each `verifications[].state`. This is the relevant KYC/KYB status interface. | [API: Profile](https://docs.monerium.com/api/#tag/profiles/operation/profile) | +| List/search users | `GET /profiles?state=&kind=` | Only documented filters are `state` and `kind` (`personal`/`corporate`). No email, IBAN, name, or address filter. | [API: Profiles](https://docs.monerium.com/api/#tag/profiles/operation/profiles) | +| Find user by IBAN | `GET /ibans/{iban}` then `GET /profiles/{profileId}` | The IBAN response contains its owning `profile` UUID. | [API: IBAN](https://docs.monerium.com/api/#tag/ibans/operation/iban) | +| Find user by address | `GET /addresses/{address}` then `GET /profiles/{profileId}` | Address response contains the owning `profile` UUID. | [API: Address](https://docs.monerium.com/api/#tag/addresses/operation/address) | +| Get user information | `GET /profiles/{profileId}` | Returns profile identity, type, name, and compliance states. It does **not** expose the submitted personal/corporate details such as email or address. | [API: Profile](https://docs.monerium.com/api/#tag/profiles/operation/profile) | +| Monitor status changes | `profile.updated` webhook | Preferred over polling. `profile.error` is opt-in and reports rejected ingestion fields. | [Whitelabel: Monitor approval](https://docs.monerium.com/whitelabel#5-monitor-approval), [Whitelabel: Event types](https://docs.monerium.com/whitelabel#event-types) | + +## KYC/KYB Profile Lifecycle + +Monerium does not expose a separate KYC/KYB case or attempt ID. The profile UUID created by +`POST /profiles` is the durable workflow identity; its `kind` is immutable, and details, form data, +and verifications are sections of that same profile. Vortex therefore mirrors one `kyc_cases` row +per Monerium `provider_customers` row and leaves `provider_case_id` unset. Repeated submissions and +status changes update that row rather than creating a new local case. + +| Profile state | Meaning and next action | +|---|---| +| `created` | No data has been submitted. Submit the required profile sections using the same profile UUID. | +| `incomplete` | The profile is resumable. Inspect section states and `profile.error`, correct or add the requested data, and resubmit the affected `/share`, `/details`, `/form`, or `/verifications` operation against the same profile UUID. | +| `pending` | Monerium is reviewing the profile. Further submissions are blocked; wait for `profile.updated` to move it to `approved`, `rejected`, or back to `incomplete`. Do not create another profile or retry blindly. | +| `approved` | KYC/KYB is complete and Monerium services are available. Further section updates return `409`. | +| `rejected` | Final compliance rejection. Do not retry or create a replacement profile unless Monerium explicitly authorizes a new onboarding. | + +The current shared client implements profile reads but not `POST /profiles` or the onboarding +`POST`/`PATCH` operations above. This lifecycle is the required behavior when that orchestration is +added. Externally imported profiles enter Vortex directly as `approved` and do not execute these +submission steps locally. + +## Connected Addresses + +| Operation | Endpoint / sequence | Commentary | Source | +|---|---|---|---| +| List all addresses | `GET /addresses?profile={profileId}` | Returns every address and its connected `chains[]`. Optional `chain` filter. | [API: Addresses](https://docs.monerium.com/api/#tag/addresses/operation/addresses) | +| Inspect one address | `GET /addresses/{address}` | Returns owner profile and connected chains. | [API: Address](https://docs.monerium.com/api/#tag/addresses/operation/address) | +| Connect address | `POST /addresses` | Submit `profile`, `address`, `chain`, fixed message, and ownership signature. | [Whitelabel: Link wallet](https://docs.monerium.com/whitelabel#link-wallet) | +| Ownership message | `I hereby declare that I am the address owner.` | Must be exact. EOA uses a 65-byte signature. Smart wallets use EIP-1271, either off-chain signatures or on-chain approval. | [Whitelabel: Link wallet](https://docs.monerium.com/whitelabel#link-wallet), [Whitelabel: EIP-1271](https://docs.monerium.com/whitelabel#eip-1271) | +| Change address | Link the new address, then `PATCH /ibans/{iban}` | There is no documented address update, reassignment, unlink, or delete endpoint. The old address remains connected. | [API: Addresses](https://docs.monerium.com/api/#tag/addresses), [Whitelabel: Move an IBAN](https://docs.monerium.com/whitelabel#move-an-iban) | +| Determine default address | `GET /ibans?profile={profileId}` | “Default” belongs to the IBAN: its `address` and `chain` are the default mint destination. There is no profile-level default-address field. | [API: IBANs](https://docs.monerium.com/api/#tag/ibans/operation/ibans), [Whitelabel: Incoming payments](https://docs.monerium.com/whitelabel#incoming-payments) | +| Change default destination | `PATCH /ibans/{iban}` with `{address, chain}` | Future incoming payments mint to the new destination. The destination must be linked to the profile. | [Whitelabel: Move an IBAN](https://docs.monerium.com/whitelabel#move-an-iban) | + +### Off-chain EIP-1271 ownership + +Off-chain EIP-1271 uses the same `POST /addresses` operation; there is no additional Vortex or +Monerium endpoint. Wallet owners collect signatures externally over the exact ownership message, +then assemble the contract-specific combined signature bytes. Vortex sends those bytes unchanged +in `signature`. Monerium immediately calls `isValidSignature(messageHash, signature)` and links the +address with `201` when the contract returns the EIP-1271 magic value. + +The public documentation demonstrates Safe's `createMessage`, `SigningMethod.ETH_SIGN`, and +`buildSignatureBytes` flow, but does not specify a generic byte-level `messageHash` derivation for +arbitrary smart wallets. The shared client therefore must not hash, split, recover, reorder, or +otherwise reinterpret the combined signature. Signature assembly remains the wallet owners' or +wallet integration's responsibility. + +For contrast, the on-chain EIP-1271 path submits `"0x"` and returns `202` while Monerium polls for +the on-chain approval. Vortex's intended integration is the immediate off-chain path, while the +client preserves both documented response semantics. + +## On-Ramp: SEPA to EURe + +1. Confirm `GET /profiles/{profileId}` returns `approved`. ([API: Profile](https://docs.monerium.com/api/#tag/profiles/operation/profile)) +2. List or connect an address using `GET/POST /addresses`. ([Whitelabel: Link wallet](https://docs.monerium.com/whitelabel#link-wallet), [API: Addresses](https://docs.monerium.com/api/#tag/addresses/operation/addresses)) +3. Retrieve existing IBAN using `GET /ibans?profile={profileId}`. ([API: IBANs](https://docs.monerium.com/api/#tag/ibans/operation/ibans)) +4. If none exists, call `POST /ibans` with `{address, chain}`. ([Whitelabel: Request IBAN](https://docs.monerium.com/whitelabel#request-iban)) +5. Wait for `iban.updated`; provisioning is asynchronous. ([Whitelabel: Retrieve the IBAN](https://docs.monerium.com/whitelabel#retrieve-the-iban)) +6. Give the IBAN to the user. ([Whitelabel: EUR IBAN](https://docs.monerium.com/whitelabel#eur-iban)) +7. Incoming SEPA funds automatically create an `issue` order and mint EURe to the IBAN’s linked address. ([Whitelabel: Incoming payments](https://docs.monerium.com/whitelabel#incoming-payments)) +8. Monitor `order.created` and `order.updated`. ([Whitelabel: Monitor orders](https://docs.monerium.com/whitelabel#monitor-orders)) + +No API call starts an incoming payment. It is passive. ([Whitelabel: Incoming payments](https://docs.monerium.com/whitelabel#incoming-payments)) + +A sender can override the destination for one payment using memo: ([Whitelabel: Routing with memo](https://docs.monerium.com/whitelabel#routing-with-memo)) + +```text +: +``` + +The address must already be linked to that profile. ([Whitelabel: Routing with memo](https://docs.monerium.com/whitelabel#routing-with-memo)) + +## Off-Ramp: EURe to SEPA + +1. Ensure the source wallet is linked and holds EURe. ([Whitelabel: SEPA payment](https://docs.monerium.com/whitelabel#sepa-payment)) +2. Construct and sign: ([Whitelabel: Signing an order](https://docs.monerium.com/whitelabel#signing-an-order)) + +```text +Send EUR to at +``` + +3. Call `POST /orders` with: ([Whitelabel: SEPA payment](https://docs.monerium.com/whitelabel#sepa-payment)) + - `kind: "redeem"` + - source `address` and `chain` + - `currency: "eur"` and `amount` + - recipient IBAN and individual/company details + - exact `message` and `signature` +4. Monitor `order.updated` until `processed` or `rejected`. ([Whitelabel: Monitor orders](https://docs.monerium.com/whitelabel#monitor-orders)) +5. For amounts of €15,000 or more, first upload supporting evidence using `POST /files` and provide `supportingDocumentId`. ([Whitelabel: SEPA payment](https://docs.monerium.com/whitelabel#sepa-payment)) + +The signed message may contain either the full IBAN or Monerium's deterministic shortened form +(`EE52...1285`, first four and last four characters). The request counterpart always contains the +full normalized IBAN. + +## Quoting + +There is **no documented quote endpoint for standard EUR on-ramp or SEPA off-ramp orders**. ([API: Orders](https://docs.monerium.com/api/#tag/orders), [Swap: Get a quote](https://docs.monerium.com/swap#get-a-quote)) + +Monerium has `GET /swap/{chain}/{sellToken}/{buyToken}` and `POST /swap/accept`, but this is a separate preview token-swap feature, currently documented for sandbox USDC/EURe on Arbitrum Sepolia. It should not be treated as the on/off-ramp quote API. ([Swap: Preview configuration](https://docs.monerium.com/swap#preview-configuration), [Swap: Get a quote](https://docs.monerium.com/swap#get-a-quote), [Swap: Accept the quote](https://docs.monerium.com/swap#accept-the-quote)) + +## Required Webhooks + +Register with `POST /webhooks`: ([Whitelabel: Webhooks](https://docs.monerium.com/whitelabel#webhooks)) + +| Event | Purpose | Source | +|---|---|---| +| `profile.updated` | KYC/KYB status changes | [API: Profile updated webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-profile-updated) | +| `profile.error` | Invalid submitted profile fields | [API: Profile error webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-profile-error) | +| `iban.updated` | IBAN provisioned or moved | [API: IBAN updated webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-iban-updated) | +| `order.created` | Incoming payment detected | [API: Order created webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-order-created) | +| `order.updated` | Payment processed or rejected | [API: Order updated webhook](https://docs.monerium.com/api/#tag/webhooks/operation/webhook-order-updated) | + +The shared client also maps `GET /webhooks` and `PATCH /webhooks/{subscription}` so contract tests +and operations can inspect and deactivate subscriptions. The current API does not document a +webhook delete operation. + +## Contract Tests + +`apps/api/src/tests/contracts/monerium.contract.test.ts` validates the consumed request and +response schemas hermetically on every run. `RUN_LIVE_TESTS=1` enables the prepared sandbox checks. +Read-only checks can list profiles, addresses, IBANs, and orders; fixture IDs enable corresponding +single-resource reads. Every mutating flow has a separate `MONERIUM_CONTRACT_RUN_*` gate because it +creates persistent sandbox state or, for an order, can move sandbox EURe. Monerium is not added to +the nightly workflow until white-label sandbox credentials and known fixtures are provisioned. + +Sources: [White-label guide](https://docs.monerium.com/whitelabel), [API reference](https://docs.monerium.com/api). diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 4756b2b9f..47549bb5f 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -283,7 +283,11 @@ live; the status endpoint only hermetically — it needs a real recent transacti order creation/polling, fiat accounts and KYC status live behind pre-provisioned sandbox fixtures, see `.env.example`), Avenia/BRLA (quotes live with credentials only; limits/balances/account-info, pix-key validation, ordinary and hosted-liveness document target creation plus read-back through the consumed document GET schemas, and PIX pay-in ticket creation/listing behind a sandbox subaccount fixture; -payout tickets hermetically only — creating one live would move funds), and the CoinGecko +payout tickets hermetically only — creating one live would move funds), Monerium white-label API v2 +(profiles, linked addresses, IBANs, orders, files, and webhook subscriptions hermetically; prepared +read-only sandbox checks behind white-label credentials and fixture IDs; every persistent or +value-moving call behind its own `MONERIUM_CONTRACT_RUN_*` gate; excluded from `contracts.yml` +until credentials and known fixtures are provisioned), and the CoinGecko `simple/price` feed (schema in `apps/api/src/api/services/priceFeed.schemas.ts` — the price fake patches above the HTTP seam, so its hermetic half is fixture-based). Client methods with no production consumers are deliberately uncovered. Next per the PRD: milestone 5, warn-only diff --git a/docs/proposal-monerium-consumer-onramp.md b/docs/proposal-monerium-consumer-onramp.md new file mode 100644 index 000000000..114058b6f --- /dev/null +++ b/docs/proposal-monerium-consumer-onramp.md @@ -0,0 +1,303 @@ +> **Status (2026-08-26):** phase-2 proposal. The B2B variant of this design shipped +> (see [adr-0005-monerium-b2b-onramp.md](adr-0005-monerium-b2b-onramp.md)); this +> document describes the CONSUMER flow (Safe + passkey) that remains future work. Its +> architecture reviews and the B2B companion documents were absorbed into the +> maintained docs and live in git history. + +# PRD: Quoteless EUR → USDC (Ethereum) Onramp via Monerium Whitelabel + +**Version:** 2.0 (response to the architecture review; review and v1 in git history) +**Status:** Revised draft — awaiting re-review and external gates (§13) +**Date:** 2026-07-13 +**Owner:** Vortex team + +**Changes since v1 (for the re-reviewer):** + +- Trust model rewritten as scoped guarantees per lifecycle stage; the absolute "Vortex can never redirect funds" claim is retracted (F01, F02, F17). +- Monerium control-plane authority (`PATCH /ibans/{iban}` on bearer auth alone) verified against live docs and added as launch gate G1 (F01). +- One-signature claim corrected: provisioning is a trusted step, made verifiable via a published configuration manifest; no cryptographic-consent claim (F02, F03). +- Module topology fixed: one immutable singleton with Safe-keyed configuration, atomic initialization, Safe-only mutation (F04). +- Automatic EIP-1271 redeem validator **removed from v1**; recovery redesigned around a mandatory independent recovery owner (F05, F11). +- Keeper-supplied route calldata removed: the module constructs Uniswap calldata internally; generic router registry removed from v1; instant pause vs. timelocked expansion (F06). +- CoW removed from the "same interface" claim; deferred to a separate v2 specification (F07). +- ERC-4337 removed from v1 entirely; rescue path is plain `execTransaction` (F18, F05). +- Backend redesigned around persistent `MoneriumAccount` / `FiatDeposit` / `ConversionExecution` models with idempotency and per-Safe serialization; does not reuse the one-shot ramp state machine (F08, F13). +- Oracle math specified with raw-unit pseudocode, explicit USDC/USD assumption, weekend policy (F09). +- Fee finalized structurally: `feeBps` in per-account config (pilot = 0), immutable `MAX_FEE_BPS` and treasury in the singleton; I1 updated (F10). +- Liquidity caps reframed as availability parameters; `minOut` is the safety condition; reproducible measurement + monitoring required (F12). +- Incident, migration, compliance, dust, and destination-edge sections added (F14, F15, F16). +- Failure-mode corrections applied, incl. atomic-revert behavior on blacklisted destination (F17). +- Filled review response table embedded as Appendix A. + +--- + +## 1. Summary + +Vortex adds a new onramp: a user onboards once, receives a **dedicated virtual IBAN** (issued by Monerium under Vortex's whitelabel integration) linked to a **user-owned Safe** on Ethereum. EUR wired to that IBAN is minted as EURe into the Safe and automatically converted to USDC and forwarded to a **destination address fixed at onboarding** — no per-transfer quote, signature, or interaction. + +**Security posture (honest version):** this design does *not* claim Vortex can never touch user funds. It provides **scoped guarantees per lifecycle stage** (§4): before mint, Vortex and Monerium are trusted parties with defined, monitored, contractually constrained authority; after mint, an immutable on-chain policy limits Vortex's authority to executing a fixed conversion, pausing it, and tuning bounded availability parameters — it cannot redirect minted principal outside the enumerated policy, under the stated assumptions. + +## 2. Scope (reduced v1) + +**In scope:** + +- Personal Monerium profiles only; **newly onboarded users only** (legacy migration deferred). +- Ethereum mainnet only. +- One swap route: EURe V2 → EURC → USDC via one pinned Uniswap v3 router; calldata constructed by the module. +- One destination per account, set at onboarding; changeable only by the Safe's owners. +- Explicit minimum deposit and processing SLA (§10.3). +- Mandatory independent recovery owner (§8). +- Zero on-chain fee for the pilot; fee structure finalized in the contract regardless (§9). + +**Out of scope for v1** (each requires its own future spec): offramp/automated redeem, CoW or any aggregator, ERC-4337, corporate profiles, legacy-user migration, other chains, per-transfer destinations, memo-routing features. + +## 3. Verified external facts + +Re-verify all before build; dates note when checked. + +- **Monerium link message** (2026-07-13): fixed string `"I hereby declare that I am the address owner."`; smart-contract accounts validated via on-chain EIP-1271 `isValidSignature(bytes32,bytes)`; contract must be deployed at validation time (no ERC-6492 documented); linking is per-chain. Redeem orders also accept EIP-1271. ([docs.monerium.com/oauth/#eip-1271](https://docs.monerium.com/oauth/#eip-1271)) +- **Monerium control plane** (2026-07-13, **F01 verified**): `PATCH /ibans/{iban}` — "Move an existing IBAN to a specified address an chain. All incoming EUR payments will automatically be routed to the address on that chain." Authorization: **API client bearer token only**; no signature from the currently linked address. `POST /addresses` requires a signature only from the **new** address's owner. Consequence: whoever holds Vortex's whitelabel credentials can redirect future mints. Memo-based routing was **not** found in current docs (review's sub-claim unconfirmed). ([docs.monerium.com/api](https://docs.monerium.com/api)) +- **Safe passkeys**: WebAuthn credentials as Safe owners via Safe's WebAuthn signer contracts; Ethereum mainnet has the EIP-7951 P-256 precompile since Fusaka (Dec 2025). Exact signer contracts, addresses, and gas to be pinned and benchmarked in spike G0. +- **Liquidity snapshot** (2026-07-10; see methodology caveat §7.4): no meaningful direct EURe/USDC pool on Ethereum. Route: EURe/EURC Uniswap v3 0.05% (`0x2a817bd5018f9782f84398067639230121e07d4c`, ~$104k TVL — bottleneck) → EURC/USDC 0.05% (`0x95dbb3c7546f22bce375900abfdd64a4e5bd73d6`, >$5M TVL). Aggregator quotes: ~spot at 10k EURe; ~3.6% impact at 50k via pure AMM. These are **snapshots, not durable bounds** (F12). +- **EURe V2 (Ethereum)**: `0x39b8B6385416f4cA36a20319F70D28621895279D`. V1 (`0x3231Cb...273f`) is deprecated; several stale V1 pools still show TVL and must be excluded from routing and tests. + +## 4. Trust model — scoped guarantees by lifecycle stage + +Replaces v1 §4/§8.3. Every user-facing security claim must be traceable to one row. + +| Stage | Guarantee | Trusted dependencies | Vortex's authority | Excluded / residual | +|---|---|---|---|---| +| **S0 Provisioning** (onboarding) | Deployed account configuration is **verifiable** against a published manifest before first deposit; fraud is detectable, not cryptographically prevented | Vortex frontend + backend + deployment path at time of onboarding; Safe & signer contracts as audited | Full (Vortex constructs the account) | A compromised provisioning pipeline can deploy a hostile account. Mitigation: manifest + independent verifier (§6.4); no cryptographic consent claim is made (F03) | +| **S1 Fiat ingress** (bank → Monerium → mint) | Deposits mint to the linked Safe **while the IBAN association is unchanged**; association changes are monitored and alarmed | Monerium (regulated EMI); **Vortex's Monerium API credentials** (F01) | Can re-associate the IBAN via `PATCH /ibans` (bearer token only) → redirect *future* mints, absent Monerium-side controls (gate G1) | Monerium insolvency/compliance action; credential theft. Mitigations: G1 contractual/technical pinning, credential isolation (HSM/scoped tokens if available), continuous association monitoring + user alert + pause | +| **S2 On-chain conversion** (EURe in Safe → USDC) | Under assumptions A1–A4 (below): minted assets cannot leave the Safe except (a) into the fixed swap returning ≥ `minOut` USDC to the Safe, (b) USDC to `destination`, (c) fee ≤ `feeBps` (pilot 0) to the immutable treasury. Max adverse extraction per swap relative to the oracle model = slippage margin + configured fee | Chainlink EUR/USD (A1); EURe/EURC/USDC token contracts behave as modeled, incl. issuer powers (A2); audited Safe + module code (A3); USDC/USD ≈ 1 within the slippage margin (A4) | Execute the fixed policy; pause instantly; tune availability params within immutable bounds (§7.3); nothing else | Oracle compromise, stablecoin depeg beyond margin, token-issuer freeze/blacklist, undiscovered contract bugs. Not covered: unrelated assets/approvals the user adds to the Safe (§7.2) | +| **S3 Delivery** | USDC reaches `destination` exactly as forwarded | Destination remains valid, non-blacklisted, and accessible to the user | None (cannot change destination) | Destination attestation is legal, not cryptographic (§6.3); exchange address rotation, blacklisting (§10.4) | +| **S4 Recovery / exit** | The user can always exit with assets using their owners (passkey and/or recovery owner) without Vortex's API, given public tooling + any funded relayer | User retains ≥1 owner credential; Ethereum RPC access | None (cannot block `execTransaction`) | Passkey RP-ID depends on Vortex's domain (§8.2); loss of **all** owner credentials strands user-initiated actions (automation continues) | + +**Liveness vs. safety:** Vortex can always *fail to act* (keeper down, pause engaged, Monerium relationship terminated). Liveness failures leave funds as EURe in the user's Safe (S2) or as unminted fiat claims at Monerium (S1); they do not move assets. + +## 5. End-to-end flows + +### 5.1 Onboarding + +1. Vortex-branded KYC (personal profiles only), submitted to Monerium via whitelabel API; approval via webhook. +2. **Passkey creation.** RP ID = Vortex's apex domain (pinned in docs); credential required to be discoverable and backup-eligible (enforced via WebAuthn `residentKey: required`, attestation-checked where possible). Documented explicitly: sync is typical, not guaranteed (F17.5). +3. **Recovery owner setup (mandatory, F11).** User chooses: second passkey on another device, an existing EOA/hardware wallet, or a **printable one-time recovery key** (EOA generated client-side, shown once, never stored by Vortex). Safe owners = [WebAuthnSigner, recoveryOwner], threshold 1. +4. **Destination collection.** Validation per §10.4; user attests ownership of the destination (checkbox + legal language — this is *legal consent*, not cryptographic proof; F03). +5. **Atomic deployment** via canonical Safe components (§6.1): proxy factory → Safe setup with a minimal audited setup library that enables `VortexSwapModule` and calls `initialize(destination, feeBps)` in the same transaction. Vortex pays gas. +6. **Manifest publication + verification (§6.4).** Onboarding halts unless the independent verifier confirms the deployed account matches the manifest. +7. **The Monerium link signature**: passkey signs the fixed link message; validated via the Safe's EIP-1271 (CompatibilityFallbackHandler → WebAuthn signer). This is the single signature the *flow* requires; the recovery setup may involve its own ceremony. "One signature" is a UX goal for the Monerium step, not a security claim (F03). +8. Vortex calls `POST /addresses` (chain: ethereum); Monerium issues the IBAN. +9. **Disclosure screen**: fee rule, rate basis (Chainlink EUR/USD ± slippage bound), minimum deposit, processing SLA, weekend behavior, failure behavior, S0–S4 trust summary in plain language. + +### 5.2 Steady-state deposit + +1. User wires EUR (SEPA / SEPA Instant) to their IBAN. Monerium mints EURe (V2) to the Safe. +2. Backend ingests the Monerium webhook (HMAC-verified, deduplicated; §11.2) and/or the on-chain Transfer watcher; records a `FiatDeposit`. +3. Keeper calls `swapAndForward(safe)` (§7.1) under a per-Safe database lock; submits via private orderflow (Flashbots Protect). +4. On confirmed execution: record `ConversionExecution`, allocate output to deposits (§11.4), notify the user with amounts and tx hash (notification correction path per §11.3). + +### 5.3 Exit and recovery + +- Any owner (passkey or recovery owner) can execute arbitrary Safe transactions via plain `execTransaction` — withdraw, disable the module, change `destination`, or sign a Monerium redeem order (EIP-1271). No ERC-4337 in v1: Vortex relays owner-signed transactions and pays gas; **independently**, any funded account can submit `execTransaction` with valid owner signatures. +- **Disaster-recovery package (mandatory deliverable, F11):** public, versioned tooling that reconstructs the account from chain data + manifest, produces the WebAuthn assertion under the correct RP ID (requires the RP domain — see §8.2), builds and submits `execTransaction` against any RPC, without any Vortex service. Tested in CI against a fork. +- **Passkey loss:** automation continues (keeper needs no user signature); the recovery owner restores user control. Loss of **both** owners: automation still delivers future deposits to `destination`; stranded EURe (paused route) is unrecoverable — disclosed at onboarding. + +## 6. Account provisioning + +### 6.1 Components (exact pins required before audit) + +- Canonical Safe v1.4.1: singleton, `SafeProxyFactory`, `CompatibilityFallbackHandler` — pinned by address **and runtime hash** in the manifest schema. No custom factory; deployment uses `createProxyWithNonce` + a minimal audited `VortexSetupLib` (delegatecalled from Safe `setup`) whose only job is `enableModule` + `module.initialize` (F04 Q4: the custom surface is one small library, not a factory). +- Safe WebAuthn signer contracts (shared verifier or per-user signer proxy — decide in G0 with gas benchmarks; EIP-7951 path preferred). +- Fallback handler is the canonical `CompatibilityFallbackHandler` **only** (needed for EIP-1271 link validation). No 4337 module, no custom handlers (F05, F18). + +### 6.2 `VortexSwapModule` topology (F04 — Option B) + +One immutable singleton deployment; per-Safe configuration in storage. + +```solidity +// Immutable (constructor): EURE, EURC, USDC, UNISWAP_ROUTER, ORACLE, ORACLE_DECIMALS, +// MAX_ORACLE_AGE, SLIPPAGE_BPS, MAX_FEE_BPS, FEE_RECIPIENT, PATH (EURe -0.05%- EURC -0.05%- USDC), +// MIN_SWAP_FLOOR, CAP_CEILING, LIVENESS_FALLBACK_DELAY +// Storage: +// struct Config { address destination; uint16 feeBps; uint64 initializedAt; bool userPaused; } +// mapping(address safe => Config) config; +// Ops params (bounded): minSwapAmount ∈ [MIN_SWAP_FLOOR, ...], perSwapCap ∈ [..., CAP_CEILING]; +// globalPaused; guardian (Vortex ops multisig); paramTimelock. +``` + +- `initialize(destination, feeBps)`: callable once per Safe, **only** with `msg.sender == safe` (holds during atomic setup: the setup library runs in the Safe's context and the module sees the Safe proxy as caller). `feeBps ≤ MAX_FEE_BPS`. Reverts on re-init. Not front-runnable: config is keyed by `msg.sender`, so only the Safe can create its own entry. +- `setDestination(addr)` / `setUserPaused(bool)`: `msg.sender == safe` only (i.e., an owner-signed Safe transaction). `feeBps` immutable after init. +- Every mutation emits events with a config version counter (F04, F14). + +### 6.3 Destination semantics + +Set at onboarding, part of the manifest and the disclosure. Changeable only via the Safe (S3). Ownership attestation is legal, not cryptographic — requiring a signature from the destination key would contradict the wallet-less UX and is explicitly not claimed (F03). + +### 6.4 Configuration manifest and verification (F02) + +Per account, a versioned JSON manifest: chain ID; Safe address; singleton + proxy factory + fallback handler addresses and runtime hashes; owners and threshold; enabled modules; module address, runtime hash, and full config (destination, feeBps); signer contract coordinates; oracle and router addresses; setup tx hash. Published to a public transparency log (repo + API). An **independent verifier** (open-source script, runnable by anyone against public RPC) checks live chain state against the manifest; onboarding blocks on it, and it re-runs continuously with alerting. This makes provisioning fraud *detectable before first deposit* — the claim stops there. + +## 7. Conversion policy (on-chain) + +### 7.1 `swapAndForward(safe)` + +Caller: authorized keeper set; **permissionless fallback** — anyone may call once the Safe's EURe balance has exceeded `minSwapAmount` for longer than `LIVENESS_FALLBACK_DELAY` (proposal: 24h). Timing-grief within the slippage bound is accepted and bounded (F06 Q, review §6.4). + +Atomic sequence (reverts as a unit; reentrancy-guarded; all external calls `CALL` with `value == 0`; safe-ERC20 handling for return values): + +1. Require: not `globalPaused`, not `userPaused`, config initialized. +2. `balance = EURE.balanceOf(safe)`; require `balance ≥ minSwapAmount`; `amountIn = min(balance, perSwapCap)`. +3. Compute `minOut` (§7.3). +4. Via `execTransactionFromModule` (CALL only): `EURE.approve(UNISWAP_ROUTER, amountIn)` (force-approve pattern). +5. Via `execTransactionFromModule`: `UNISWAP_ROUTER.exactInput({path: PATH, recipient: safe, amountIn, amountOutMinimum: minOut})` — **calldata constructed entirely by the module** (F06); path, router, recipient hard-pinned; deadline semantics per the pinned router version (SwapRouter takes an explicit deadline — set `block.timestamp`; SwapRouter02 omits it — decide at pin time in G0). +6. Verify: EURe allowance to router == 0 (reset if router pulled less; then also verify EURe delta == amount actually swapped), `usdcDelta = USDC.balanceOf(safe) − pre ≥ minOut`. +7. `fee = usdcDelta × feeBps / 10_000` → `FEE_RECIPIENT` (pilot: 0); remaining full USDC balance → `config.destination`. If the destination transfer reverts (e.g. Circle blacklist), **the whole execution reverts and funds remain EURe** (F17.1). +8. Emit `SwapExecuted(safe, amountIn, usdcOut, fee, roundId)`. + +Scope statement (F06): the guarantee covers **EURe and USDC in a dedicated Safe provisioned by this flow**. Assets or approvals the user independently adds to the Safe are outside the policy's protection (the module never touches them, but a future user-granted allowance is the user's own act). + +### 7.2 What was removed (F06, F07) + +No keeper-supplied calldata, no selector allowlists, no generic router registry, no aggregators, no CoW. Route governance in v1 is binary: the single pinned route can be **paused instantly** by the guardian (protective, instant) ; any expansion (new route/module version) is a new deployment + per-user owner-authorized migration (§12) — i.e., additions are maximally slow, removals are instant. + +### 7.3 Oracle math (F09) + +```text +(roundId, answer, , updatedAt, ) = ORACLE.latestRoundData() +require(answer > 0 && updatedAt != 0) +require(block.timestamp − updatedAt ≤ MAX_ORACLE_AGE) // immutable ceiling +// EURe: 18 dec; ORACLE_DECIMALS: read once at deploy (expect 8); USDC: 6 dec +// scale = 10^(18 + ORACLE_DECIMALS − 6) → 10^20 for an 8-dec feed +minOut = mulDiv(amountIn, uint256(answer) × (10_000 − SLIPPAGE_BPS), + 10 ** (12 + ORACLE_DECIMALS) × 10_000) // floor; conservative direction, error < 1 unit +``` + +- **A4 stated:** USDC/USD is assumed 1.0; the SLIPPAGE_BPS margin absorbs both stablecoin bases. USDC below the margin ⇒ swaps revert (protective). No USDC/USD feed in v1 (documented decision; revisit if margin proves tight). +- **Weekend policy:** Chainlink FX feeds hold the last market price outside trading hours. Verify in G0 whether heartbeat updates continue (staleness passes) or stop (swaps revert Fri→Mon). If they continue: execute normally — the slippage margin has historically absorbed weekend EUR/USD gaps — but keeper policy defers swaps above a size threshold to market hours; disclosed. If they stop: deposits queue until Monday; SLA disclosure reflects it. +- Loss statement (F09): *the swap cannot deliver less than `(1 − SLIPPAGE_BPS)` of the oracle-model value, assuming an honest oracle and modeled token behavior.* This is not a principal bound under oracle or stablecoin failure (see S2 assumptions). + +### 7.4 Liquidity: measurement, caps, monitoring (F12) + +- `minOut` is the **safety** condition. `perSwapCap` / `minSwapAmount` are **availability** parameters (bounded by immutable floor/ceiling; lowering instant, raising behind the ops timelock). +- Launch methodology: record block-numbered `QuoterV2` static-call quotes at {1k, 5k, 10k, 25k} EURe plus active-tick liquidity for both pools; archive parameters for reproducibility. TVL alone never justifies raising caps. +- Continuous monitoring: executable quote at `perSwapCap` vs oracle; alert and auto-engage keeper pause when impact at `minSwapAmount` exceeds SLIPPAGE_BPS for a sustained period (launch/pause thresholds defined in runbook). +- Rapid successive executions beyond depth **revert on `minOut`** (availability loss, keeper gas waste — not fund loss); pacing is keeper policy, best-effort, and documented as such. + +## 8. Keys and recovery + +### 8.1 Owners + +Safe owners: `[SafeWebAuthnSigner(passkey), recoveryOwner]`, threshold 1. Either owner has full unilateral control — both are user-controlled; this is disclosed. Vortex holds no owner key. + +### 8.2 RP-ID dependence (F11) + +The passkey works only via an origin under Vortex's RP ID. Mitigations: (a) the mandatory recovery owner is RP-independent (EOA/hardware/second passkey); (b) the disaster-recovery package includes a static, self-hostable page for the RP domain, and Vortex commits to a domain-continuity plan (registrar lock, escrowed transfer instructions) — documented limitation, not fully eliminable; (c) credentials required discoverable + backup-eligible. + +### 8.3 Removed from v1 (F05) + +The automatic EIP-1271 redeem validator ("redeem to pinned refund IBAN without user signature") is removed: it was wrong-layered (EIP-1271 lives in the fallback handler, not a module), required a bespoke signature-encoding protocol, risked weakening link-message validation, and gave Vortex unilateral disposal authority that changes the custody analysis. Stranded-EURe recovery in v1 = owner-signed redeem or withdrawal. Residual: loss of both owners + paused route strands EURe (disclosed; accepted). + +## 9. Fees (F10) + +- Structure finalized now: per-account `feeBps` set at `initialize`, **immutable thereafter**; global immutable `MAX_FEE_BPS` (proposal: 100) and immutable `FEE_RECIPIENT` in the singleton. Fee assessed per execution on gross swap output; batching therefore charges each batched deposit pro-rata by construction (§11.4). +- **Pilot: `feeBps = 0`** (loss-leader; Vortex pays deployment + keeper gas). GA fee value is a business decision (OQ1) — changing it means new accounts get a different `feeBps`; existing accounts keep theirs. +- I1 (S2 guarantee) enumerates the treasury as a permitted recipient bounded by `feeBps ≤ MAX_FEE_BPS`. Disclosure separates fee (deterministic) from slippage bound (worst-case market execution). + +## 10. Product behavior: minimums, dust, destinations (F16) + +### 10.1 Minimum deposit & SLA + +User-facing: deposits ≥ €25 convert within a stated SLA (proposal: 1 business hour under normal conditions; next FX market open under the weekend policy). Deposits < €25 **accumulate** until the threshold is crossed; always recoverable via owner-signed exit. `minSwapAmount` is gas-responsive within its immutable floor. + +### 10.2 Gas griefing + +Many small SEPA transfers can force keeper gas. Bounded by: min threshold + natural batching (balance sweep), sender is a KYC'd bank customer (low realistic abuse), and per-account keeper budget alerts. Vortex cannot prevent inbound SEPA to an issued IBAN; accepted operational cost. + +### 10.3 Batching + +Deposits arriving before conversion batch naturally (module sweeps balance). Allocation rule in §11.4; batching latency covered by the SLA disclosure. + +### 10.4 Destination validation + +At onboarding: EIP-55 checksum; deny zero/dead addresses, precompiles, the Safe itself, the module, the router, known token contracts; warn for contract destinations (recoverability unprovable) and exchange deposit addresses (rotation, minimum-deposit thresholds — user attests awareness); sanctions/blacklist screen at onboarding and **periodic re-screening** with conversion pause + user notification on a hit (F15, F16). Blacklisted destination at execution time ⇒ atomic revert, funds stay EURe (§7.1.7). + +## 11. Backend (F08, F13) + +### 11.1 Data model — replaces the one-shot ramp machine for this product + +The existing `PhaseProcessor` is **not** reused: its documented non-atomic multi-instance lock (spec finding F-003) and retry-exhaustion gap (F-004) are unacceptable for a permanent, repeatedly funded account. + +```text +MoneriumAccount: profileId, iban, safeAddress, configVersion, status (onboarding|active|suspended|closed), complianceState +FiatDeposit: moneriumOrderId (unique), amount, currency, paymentStatus, mintTx {chainId, txHash, logIndex, blockHash}, complianceStatus +ConversionExecution: safeAddress, includedDepositIds[], eureIn, usdcGross, fee, usdcNet, destination, txHash, status, error +``` + +Idempotency keys: `moneriumOrderId` (accounting identity) and `(chainId, txHash, logIndex)` (on-chain identity). **On-chain balance is the execution-safety source; Monerium order IDs are the accounting source.** + +### 11.2 Webhooks + +HMAC verification over raw request bytes, constant-time compare, timestamp/replay window, persisted webhook-ID dedup, immediate `200` + async processing, out-of-order tolerance (state machine on `FiatDeposit.paymentStatus` accepts only forward transitions). + +### 11.3 Chain handling + +Confirmation policy: detect mints at 1 confirmation; execution reads live balance (safety source) so reorged mints self-correct; user **notifications** only after the execution tx reaches N confirmations (proposal: 32 blocks) with block-hash re-verification; a reorg after notification triggers a correction notice. Keeper: unique nonce manager, stale private-relay tx replacement policy. + +### 11.4 Concurrency & attribution + +Per-Safe serialization via Postgres advisory lock (`pg_advisory_xact_lock(hash(safeAddress))`) — contract reentrancy guards do not serialize separate keeper processes (F13). Batched output allocation: pro-rata by deposit amount, floor to 6 dp, remainder to the largest deposit — deterministic and auditable; fees allocated identically. + +### 11.5 Partner/API surface (F08 Q) + +The public API exposes `MoneriumAccount` (long-lived) and per-deposit `FiatDeposit`/`ConversionExecution` objects with webhooks per deposit — **not** one eternal ramp object. + +## 12. Incidents and migration (F14) + +- **Pause:** guardian pauses globally or per-account **instantly** (protective-only action). Unpause instant (config is user/immutable-controlled, so unpause cannot enact a hostile change). +- **02:00-UTC module vulnerability runbook:** pause all → ask Monerium to suspend affected IBANs (capability to be confirmed in MSA — G1) → notify users to stop sending EUR (email/app + status page) → assess → ship migration. +- **Migration:** deploy new module singleton (new audit) → each user authorizes `enableModule(new)` + `disableModule(old)` via an owner signature (relayed by Vortex; also possible via the DR package). The Safe address — and therefore the IBAN link — **does not change**, avoiding F01's re-association path. Users who never migrate keep the paused old module; funds remain owner-recoverable. +- Module/config version discovery: on-chain events + manifest log. Old-version support/sunset policy published. +- Users who lost all owners: automation (if unpaused) still forwards; otherwise funds sit; no backdoor exists by design — disclosed. + +## 13. Launch gates (external dependencies) + +- **G0 — Technical spike:** Monerium sandbox E2E (deploy → EIP-1271 link → mint → swap on fork); pin Safe/WebAuthn contracts + gas benchmark (EIP-7951 path); confirm router version & deadline semantics; confirm Chainlink EUR/USD feed address, decimals, heartbeat, and **weekend update behavior**; reproducible liquidity baseline (§7.4). +- **G1 — Monerium MSA (blocking for the S1 claim, F01):** written + sandbox-verified answers on: authorization required for `PATCH /ibans` and `POST /addresses` on whitelabel profiles; whether an IBAN/profile can be locked to a single non-movable address absent end-user authorization; credential scoping; association-change event feed; per-IBAN suspension capability; SEPA recall/fraud loss allocation **after** conversion+forwarding; behavior of conversions during profile review/suspension. If pinning is unavailable: launch is still possible with the S1 trust statement as written (Vortex trusted pre-mint) + monitoring — a product/legal decision to make explicitly. +- **G2 — Legal/compliance sign-off (F15):** custody analysis covering module authority **and** Monerium control-plane authority; MiCA scoping; DPA/controller-processor roles with Monerium; retention/access table and data-flow diagram; sanctions screening procedure; disclosure texts. +- **G3 — Audit:** contracts (module + setup lib) with invariant/fuzz suites covering §7.1 post-conditions, init front-running, pause semantics, oracle edge cases, V1-token poisoning; DR package tested on fork. +- **G4 — Pilot:** invite-only, personal profiles, `feeBps = 0`, `perSwapCap` conservative (≈ €5–10k), €1k/user/day operational limit, full monitoring live. + +## 14. Open questions (reduced) + +- **OQ1** — GA fee value (structure is finalized; §9). +- **OQ2** — `LIVENESS_FALLBACK_DELAY`, SLA numbers, cap/threshold launch values (G0 data). +- **OQ3** — Weekend policy final form (depends on G0 feed behavior). +- **OQ4** — G1 outcome: does the S1 statement get upgraded (Monerium pinning) or stay trust-based? +- **OQ5** — Recovery-owner UX default (second passkey vs printable key as the recommended path). + +--- + +## Appendix A — Response to architecture review findings + +| ID | Disposition | Response / change | +|---|---|---| +| F01 | **Accept** (independently verified) | `PATCH /ibans` bearer-auth redirect confirmed against live docs; memo-routing sub-claim not found in current docs (noted, immaterial). Trust model rewritten (S1); Monerium controls made launch gate G1; association monitoring + credential isolation added. Absolute non-custody claim retracted | +| F02 | **Accept** | §8.3 replaced by per-stage guarantee matrix (§4); versioned config manifest + independent verifier + continuous re-verification (§6.4) | +| F03 | **Modify** | Finding accepted: link signature binds nothing beyond address ownership; "implicitly ratifies" retracted. Resolution = review's Option 3 (trusted provisioning, stated plainly) + manifest verification. Review's Option 1 (extra EIP-712 passkey signature) **rejected as a trust upgrade**: WebAuthn lacks what-you-see-is-what-you-sign, so under a compromised frontend it yields an audit artifact, not consent — equivalent to Option 3 in the threat model it targets. Destination attestation is legal consent (§6.3). "One signature" demoted to UX description (§5.1.7) | +| F04 | **Accept** | Option B selected: immutable singleton + Safe-keyed config, `initialize` once with `msg.sender == safe` (atomic via setup library; keyed-by-caller ⇒ not front-runnable), `setDestination` Safe-only, `feeBps` immutable post-init, versioned events (§6.2). Safe v1.4.1 pinned by address + runtime hash; custom factory dropped for canonical factory + minimal setup lib | +| F05 | **Accept** | Correct on all points (module ≠ fallback handler; hash-only 1271 input; IBAN canonicalization; replay; link-message weakening; unilateral-disposal custody impact). Auto-redeem validator removed from v1 (§8.3); recovery = mandatory independent owner + owner-signed redeems. Fallback handler = canonical CompatibilityFallbackHandler only | +| F06 | **Accept resolution; correct one argument** | v1: module constructs all calldata; pinned router/path/recipient; no keeper calldata; no registry; guarantee scoped to EURe/USDC in the dedicated Safe; instant pause vs deployment-grade additions (§7.1–7.2). Correction: balance-delta post-conditions already defeat recipient/path redirection of swap output atomically — the genuine residual was other assets/approvals and nested calls, which the scoping + internal-calldata resolution addresses | +| F07 | **Accept** | CoW removed from same-interface claim; deferred to a standalone v2 spec with its own lifecycle/threat model (order authorization, watchtower, settlement-time 1271, partial fills, fallback-handler coexistence) | +| F08 | **Accept** (verified in-repo) | Spec findings F-003/F-004 confirmed in `docs/security-spec/03-ramp-engine/state-machine.md`. New persistent model (§11.1), idempotency keys, advisory-lock serialization, per-deposit API objects (§11.5). New security-spec file required per repo sync rule; legacy `05-integrations/monerium.md` superseded-by link | +| F09 | **Accept** | Raw-unit pseudocode with `10^(12+ORACLE_DECIMALS)` scaling (=10^20 at 8 dec), read-once decimals, staleness ceiling, floor rounding, explicit A4 (USDC/USD = 1 within margin), weekend policy pending G0 feed-behavior check; loss statement rewritten as oracle-model-relative (§7.3) | +| F10 | **Accept** | Fee structurally finalized: per-account immutable `feeBps` (pilot 0), immutable `MAX_FEE_BPS` + treasury; I1/S2 updated to enumerate the fee recipient; per-execution assessment with pro-rata batch allocation (§9, §11.4) | +| F11 | **Accept** | RP-ID dependence acknowledged (§8.2). Independent recovery owner **mandatory** at onboarding (§5.1.3); DR package (Vortex-independent, fork-tested, incl. self-hostable RP page) a launch deliverable; domain-continuity plan documented; credentials discoverable + backup-eligible required | +| F12 | **Accept resolution; correct one argument** | Caps reframed as availability parameters; `minOut` is the safety condition; block-numbered reproducible quoting methodology + continuous executable-depth monitoring + pause thresholds (§7.4). Correction: rapid cap-sized executions revert on `minOut` rather than execute at bad prices — availability/gas loss, not fund loss | +| F13 | **Accept** | Full webhook (HMAC/dedup/replay), confirmation/reorg, nonce, advisory-lock, and allocation spec added (§11.2–11.4); balance = safety source, order IDs = accounting source | +| F14 | **Accept** | Instant guardian pause; incident runbook incl. Monerium IBAN suspension (G1 question); owner-authorized module migration that **keeps the Safe address** (avoids F01 re-association); version discovery; sunset policy (§12) | +| F15 | **Accept** | v1 = personal, newly onboarded only; G2 gate for legal/DPA/retention/sanctions; SEPA-recall loss allocation moved into G1 MSA questions; destination re-screening added (§10.4) | +| F16 | **Accept** | Min deposit + accumulation + SLA disclosure (§10.1); gas-griefing bounded and accepted (§10.2); destination validation/denylist/warnings/re-screening (§10.4). Noted: griefing realism is low (KYC'd bank senders) but policy specified regardless | +| F17 | **Accept** | All six corrections applied: atomic revert on blacklisted destination (funds remain EURe); "Vortex executes only the constrained policy"; withhold/censor language scoped; extraction bound restated as oracle-model-relative incl. fee; passkey-sync nuance; EIP-7951 treated as live with G0 benchmarking of the pinned implementation | +| F18 | **Accept** | v1 composition cut to: canonical Safe + passkey signer + recovery owner + one module (internal calldata) + canonical fallback handler. Removed: 4337, custom factory, generic registry, aggregator calldata, CoW, auto-redeem validator, mutable fees. Matches review §6 with one divergence: module topology is Option B (singleton+config) rather than per-Safe clones — one audited deployment, no per-user bytecode, equivalent immutability of logic | + +**Requested end-to-end statement (review §8):** the composition now supports this security statement — *"Once EURe is minted to the user's Safe, Vortex's total authority is: execute the fixed EURe→USDC→destination conversion within an oracle-checked slippage bound and a disclosed fee; pause it; and tune bounded availability parameters. Redirecting or extracting minted principal beyond the slippage margin + fee requires breaking a stated assumption (oracle integrity, token-contract behavior, audited-code correctness) — not merely abusing any authority Vortex holds. Before mint, Vortex and Monerium hold monitored, contractually constrained, but real authority over deposit routing; users are told so."* diff --git a/docs/security-spec/02-signing-keys/server-side-signing.md b/docs/security-spec/02-signing-keys/server-side-signing.md index 5816b3f92..b733926a0 100644 --- a/docs/security-spec/02-signing-keys/server-side-signing.md +++ b/docs/security-spec/02-signing-keys/server-side-signing.md @@ -20,7 +20,7 @@ All keys are loaded from environment variables. There is no HSM, secrets manager 6. **Missing mandatory keys MUST prevent server startup** — If `PENDULUM_FUNDING_SEED` or the currently required legacy-named `MOONBEAM_EXECUTOR_PRIVATE_KEY` compatibility fallback are absent, startup validation fails. This requirement reflects general EVM configuration compatibility, not active Moonbeam execution. 7. **The CryptoService singleton MUST initialize keys exactly once** — `initializeKeys()` should be called once at startup. Repeated calls should be idempotent or rejected. 8. **Webhook signatures MUST bind the delivery timestamp** — `X-Vortex-Signature` is computed over `` `${timestamp}.${body}` `` where `timestamp` is the value of the `X-Vortex-Timestamp` header (unix seconds). Consumers verify against that exact string, reject timestamps outside a bounded window, and deduplicate on the payload's `eventId`, which is unique per event and stable across delivery retries. A signature over the body alone MUST NOT verify. -9. **Every webhook row MUST have an owner principal** — the partner behind a partner-scoped secret key or the user behind a user-scoped secret key (`webhooks.partner_id` / `webhooks.user_id`). Registering a webhook for a quote requires that the owner principal owns the quote (`quote_tickets.partner_id` / `user_id` match); a foreign quote returns the same 404 as a nonexistent one. Deletion is owner-scoped with a uniform 404 for foreign IDs. Delivery matching filters webhooks by the quote's owner, so session-scoped subscriptions cannot receive another tenant's events. Ownerless rows are unrepresentable: migration 056 deletes any pre-existing rows (there were none in production) and a CHECK constraint requires exactly one of `partner_id`/`user_id`, so the delivery matcher has no ownerless branch — one would match every quote and reopen the cross-tenant hole for exactly the rows an attacker could have planted before ownership existed. An event whose quote owner cannot be resolved is delivered to nobody. +9. **Every webhook row MUST have an owner principal** — the partner behind a partner-scoped secret key or the user behind a user-scoped secret key (`webhooks.partner_id` / `webhooks.user_id`). Registering a webhook for a quote requires that the owner principal owns the quote (`quote_tickets.partner_id` / `user_id` match); a foreign quote returns the same 404 as a nonexistent one. Deletion is owner-scoped with a uniform 404 for foreign IDs. Delivery matching filters webhooks by the quote's owner, so session-scoped subscriptions cannot receive another tenant's events. Ownerless rows are unrepresentable: migration 056 deletes any pre-existing rows (there were none in production) and a CHECK constraint requires exactly one of `partner_id`/`user_id`, so the delivery matcher has no ownerless branch — one would match every quote and reopen the cross-tenant hole for exactly the rows an attacker could have planted before ownership existed. An event whose quote owner cannot be resolved is delivered to nobody. The account-scoped deposit-event family (`DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED`) follows the same principle with a different owner derivation: subscriptions are user-owned only (registration rejects a partner credential, a quote/session target, and any mix with transaction events), and delivery matches exclusively `webhooks.user_id = `, resolved from `monerium_accounts.vortex_profile_id` through the active `managed_profiles` relationship (`webhook.service.ts findAccountEventWebhooks`, `monerium-b2b/manager-events.ts`). An account without a resolvable controlling manager delivers to nobody. These deliveries go through the durable `webhook_deliveries` outbox (unique per webhook and event, claim-based dispatch with backoff) rather than the in-process retry loop, and a failing endpoint is never auto-deactivated. 10. **Webhook callback URLs MUST NOT reach internal infrastructure (SSRF)** — registration accepts only HTTPS URLs without embedded credentials, rejects IP-literal hosts outside publicly routable space, and resolves the hostname — rejecting it if it resolves to a non-public address (a host that does not resolve yet is allowed, since DNS is often provisioned after integration setup and delivery re-validates anyway). Before every delivery the hostname is re-resolved and every resolved address must be public; redirects are rejected (`redirect: "error"`). Address classification follows the IANA special-purpose registries for both IPv4 and IPv6, so documentation/benchmarking/6to4/site-local ranges are treated as non-public. **Residual risk (accepted):** a resolve-then-connect race remains — the guard and `fetch` resolve independently, so a DNS-rebinding attacker controlling the domain can answer differently for each. Closing it requires pinning the validated address for the connection (preserving Host/SNI) or an egress proxy enforcing destination policy; tracked as follow-up. Exploitation requires an authenticated secret key, and deliveries are POSTs whose response body is never returned to the registrant (blind SSRF). ## Threat Vectors & Mitigations diff --git a/docs/security-spec/05-integrations/monerium-b2b.md b/docs/security-spec/05-integrations/monerium-b2b.md new file mode 100644 index 000000000..395284b0f --- /dev/null +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -0,0 +1,99 @@ +# Monerium B2B Whitelabel Onramp + +## What This Does + +The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each corporate client a Monerium IBAN linked to a per-client `VortexForwarder` contract. SEPA deposits mint EURe to the forwarder; a keeper later swaps and forwards USDC to the client's destination. This spec covers the backend integration built in `apps/api/src/api/services/monerium-b2b/`: the whitelabel API client, the attestor signature for address linking, the webhook receiver with durable inbox, the deposit processor, the managed-profile account mapping, and the onboarding automation. It is deliberately NOT part of the one-shot ramp state machine — accounts are persistent and repeatedly funded. Each account is owned by a Vortex **managed profile** (`monerium_accounts.vortex_profile_id`): the corporate is onboarded and KYB-approved on Monerium's side under the partner's KYC reliance, then mapped into Vortex as a managed child with an approved `provider_customers`/`kyc_cases` mirror. + +**Provider type:** on-ramp (EUR → USDC) +**Fiat currencies:** EUR +**Chains involved:** Ethereum (forwarder contracts, EURe/USDC) +**Modules:** `monerium-b2b/monerium-api.ts` (narrow adapter over the shared white-label client, [monerium.md](./monerium.md)), `monerium-b2b/attestor.ts`, `monerium-b2b/webhook.ts`, `monerium-b2b/deposit-processor.ts`, `monerium-b2b/account-provisioning.ts`, `monerium-b2b/onboarding.ts`, `controllers/monerium-b2b.controller.ts` (POST `/v1/monerium-b2b/webhook`), `controllers/admin/moneriumB2b.controller.ts` (POST `/v1/admin/monerium-b2b/accounts`, ADMIN_SECRET); keeper: `monerium-b2b/chain.ts`, `monerium-b2b/mint-watcher.ts`, `monerium-b2b/conversion-executor.ts`, `monerium-b2b/dormancy.ts`, `workers/monerium-b2b.worker.ts`; monitoring: `monerium-b2b/monitoring.ts` +**API auth method:** OAuth client credentials through the shared `MoneriumApiService` (`MONERIUM_WHITELABEL_CLIENT_ID`/`MONERIUM_WHITELABEL_CLIENT_SECRET`) against `MONERIUM_API_URL` (defaults to sandbox `api.monerium.dev` while `SANDBOX_ENABLED`, `api.monerium.app` otherwise); inbound webhooks authenticated by HMAC-SHA256 (`MONERIUM_B2B_WEBHOOK_SECRET`) + +## Security Invariants + +0. **Dark by default and fail-fast on activation** — unless `MONERIUM_B2B_ENABLED` is exactly `true`, the public/admin B2B routes, raw webhook parser, keeper worker, and deposit-webhook registration are disabled. Existing generic webhook-outbox rows continue draining. Activation requires `FLOW_VARIANT=mykobo`, all whitelabel credentials, three keys, read RPC, webhook secret, and the trusted factory address; production also requires the private orderflow RPC. Missing configuration aborts startup rather than partially enabling the flow. +1. **Attestor key stays in env and out of logs** — `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` is read from the environment only, never persisted, never returned by an API response, and never included in log lines or error messages (the not-configured error names the variable, not the value). +2. **The attestor signature authorizes linking only** — the attestor signs exactly `keccak256(abi.encodePacked(block.chainid, forwarderAddress, hash))` (the chainid binding prevents cross-chain replay — review r1) where `hash` is `LINK_HASH_191`, the EIP-191 personal-message hash of `"I hereby declare that I am the address owner."` (the raw-keccak variant was removed after the G0 sandbox validation), or the optional non-zero `RECOVERY_HASH` reserved for a future distinct recovery message (kept `bytes32(0)`: per Monerium, T1 resolved 2026-08-26 verbally, the recovery flow validates the SAME ownership message as linking, so the whitelisted `LINK_HASH_191` already covers issuer recovery — the signature proves only address ownership; the recovery payout target is Monerium's controlled process, paying exclusively to the customer's own verified bank account). `VortexForwarder.isValidSignature` accepts nothing else, so a leaked attestor key can link addresses but can never move funds or change forwarder config. The backend MUST never sign arbitrary hashes with this key. +3. **Signature format matches the contract check** — 65-byte `r ‖ s ‖ v` with `v ∈ {27, 28}` and low-s (the contract rejects malleable signatures). Enforced by construction (viem canonical signatures) and pinned by unit test `attestor.test.ts`. +4. **Webhook HMAC follows Monerium's current v1 protocol** — `webhook-signature` must contain exactly `v1,`. The HMAC-SHA256 input is `..` and the key is the decoded 24–64-byte payload of the configured `whsec_` secret. The raw bytes are captured by a route-scoped body-parser hook and compared with `crypto.timingSafeEqual`; malformed or unverified requests are rejected 401 before any database write. +5. **Durable persist before 200 (R06)** — every verified delivery is inserted into `monerium_webhook_events` before the 200 response is sent. Processing happens strictly after the response; a crash between insert and processing loses nothing because the inbox row survives. +6. **Delivery dedup is enforced by the database** — inserts use `ON CONFLICT DO NOTHING` on the unique `event_id`, populated from the signed `webhook-id` header, so retries of a delivery can never double-create or double-apply a deposit event. +7. **Deposit status transitions are forward-only** — `pending → {minted, held, returned}`, `held → {minted, returned}`; `minted` and `returned` are terminal. Out-of-order or replayed webhook events can never regress a deposit status; regressive transitions are logged and ignored. Guarded by `isForwardTransition` (unit-tested). +8. **Per-forwarder serialization via advisory lock** — all deposit writes for one forwarder happen inside a transaction holding `pg_advisory_xact_lock(hashtextextended('monerium-b2b:' || lower(forwarderAddress), 0))`, so concurrent processors (multiple API instances, webhook-triggered plus scheduled runs) apply events for an account strictly one at a time. This is the same serialization point the execution/attribution logic (R04) will use. +9. **Deposit identity and scope are verified** — authenticated payloads must pass the shared Monerium wire schema. Only EUR issue orders on the configured chain and the mapped account's Monerium profile are accepted; `meta.txHashes` is used only when it contains exactly one hash. `monerium_order_id` is unique and cannot move between accounts; the on-chain mint `(chain_id, tx_hash, log_index)` is a second partial-unique identity. Amounts are positive 18-decimal base-unit strings converted from provider decimals, never floats. An amount-only mint match is accepted only when exactly one same-account candidate exists. A late real order in a minted provider state reconciles its unique exact same-account unattributed mint by amount and transaction hash: a missing provider row adopts the synthetic row, while an existing provider row receives the chain identity and allocations atomically before the synthetic row is removed. Pending or terminal provider states never adopt a synthetic mint. Ambiguity is quarantined and alerted, never guessed. Malformed authenticated deliveries are terminally discarded so they cannot poison the inbox. +10. **Client credentials are env-only and requests are bounded** — all provider calls go through the shared white-label client ([monerium.md](./monerium.md)): credentials come from env (`MONERIUM_WHITELABEL_CLIENT_ID/SECRET`), every call carries an explicit timeout, HTTPS base URLs only, successful responses are validated against the consumed wire schemas, and upstream failures surface with redacted response bodies. The B2B adapter (`monerium-api.ts`) adds no transport of its own. +11. **No KYB submission path exists** — the whitelabel KYB mechanism is contractually unsettled (adr-0005 registry T3), so no identity-data submission code path exists in the B2B module or the shared client. Pilot corporates do not need one: they are onboarded and approved by Monerium under the partner's KYC reliance, and the admin mapping imports that outcome as an approved `kyc_cases` row. +12. **Account mapping is admin-only, atomic, and rooted in a trusted factory** — `POST /v1/admin/monerium-b2b/accounts` (ADMIN_SECRET) verifies that the forwarder's immutable `FACTORY()` equals `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, queries `isForwarder` on that configured factory (never a self-reported address), and reads back destination/fallbackAddress/feeBps before persistence. The managed child, customer entity, approved KYB mirror, and account then commit in one database transaction, so a late uniqueness conflict leaves no orphan identity records. Identical replay returns existing records; any divergence is 409, never an overwrite. A Monerium profile, forwarder, and managed profile can each back at most one account (migrations 069/071). +13. **Onboarding provider writes are exactly-once and provider reads are scoped** — the automated link (`POST /addresses`) and IBAN request (`POST /ibans`) run through the profile-scoped `financial_operations` ledger (flow `monerium-b2b-onboarding`), so crashes and retries never repeat a claimed provider call; interrupted calls reconcile by re-reading the linked addresses / issued IBANs. Every linked-address and IBAN selection requires the exact mapped profile, configured Monerium chain, and forwarder address; multiple exact IBAN matches are rejected rather than selected arbitrarily. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and activation after the penny test stays a manual operator step. +14. **A recorded IBAN is never overwritten** — the `iban.updated` webhook (and the onboarding read) only fill a NULL `iban`; a delivery reporting a different IBAN for an account is logged at error level as a possible IBAN move (the S1 association-monitor alert condition), not applied. +15. **The read surface is effective-user scoped and accepts no selectors** — `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/deposits` resolve the account strictly from the acting profile (manager delegation via `X-Managed-Profile-Id` under the standard managed-profile authorization with EU corridor + business policy, or the child's own credential); no caller-supplied account, profile, or IBAN identifier is accepted, a foreign manager gets the uniform managed-profile 403, and R09 `unattr:` synthetic deposit rows are never returned (`monerium-b2b-account-read.integration.test.ts`). +16. **Manager deposit events are final, chain-backed, and manager-only** — `DEPOSIT_RECEIVED` requires the real chain id, transaction hash, log index, and block number, so a provider order alone cannot claim that funds landed. `DEPOSIT_CONVERTED` fires once only after allocations cover the deposit's full EURe amount and every contributing execution is `NOTIFY_CONFIRMATION_DEPTH` blocks deep; its `conversions[]` contains per-execution EURe/USDC portions and payload `usdcNetRaw` is the aggregate. Per-deposit markers prevent replay to late subscribers. Deliveries go only to the controlling manager's webhooks through the durable outbox; `unattr:` rows never emit (`manager-events.test.ts`). +17. **Account lifecycle transitions are explicit** — `onboarding → active`, `active → {suspended, closed}`, and `suspended → {active, closed}` are the only state changes; `closed` is terminal. Repeating the current status is idempotent. The admin controller returns 409 for every invalid edge, including reopening a closed account or moving an active account back to onboarding (`moneriumB2b.controller.test.ts`). + +## Keeper + +The keeper loop (`workers/monerium-b2b.worker.ts`, every minute: webhook inbox → mint watcher → per-account conversion executor → dormancy gate) holds signing keys and submits transactions; its invariants: + +1. **Three-way key separation** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`/`swapAndForward()`), the guardian key (`MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, dormancy pause only), and the attestor key (address linking only) are three distinct keys. None of them can move funds: `swapAndForward` only executes the contract-constrained oracle-checked swap to the client's own `destination`; `setGuardianPaused` is protective-only by contract invariant; the attestor signs the fixed link statement. All three are env-only and never logged. +2. **Private orderflow for keeper writes** — keeper/guardian transactions are submitted through a dedicated transport (`MONERIUM_B2B_PRIVATE_RPC_URL`, e.g. `https://rpc.flashbots.net`), separate from the read/receipt client (`MONERIUM_B2B_RPC_URL`). If the private endpoint is unset the keeper falls back to the public RPC and logs a warning — acceptable on sandbox/testnet, an operational finding on mainnet. +3. **Execution record and exact recovery before resend** — the pending execution row is committed before broadcast. A nonce-less row is a five-minute pre-send reservation; expiry and swap-nonce persistence are competing compare-and-set updates, so an expired owner cannot later broadcast. Any required, non-value-moving `poke()` is sent first. Only after it succeeds are the exact swap nonce and pre-broadcast chain head persisted immediately before `swapAndForward()`, then the hash immediately after broadcast. A receipt finalizes normally. A missing receipt never becomes failure on elapsed time. While the latest confirmed nonce has not passed the persisted nonce, the row stays pending even if the public mempool cannot see it. Once consumed, recovery scans sequential, bounded 2,000-block pages from the persisted head and adopts only one unclaimed transaction whose sender is the keeper, nonce is exact, target is this forwarder, calldata is exactly no-arg `swapAndForward()`, and receipt emits `SwapExecuted` from the forwarder. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. +4. **Advisory-lock serialization** — all keeper database mutations (mint recording, execution slot check/creation, finalization, R04 allocation) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. +5. **Attribution is N:M, cursor-gated, exact-snapshot, and idempotent (R04)** — a confirmed execution records the block and block-global `SwapExecuted` log index but is not allocated immediately. Reconciliation starts only after the persisted mint cursor has processed that block, then consumes outstanding portions of deposits minted in earlier blocks or earlier log positions in the same block, oldest-first up to `eureInRaw`. This covers a mint that lands between the executor's balance read and swap without assigning a later same-block mint to the execution. A cap-cut deposit receives a partial `monerium_deposit_allocations` row and its remainder participates in the next execution; one execution may likewise allocate across many deposits. Each row records its EURe portion and proportional net USDC; execution net is computed as `usdcOut - fee`, never the event's `forwarded` full-balance sweep, so pre-existing unsolicited USDC is not misreported as this deposit's yield. Floor dust goes to the largest allocation only when indexed deposits cover the whole execution, so missing inflow cannot inflate a customer's share. Mint identity is `(chain_id, tx_hash, log_index)` and the watcher scans 12-deep blocks. Only chain-indexed deposits make an account a conversion candidate; a raw forwarder balance never bypasses the watcher. Non-Monerium inflows become `unattr:` rows and never surface as customer claims. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds or block the client's fallback paths (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The stranding marker still arms for dormant, suspended, and closed accounts (`poke()` is pause-immune): the un-pausable dead-man sweep exists precisely for accounts nobody operates. + +## Monitoring + +The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-limited to one pass per 30 minutes) is detection-only; its invariants: + +1. **No keys, no transactions** — monitors read chain state (`MONERIUM_B2B_RPC_URL`) and the Monerium API only; they never hold private keys and never broadcast. The only database mutation is the R07 reconciliation in (4). Alerts go through the standard logger (`error` = incident trigger per `docs/operations-monerium-b2b-runbook.md`). +2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on the pinned EURe→EURC→USDC path at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline). Impact above `SLIPPAGE_BPS` at `minSwapAmount` size logs the error-level PAUSE THRESHOLD line; at `perSwapCap` size a warning. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. +3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe with the on-chain stranding marker (R03) armed longer than 12 h warn; past `TRIGGER_DELAY` they error (the permissionless trigger is then live — a keeper-outage signal, not a fund-risk signal). +4. **Association monitor (S1 detective control)** — per active account, re-reads linked addresses and IBANs scoped to the exact mapped profile and configured chain, then error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). This is the detective control for the S1 risk (Vortex-held whitelabel credentials can move associations at Monerium): changes cannot be prevented client-side, only detected. +5. **Config reconciliation (R07)** — first requires the clone's immutable `FACTORY()` to equal the configured trusted factory, then reads `implementation()` and `isForwarder()` only from that trusted address. A mismatch is an error and no mutable fields are reconciled. For trusted clones, destination/fallback and timelocked fee changes are authorized transitions reconciled with a version bump; proxy bytecode or registration drift is an incident. The standalone manifest verifier remains consistency evidence, not the trust root. + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| **Webhook spoofing** | Attacker posts fabricated order events to `/v1/monerium-b2b/webhook` to invent or advance deposits | Monerium v1 HMAC over signed id + timestamp + raw bytes, constant-time compare; 401 before persistence; enabled startup refuses a missing secret | +| **Cross-account/provider poisoning** | A valid provider event names another chain/profile, or a claimed mint hash carries a different amount | Strict wire/chain/profile/currency checks; hash+amount must both match before chain identity or `DEPOSIT_RECEIVED`; conflicting chain logs are isolated as `unattr:` | +| **Lost or replaced keeper transaction** | A slow/hidden transaction is declared stale and a second swap sends the same funds | Compare-and-set pre-send reservation; no time-based failure after nonce persistence; fail-closed nonce state; bounded complete persisted-block scan plus exact sender/nonce/target/calldata/event identity before adopt/fail | +| **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving attribution permanently incomplete | Conversion candidates require chain-indexed deposits; allocation waits until the mint cursor covers the swap's exact block/log boundary | +| **Unsolicited USDC inflates deposit reporting** | The contract sweeps a pre-existing USDC balance with a later swap and the backend credits the whole transfer to that deposit | Execution net and allocations use `SwapExecuted.usdcOut - fee`; `forwarded` is deliberately excluded from conversion accounting | +| **Untrusted forwarder factory** | Admin-secret holder submits a contract whose self-reported factory blesses it and redirects mints | Configured factory is the trust root for provisioning, execution, and monitoring; local provisioning is atomic | +| **Webhook replay / duplicate delivery** | A captured valid delivery is replayed to double-count a deposit | Durable inbox dedup on unique `event_id` (`ON CONFLICT DO NOTHING`); forward-only transitions make a replayed older state a no-op | +| **Out-of-order events regress state** | A delayed `pending` event arrives after `minted` | Forward-only transition lattice; regressions logged and dropped | +| **Attestor key leak** | Attacker obtains `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` | Blast radius is bounded by design: the key can only produce link attestations for the fixed message, never move funds (contract-side invariant); rotate key + re-deploy forwarders with new ATTESTOR immutable | +| **Attestor signing oracle abuse** | Backend is tricked into signing an arbitrary hash with the attestor key | `signLinkAttestation` derives the hash internally from the fixed LINK_MESSAGE and the forwarder address parameter; no caller-supplied hash is ever signed | +| **Concurrent processors corrupt attribution** | Two instances process events for one account simultaneously | Transaction-scoped Postgres advisory lock per forwarder address serializes all per-account writes | +| **Lost webhook between receipt and processing** | Process crashes after 200 but before the deposit write | Insert-before-200 durable inbox; unprocessed rows are retried on the next run | +| **Poison inbox row blocks processing** | A malformed payload throws forever | Non-order/unrecognized payloads are marked processed and skipped; genuine failures are logged per-row and do not block other rows | +| **API credential compromise** | Whitelabel client id/secret leak | Env-only storage; the whitelabel credential set (`MONERIUM_WHITELABEL_*`) is distinct from the legacy OAuth auth-code client (`MONERIUM_CLIENT_ID`); rotate at Monerium; the S1 association monitor detects unauthorized use | +| **Provider unavailability** | Monerium API down | Client calls have explicit timeouts and surface 502; webhook inbox is unaffected (processing is local) | + +## Audit Checklist + +- [ ] `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY`, `MONERIUM_WHITELABEL_CLIENT_SECRET`, `MONERIUM_B2B_WEBHOOK_SECRET` loaded from env only; grep confirms no logging of their values +- [ ] Attestor signs only the bound link hash (`attestor.ts` has no arbitrary-hash signing entry point) +- [ ] `attestor.test.ts` pins the signature layout against `VortexForwarder.isValidSignature` (65 bytes, v in 27/28, low-s, bound to forwarder address) +- [ ] Webhook HMAC fixture covers signed `webhook-id`, `webhook-timestamp`, raw bytes, decoded `whsec_` key, and `v1,` constant-time comparison +- [ ] Inbox insert (`ON CONFLICT DO NOTHING` on `event_id`) happens before the 200 response in `monerium-b2b.controller.ts` +- [ ] Forward-only transition guard covers all four statuses; regressive events are dropped, not applied +- [ ] All deposit writes run under `pg_advisory_xact_lock` keyed by lower-cased forwarder address +- [ ] `monerium_order_id` unique constraint present; mint-log partial unique index present (migration 069) +- [ ] `monerium_accounts.vortex_profile_id` partial unique index present (migration 071); admin mapping rejects divergence with 409 (`moneriumB2b.controller.test.ts`) +- [ ] Onboarding link/IBAN calls wrapped in profile-scoped `financial_operations`; replay never repeats a provider write (`onboarding.test.ts`) +- [ ] Provider address/IBAN selection requires the exact profile + chain + forwarder tuple and rejects ambiguous matches (`monerium-api.test.ts`) +- [ ] No KYB submission code path exists unless registry item T3 has been resolved and this spec updated +- [ ] HTTPS enforcement, timeouts, and wire-schema validation on every provider call are delivered by the shared client ([monerium.md](./monerium.md)); `monerium-api.ts` adds no transport of its own +- [ ] Current webhook signature/id protocol and upstream order-state vocabulary re-verified from a production delivery before first mainnet deposit (registry T4) +- [ ] Keeper, guardian, and attestor private keys are three distinct keys in production; none logged +- [ ] `MONERIUM_B2B_PRIVATE_RPC_URL` set in production (public-RPC fallback warning absent from logs) +- [ ] Conversion execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the swap; no elapsed-time failure exists after nonce persistence; exact recovery identity, bounded paging, and R04 N:M allocation math are covered by `conversion-executor.test.ts` +- [ ] Confirmed executions carry `swap_log_index` (migration 077); `conversion-allocation.test.ts` proves allocation waits for the mint cursor and applies the exact same-block log boundary idempotently +- [ ] Execution/allocation `usdcNetRaw` is `SwapExecuted.usdcOut - fee`, never the full-balance `forwarded` field (`conversion-executor.test.ts`) +- [ ] Migration 076 refuses a lossy rollback once any deposit allocation exists (`monerium-deposit-allocation-migration.test.ts`) +- [ ] `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` is the factory queried during provisioning/monitoring, and a self-reported mismatch is rejected before local persistence +- [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 owner-authorized config reconciliation; quote-impact, stranding, association-diff and drift classification covered by `monitoring.test.ts` +- [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; owner-authorized config changes (R07) are warn-level reconciliations, never incidents diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index f7fadfac3..fe75b6b24 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -1,6 +1,79 @@ # Monerium Integration -## What This Does +> **B2B onramp:** the whitelabel attestor/webhook onramp is specified in [monerium-b2b.md](./monerium-b2b.md); it consumes the shared white-label client specified below. This file covers the shared white-label API client and the legacy consumer OAuth onboarding flow. + +## White-Label API Client (`@vortexfi/shared`) + +### What This Does + +`@vortexfi/shared` provides a server-to-server Monerium white-label API client authenticated with +the `client_credentials` grant. It maps profile status, linked addresses, IBAN provisioning and +movement, EURe redemption orders, supporting-document uploads, and webhook subscriptions. Users +interact only with Vortex; all Monerium credentials, tokens, and API calls remain backend-only. + +This client establishes the integration baseline for API-managed EU KYC/KYB, wallet ownership, +IBANs, and SEPA/EURe payments. Its only consumer today is the Monerium B2B onramp +([monerium-b2b.md](./monerium-b2b.md)), which wraps it in its own audited orchestration. It is not +connected to a public Vortex route or ramp phase, and EUR ramp registration remains disabled until +that orchestration and its tests are implemented. + +### Externally Imported Profiles + +- Externally imported Monerium profiles MUST come from a trusted party and MUST bind the correct + Monerium profile UUID to the correct Vortex legal entity. The import mechanism is not yet defined; + no caller-controlled profile adoption may be exposed until it is. +- Every imported profile MUST create a `provider_customers` row and a linked `kyc_cases` row in the + `approved` state, regardless of whether Vortex performed the KYC/KYB flow. +- Profile-scoped persistence MUST remain limited to the Monerium profile identifier and compliance + status. Addresses and IBANs remain provider-authoritative; any selected values needed by a ramp + belong in quote or ramp state, not a permanent Monerium profile table. +- A Monerium mint address MUST belong exclusively to one Monerium profile and MUST never be shared + across profiles. Incoming deposits are attributed to that profile through its dedicated address. + +### Security Invariants + +1. White-label credentials MUST use `MONERIUM_WHITELABEL_CLIENT_ID` and `MONERIUM_WHITELABEL_CLIENT_SECRET`, remain backend-only, and never be accepted from caller input. +2. `MONERIUM_API_URL` MUST use HTTPS. Every authenticated call MUST request API v2, encode dynamic path/query values, and use an explicit 10-second timeout. +3. Authentication MUST send form-encoded `client_credentials`. Access tokens MUST be cached only in memory, coalesced across concurrent requests, renewed before expiry, and reacquired at most once after `401`. +4. Client secrets, access tokens, signatures, request bodies, and raw provider response bodies MUST NOT appear in logs, structured errors, or Vortex API responses. Error endpoint fields MUST use route templates rather than customer identifiers. +5. Successful provider responses MUST be validated against consumed wire schemas. Malformed successful responses MUST surface as contract violations, not trusted typed values or provider-availability errors. +6. Profile kinds and states MUST preserve Monerium's documented values. A profile UUID MUST remain bound to the correct Vortex legal entity when orchestration is added. +7. The wallet-ownership message MUST remain exactly `I hereby declare that I am the address owner.` Callers SHOULD obtain it through `buildMoneriumWalletLinkMessage`. +8. EOA signatures and off-chain EIP-1271 combined signature bytes MUST be sent unchanged. Vortex MUST NOT hash, split, recover, reorder, or assemble smart-wallet owner signatures. The wallet integration owns signature assembly; Monerium owns `isValidSignature` verification. +9. Address results MUST preserve `201` immediate success and `202` pending on-chain verification. IBAN creation MUST preserve `202` provisioning and `304` already-provisioned semantics. Order creation MUST preserve `200` placed and `202` pending semantics. +10. SEPA redemption messages MUST bind currency, exact amount, recipient IBAN, and an RFC3339 minute timestamp no more than five minutes old. Only the full normalized IBAN or its deterministic first-four/last-four shortened form is valid. +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. No public route or ramp phase may rely on this client until ownership checks, persistence, webhook verification, idempotency, and end-to-end corridor tests are implemented. The Monerium B2B onramp ([monerium-b2b.md](./monerium-b2b.md)) consumes this client for its provider calls through its own audited orchestration (attestor-signed linking, HMAC-verified webhooks, exactly-once financial operations). + +### Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| White-label credential disclosure | A provider error echoes a secret, token, signature, or profile data | The client never logs bodies and replaces upstream/transport response bodies with a fixed redacted value | +| Token stampede | Concurrent requests receive a delayed `401` and repeatedly request tokens | Token acquisition is coalesced and a rejected token is cleared only if it is still the active cached token | +| Provider hangs | Monerium does not respond | Every provider fetch has an explicit 10-second abort timeout | +| Provider contract drift | Monerium renames a consumed field or changes an enum/status body | Runtime schemas reject malformed successes and the API contract suite exercises the same schemas | +| Smart-wallet proof corruption | Vortex hashes or reassembles Safe owner signatures differently from the wallet contract | Combined off-chain EIP-1271 bytes are opaque; the exact fixed message and hex-byte envelope are validated, then sent unchanged | +| Signed-order substitution | Amount, IBAN, or timestamp differs between the signature and submitted order | Request validation binds the exact documented message to the request fields before transmission | +| 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 | + +### Audit Checklist + +- [x] White-label authentication uses form-encoded `client_credentials`; access tokens are coalesced, memory-only, and retried once after `401`. +- [x] White-label requests use API v2, encoded parameters, 10-second abort signals, and redacted structured provider errors. +- [x] Successful profile, address, IBAN, order, file, and webhook responses are validated before return. +- [x] Address linking preserves externally assembled EIP-1271 signature bytes and the documented `201`/`202` distinction. +- [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. +- [ ] Public white-label onboarding and ramp orchestration are not implemented; ownership, persistence, webhook verification, idempotency, and corridor coverage remain required before exposure. + +## Legacy OAuth Onboarding (dashboard KYC/KYB) + +### What This Does 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`. @@ -8,7 +81,7 @@ The endpoints are `POST /v1/monerium/oauth/start`, `POST /v1/monerium/oauth/comp 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. -## Security Invariants +### Security Invariants 1. OAuth state and the PKCE verifier MUST be generated with a cryptographically secure random source on the backend. 2. Each OAuth transaction MUST expire after 10 minutes and be bound to the authenticated user, customer entity, customer type, and configured redirect URI. @@ -31,7 +104,7 @@ Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR rec 19. Admin impersonation MUST NOT start or complete Monerium OAuth. `GET /status` remains available so an operator can inspect the target's persisted verification state. 20. Managed-profile selection is unsupported on these legacy routes. `X-Managed-Profile-Id` is ignored and every operation remains scoped to the Supabase-authenticated manager. Managed clients MUST NOT send the selector; the dashboard omits it and disables Monerium actions in child mode. -## Threat Vectors & Mitigations +### Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| @@ -46,7 +119,7 @@ Monerium replaces Mykobo as the EU dashboard onboarding provider and the EUR rec | 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 | -## Audit Checklist +### Audit Checklist - [x] All three Monerium endpoints require Supabase authentication. - [x] State and PKCE are generated server-side with `crypto.randomBytes`; S256 is used. diff --git a/docs/security-spec/05-integrations/mykobo.md b/docs/security-spec/05-integrations/mykobo.md index c3fda84c0..321dbfd68 100644 --- a/docs/security-spec/05-integrations/mykobo.md +++ b/docs/security-spec/05-integrations/mykobo.md @@ -11,7 +11,7 @@ Monerium now owns EU dashboard KYC/KYB and recipient onboarding eligibility; Myk Mykobo replaces two earlier EUR rails: - The **Stellar SEP-24 EUR off-ramp** (Mykobo anchor reached via Spacewalk) — removed; Stellar/Spacewalk support was fully removed from the platform (migration 028). -- The legacy **Monerium EUR on-ramp** (Monerium EURe minted on Moonbeam) — removed. The new Monerium OAuth onboarding flow is separate and does not restore that settlement path; see `monerium.md`. +- The legacy **Monerium EUR on-ramp** (Monerium EURe minted on Moonbeam) — removed. The white-label reintegration is a new API-managed baseline and does not restore that settlement path by itself; see `monerium.md`. **Provider type:** Both (on-ramp and off-ramp) **Fiat currency:** EUR (Euro, SEPA) diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index e7f7cd62b..8b1162035 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -44,7 +44,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - During an active window, mutable quote/ramp operations return HTTP `503 Service Unavailable` before controller/service work starts. - Rejections include `Retry-After`, `Cache-Control: no-store`, and downtime metadata (`maintenance_start`, `maintenance_end`, affected operations) in the error payload so direct API clients can pause and retry after the window. -**Route structure:** 43 `*.route.ts` files under `api/routes/` (36 under `v1/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. +**Route structure:** 45 `*.route.ts` files under `api/routes/` (35 directly under `v1/`, 8 under `v1/admin/`, 2 under `v1/admin-console/`), plus `v1/index.ts`, each mounting controllers with appropriate auth middleware. `api/routes/api-surface-inventory.test.ts` derives this count from the tree so the audit inventory cannot silently stale. **Multipart uploads:** Four operations use in-memory Multer buffering. Alfredpay's `POST /v1/alfredpay/submitKycFile`, `submitKybFile`, and `submitKybRelatedPersonFile` (also mounted under the country aliases `/v1/mx`, `/v1/co`, and `/v1/ar`) allow one file up to 5MB; secret/Bearer authentication and the managed relationship/entity-type gate run before buffering, while multipart country authorization runs after parsing. On a country alias, the path-derived country replaces any multipart country field before that authorization. Mykobo's `POST /v1/mykobo/profiles` is Supabase-authenticated before buffering and accepts up to four named files (`front`, `back`, `face`, `utility_bill`), each up to 10MB. These routes bound individual file size but do not currently configure a MIME/type `fileFilter`; the Mykobo request can buffer up to 40MB in aggregate. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 5dc966a9a..bae47ad9a 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -60,7 +60,8 @@ 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` | Mykobo EUR on/off-ramp on Base (currently registration-gated) | -| Monerium | `05-integrations/monerium.md` | Server-side OAuth KYC/KYB and verification status mirroring | +| Monerium | `05-integrations/monerium.md` | Server-to-server white-label API client plus the legacy OAuth KYC/KYB and verification status mirroring | +| 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 | | FastForex | `05-integrations/fastforex.md` | Fiat forex price provider used by quote/conversion math | @@ -108,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** | EUR fiat anchor for SEPA on/off-ramp on Base (settles EURC on Base; currently registration-gated) | -| **Monerium** | European e-money provider used for OAuth-based KYC/KYB verification and EUR profile status. | +| **Monerium** | European e-money provider integrated through the white-label API for KYC/KYB, IBANs, and EUR payments. | | **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/package.json b/package.json index 4b281dc5b..337997da3 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "build:frontend": "bun run --cwd apps/frontend build", "build:sdk": "bun run --cwd packages/sdk build", "build:shared": "bun run --cwd packages/shared build", + "compile:contracts:monerium-forwarder": "bun run --cwd contracts/monerium-forwarder compile", "compile:contracts:relayer": "bun run --cwd contracts/relayer compile", "dev": "bun run --cwd packages/shared dev & bun run --cwd apps/api dev & bun run --cwd apps/frontend dev & wait", "dev:backend": "bun run --cwd apps/api dev", @@ -129,6 +130,7 @@ "serve:frontend": "bun run --cwd apps/frontend preview", "test": "bun run test:shared && bun run test:kyc && bun run test:sdk && bun run test:rebalancer && bun run test:api && bun run test:demo && bun run test:frontend", "test:api": "cd apps/api && bun test", + "test:contracts:monerium-forwarder": "bun run --cwd contracts/monerium-forwarder test", "test:contracts:relayer": "bun run --cwd contracts/relayer test", "test:coverage": "bun run --cwd packages/shared test:coverage && bun run --cwd packages/sdk test:coverage && bun run --cwd apps/rebalancer test:coverage && bun run --cwd apps/api test:coverage && bun run --cwd apps/frontend test:coverage && bun scripts/coverage-report.ts", "test:coverage:html": "bun scripts/coverage-report.ts --html coverage/index.html && open coverage/index.html", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5f2231413..39979d459 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -23,3 +23,6 @@ export const MYKOBO_ACCESS_KEY = getEnvVar("MYKOBO_ACCESS_KEY"); export const MYKOBO_SECRET_KEY = getEnvVar("MYKOBO_SECRET_KEY"); // Optional. Mykobo defaults the fee scope to `.mykobo.app` when omitted. export const MYKOBO_CLIENT_DOMAIN = getEnvVar("MYKOBO_CLIENT_DOMAIN"); + +export const MONERIUM_API_URL = + getEnvVar("MONERIUM_API_URL") || (SANDBOX_ENABLED ? "https://api.monerium.dev" : "https://api.monerium.app"); diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index dba918272..26f45752a 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -2,7 +2,24 @@ import { RampDirection } from "../index"; export enum WebhookEventType { TRANSACTION_CREATED = "TRANSACTION_CREATED", - STATUS_CHANGE = "STATUS_CHANGE" + STATUS_CHANGE = "STATUS_CHANGE", + DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", + DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED" +} + +/** + * The account-scoped event family (business EUR onramp accounts). Subscriptions to + * these events are registered without a quoteId/sessionId, cannot be mixed with the + * transaction events in one webhook, and are delivered durably (at-least-once with + * backoff) to the account's controlling manager. + */ +export const ACCOUNT_WEBHOOK_EVENT_TYPES = [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED] as const; + +export enum DepositStatus { + PENDING = "pending", + MINTED = "minted", + HELD = "held", + RETURNED = "returned" } export enum TransactionStatus { @@ -61,7 +78,54 @@ export interface StatusChangeWebhookPayload { payload: WebhookPayloadBase; } -export type WebhookPayload = TransactionCreatedWebhookPayload | StatusChangeWebhookPayload; +export interface DepositWebhookPayloadBase { + /** The onramp account the deposit belongs to. */ + accountId: string; + /** The managed child profile that owns the account. */ + profileId: string; + depositId: string; + /** Deposit amount in 18-decimal base units of the deposit currency. */ + amountRaw: string; + currency: string; + status: DepositStatus; + /** The on-chain mint transaction, when observed. */ + txHash: string | null; +} + +export interface DepositReceivedWebhookPayload { + /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.DEPOSIT_RECEIVED; + timestamp: string; + payload: DepositWebhookPayloadBase; +} + +export interface DepositConvertedWebhookPayload { + /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.DEPOSIT_CONVERTED; + timestamp: string; + payload: DepositWebhookPayloadBase & { + /** Every confirmed conversion portion that consumed this deposit, oldest first. */ + conversions: Array<{ + /** EURe from this deposit consumed by this execution (18-decimal base units). */ + eureInRaw: string; + executionId: string; + /** The swap-and-forward transaction. */ + txHash: string | null; + /** Net USDC from this execution attributed to this deposit (6-decimal base units). */ + usdcNetRaw: string; + }>; + /** Aggregate net USDC attributed to the complete deposit (6-decimal base units). */ + usdcNetRaw: string; + }; +} + +export type WebhookPayload = + | TransactionCreatedWebhookPayload + | StatusChangeWebhookPayload + | DepositReceivedWebhookPayload + | DepositConvertedWebhookPayload; export interface WebhookDeliveryAttempt { webhookId: string; diff --git a/packages/shared/src/services/index.ts b/packages/shared/src/services/index.ts index 58fa879ed..dcc80336c 100644 --- a/packages/shared/src/services/index.ts +++ b/packages/shared/src/services/index.ts @@ -2,6 +2,7 @@ export * from "../contracts"; export * from "./alfredpay"; export * from "./brla"; export * from "./evm"; +export * from "./monerium"; export * from "./mykobo"; export * from "./nabla"; export * from "./pendulum"; diff --git a/packages/shared/src/services/monerium/index.ts b/packages/shared/src/services/monerium/index.ts new file mode 100644 index 000000000..b41341e19 --- /dev/null +++ b/packages/shared/src/services/monerium/index.ts @@ -0,0 +1,3 @@ +export * from "./moneriumApiService"; +export * from "./schemas"; +export * from "./types"; diff --git a/packages/shared/src/services/monerium/moneriumApiService.test.ts b/packages/shared/src/services/monerium/moneriumApiService.test.ts new file mode 100644 index 000000000..eb134acbc --- /dev/null +++ b/packages/shared/src/services/monerium/moneriumApiService.test.ts @@ -0,0 +1,333 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + buildMoneriumSepaRedemptionMessage, + buildMoneriumWalletLinkMessage, + MoneriumApiError, + MoneriumApiService, + MoneriumContractError, + MONERIUM_REQUEST_TIMEOUT_MS +} from "./moneriumApiService"; +import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE } from "./types"; + +const realFetch = globalThis.fetch; +const PROFILE_ID = "123e4567-e89b-42d3-a456-426614174000"; +const ADDRESS = "0x59cFC408d310697f9D3598e1BE75B0157a072407"; +const IBAN = "EE521273842688571285"; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function service(): MoneriumApiService { + const instance = Object.create(MoneriumApiService.prototype) as MoneriumApiService; + Object.assign(instance, { + baseUrl: "https://api.monerium.dev", + clientId: "client-id", + clientSecret: "client-secret" + }); + return instance; +} + +function tokenResponse(token = "access-token"): Response { + return Response.json({ access_token: token, expires_in: 3600, token_type: "Bearer" }); +} + +function profileListResponse(): Response { + return Response.json({ + profiles: [{ id: PROFILE_ID, kind: "personal", name: "Jane Doe", state: "approved" }] + }); +} + +function redeemRequest() { + const timestamp = new Date(Date.now() + 60_000); + return { + address: ADDRESS, + amount: "100.00", + chain: "ethereum" as const, + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: IBAN, standard: "iban" as const } + }, + currency: "eur" as const, + kind: "redeem" as const, + message: buildMoneriumSepaRedemptionMessage("100.00", IBAN, timestamp), + signature: `0x${"ab".repeat(65)}` + }; +} + +describe("MoneriumApiService authentication and transport", () => { + test("builds the exact documented wallet-link message", () => { + expect(buildMoneriumWalletLinkMessage()).toBe("I hereby declare that I am the address owner."); + }); + + test("uses client_credentials, caches the token, and requests API v2", async () => { + const fetchMock = mock(async () => { + const call = fetchMock.mock.calls.length; + if (call === 1) return tokenResponse(); + return profileListResponse(); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const api = service(); + await api.listProfiles({ kind: "personal", state: "approved" }); + await api.listProfiles(); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const [authUrl, authOptions] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(authUrl).toBe("https://api.monerium.dev/auth/token"); + expect(authOptions.method).toBe("POST"); + expect(String(authOptions.body)).toBe("client_id=client-id&client_secret=client-secret&grant_type=client_credentials"); + expect(authOptions.headers).toEqual({ + Accept: "application/vnd.monerium.api-v2+json", + "Content-Type": "application/x-www-form-urlencoded" + }); + expect(authOptions.signal).toBeInstanceOf(AbortSignal); + + const [profilesUrl, profilesOptions] = fetchMock.mock.calls[1] as unknown as [string, RequestInit]; + expect(profilesUrl).toBe("https://api.monerium.dev/profiles?kind=personal&state=approved"); + expect(profilesOptions.headers).toEqual({ + Accept: "application/vnd.monerium.api-v2+json", + Authorization: "Bearer access-token" + }); + }); + + test("reacquires a token once after a 401", async () => { + const responses = [tokenResponse("token-1"), new Response(null, { status: 401 }), tokenResponse("token-2"), profileListResponse()]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(service().listProfiles()).resolves.toEqual({ + profiles: [{ id: PROFILE_ID, kind: "personal", name: "Jane Doe", state: "approved" }] + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect((fetchMock.mock.calls[1][1] as RequestInit).headers).toEqual( + expect.objectContaining({ Authorization: "Bearer token-1" }) + ); + expect((fetchMock.mock.calls[3][1] as RequestInit).headers).toEqual( + expect.objectContaining({ Authorization: "Bearer token-2" }) + ); + }); + + test("reuses a newer token when a delayed concurrent request returns 401", async () => { + let authCalls = 0; + const pendingTokenOneResponses: Array<(response: Response) => void> = []; + let tokenTwoRequests = 0; + const fetchMock = mock(async (input: string | URL | Request, options?: RequestInit) => { + if (String(input).endsWith("/auth/token")) { + authCalls += 1; + return tokenResponse(`token-${authCalls}`); + } + const authorization = (options?.headers as Record).Authorization; + if (authorization === "Bearer token-1") { + return await new Promise(resolve => pendingTokenOneResponses.push(resolve)); + } + tokenTwoRequests += 1; + return profileListResponse(); + }); + globalThis.fetch = fetchMock as typeof fetch; + const api = service(); + + const first = api.listProfiles(); + const second = api.listProfiles(); + while (pendingTokenOneResponses.length < 2) await new Promise(resolve => setTimeout(resolve, 0)); + + pendingTokenOneResponses[0](new Response(null, { status: 401 })); + while (tokenTwoRequests < 1) await new Promise(resolve => setTimeout(resolve, 0)); + pendingTokenOneResponses[1](new Response(null, { status: 401 })); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(authCalls).toBe(2); + expect(tokenTwoRequests).toBe(2); + }); + + test("rejects malformed successful responses at the provider boundary", async () => { + const responses = [tokenResponse(), Response.json({ profiles: [{ id: PROFILE_ID }] })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + await expect(service().listProfiles()).rejects.toBeInstanceOf(MoneriumContractError); + }); + + test("classifies malformed token JSON and accepted bodies as contract violations", async () => { + globalThis.fetch = mock(async () => new Response("not-json")) as typeof fetch; + await expect(service().listProfiles()).rejects.toBeInstanceOf(MoneriumContractError); + + const responses = [tokenResponse(), Response.json({ code: 202, status: "Pending" }, { status: 202 })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + await expect( + service().linkAddress({ + address: ADDRESS, + chain: "ethereum", + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature: "0x" + }) + ).rejects.toBeInstanceOf(MoneriumContractError); + }); + + test("redacts provider bodies and credentials from HTTP errors", async () => { + const sentinel = "SENTINEL-PROVIDER-PII"; + const responses = [tokenResponse(), new Response(sentinel, { status: 403 })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + + const error = await service() + .getProfile(PROFILE_ID) + .catch(value => value as MoneriumApiError); + expect(error).toBeInstanceOf(MoneriumApiError); + expect(error.status).toBe(403); + expect(error.responseBody).toBe("Sensitive provider response omitted"); + expect(error.endpoint).toBe("/profiles/:profile"); + expect(error.message).not.toContain(sentinel); + expect(error.message).not.toContain("client-secret"); + expect(JSON.stringify(error)).not.toContain(PROFILE_ID); + }); + + test("maps transport failures to status 0", async () => { + globalThis.fetch = mock(async () => { + throw new Error("connection reset"); + }) as typeof fetch; + + const error = await service() + .listProfiles() + .catch(value => value as MoneriumApiError); + expect(error).toBeInstanceOf(MoneriumApiError); + expect(error.status).toBe(0); + expect(MONERIUM_REQUEST_TIMEOUT_MS).toBe(10_000); + }); +}); + +describe("MoneriumApiService resource mappings", () => { + test("encodes address/profile paths and query parameters", async () => { + const responses = [ + tokenResponse(), + Response.json({ address: ADDRESS, chains: ["ethereum"], profile: PROFILE_ID }), + Response.json({ addresses: [] }) + ]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + const api = service(); + + await api.getAddress(`${ADDRESS}/suffix`); + await api.listAddresses({ chain: "ethereum", profile: "profile/id" }); + + expect(String(fetchMock.mock.calls[1][0])).toEndWith(`/addresses/${ADDRESS}%2Fsuffix`); + expect(String(fetchMock.mock.calls[2][0])).toBe( + "https://api.monerium.dev/addresses?chain=ethereum&profile=profile%2Fid" + ); + }); + + test("submits combined EIP-1271 bytes through the normal address endpoint", async () => { + const responses = [tokenResponse(), Response.json({}, { status: 201 })]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + const signature = `0x${"12".repeat(130)}`; + const request = { + address: ADDRESS, + chain: "ethereum" as const, + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature + }; + + await expect(service().linkAddress(request)).resolves.toEqual({ httpStatus: 201 }); + const [url, options] = fetchMock.mock.calls[1] as unknown as [string, RequestInit]; + expect(url).toBe("https://api.monerium.dev/addresses"); + expect(options.method).toBe("POST"); + expect(options.body).toBe(JSON.stringify(request)); + }); + + test("preserves the pending on-chain EIP-1271 status", async () => { + const responses = [tokenResponse(), Response.json({ code: 202, status: "Accepted" }, { status: 202 })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + + await expect( + service().linkAddress({ + address: ADDRESS, + chain: "ethereum", + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature: "0x" + }) + ).resolves.toEqual({ code: 202, httpStatus: 202, status: "Accepted" }); + }); + + test("treats an existing IBAN 304 as a documented result", async () => { + const responses = [tokenResponse(), new Response(null, { status: 304 })]; + globalThis.fetch = mock(async () => responses.shift() as Response) as typeof fetch; + await expect(service().requestIban({ address: ADDRESS, chain: "ethereum" })).resolves.toEqual({ httpStatus: 304 }); + }); + + test("pins the exact redemption payload and accepted response", async () => { + const responses = [tokenResponse(), Response.json({ code: 202, status: "Accepted" }, { status: 202 })]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + const request = redeemRequest(); + + await expect(service().createRedemptionOrder(request)).resolves.toEqual({ + code: 202, + httpStatus: 202, + status: "Accepted" + }); + expect((fetchMock.mock.calls[1][1] as RequestInit).body).toBe(JSON.stringify(request)); + }); + + test("rejects an order whose body no longer matches its signed message", async () => { + globalThis.fetch = mock(async () => tokenResponse()) as typeof fetch; + await expect( + service().createRedemptionOrder({ ...redeemRequest(), amount: "101.00" }) + ).rejects.toThrow("message must exactly match"); + }); + + test("uploads a file under the documented multipart field without overriding its content type", async () => { + const uploaded = { + hash: "hash", + id: "223e4567-e89b-42d3-a456-426614174001", + meta: { + createdAt: "2026-08-25T12:00:00Z", + updatedAt: "2026-08-25T12:00:00Z", + uploadedBy: PROFILE_ID + }, + name: "evidence.pdf", + size: 4, + type: "application/pdf" + }; + const responses = [tokenResponse(), Response.json(uploaded)]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(service().uploadFile(new Blob(["test"], { type: "application/pdf" }), "evidence.pdf")).resolves.toEqual( + uploaded + ); + const options = fetchMock.mock.calls[1][1] as RequestInit; + expect(options.body).toBeInstanceOf(FormData); + expect((options.body as FormData).get("file")).toBeInstanceOf(File); + expect((options.headers as Record)["Content-Type"]).toBeUndefined(); + }); + + test("maps webhook creation and deactivation without sending unsupported fields", async () => { + const subscription = { + id: "223e4567-e89b-42d3-a456-426614174001", + state: "active", + types: ["profile.updated"], + url: "https://example.com/monerium" + }; + const responses = [ + tokenResponse(), + Response.json(subscription, { status: 201 }), + Response.json({ ...subscription, state: "inactive" }) + ]; + const fetchMock = mock(async () => responses.shift() as Response); + globalThis.fetch = fetchMock as typeof fetch; + const api = service(); + const request = { + secret: `whsec_${btoa("a".repeat(32))}`, + types: ["profile.updated" as const], + url: "https://example.com/monerium" + }; + + await api.createWebhook(request); + await api.updateWebhook("subscription/id", { state: "inactive" }); + + expect((fetchMock.mock.calls[1][1] as RequestInit).body).toBe(JSON.stringify(request)); + expect(String(fetchMock.mock.calls[2][0])).toEndWith("/webhooks/subscription%2Fid"); + expect((fetchMock.mock.calls[2][1] as RequestInit).body).toBe(JSON.stringify({ state: "inactive" })); + }); +}); diff --git a/packages/shared/src/services/monerium/moneriumApiService.ts b/packages/shared/src/services/monerium/moneriumApiService.ts new file mode 100644 index 000000000..ad22074b9 --- /dev/null +++ b/packages/shared/src/services/monerium/moneriumApiService.ts @@ -0,0 +1,402 @@ +import type { ZodType } from "zod"; +import { MONERIUM_API_URL } from "../.."; +import { ProviderHttpError } from "../providerHttpError"; +import { + moneriumAcceptedResponseSchema, + moneriumAccessTokenResponseSchema, + moneriumAddressSchema, + moneriumCreateWebhookRequestSchema, + moneriumIbanDestinationRequestSchema, + moneriumIbanSchema, + moneriumLinkAddressRequestSchema, + moneriumListAddressesResponseSchema, + moneriumListIbansResponseSchema, + moneriumListOrdersResponseSchema, + moneriumListProfilesResponseSchema, + moneriumListWebhooksResponseSchema, + moneriumOrderSchema, + moneriumProfileSchema, + moneriumRedeemOrderRequestSchema, + moneriumUpdateWebhookRequestSchema, + moneriumUploadedFileSchema, + moneriumWebhookSubscriptionSchema +} from "./schemas"; +import { + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + type MoneriumAddress, + type MoneriumChain, + type MoneriumCreateOrderResult, + type MoneriumCreateWebhookRequest, + type MoneriumIban, + type MoneriumIbanDestinationRequest, + type MoneriumLinkAddressRequest, + type MoneriumLinkAddressResult, + type MoneriumListAddressesResponse, + type MoneriumListIbansResponse, + type MoneriumListOrdersResponse, + type MoneriumListProfilesResponse, + type MoneriumListWebhooksResponse, + type MoneriumOrder, + type MoneriumOrderFilterState, + type MoneriumProfile, + type MoneriumProfileKind, + type MoneriumProfileState, + type MoneriumRedeemOrderRequest, + type MoneriumRequestIbanResult, + type MoneriumUpdateWebhookRequest, + type MoneriumUploadedFile, + type MoneriumWebhookSubscription +} from "./types"; + +const API_V2_MEDIA_TYPE = "application/vnd.monerium.api-v2+json"; +const REDACTED_PROVIDER_RESPONSE = "Sensitive provider response omitted"; +const TOKEN_EXPIRY_SKEW_MS = 30_000; +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; +const ALLOWED_FILE_TYPES = new Set(["application/pdf", "image/jpeg"]); +export const MONERIUM_REQUEST_TIMEOUT_MS = 10_000; + +type HttpMethod = "GET" | "PATCH" | "POST"; + +interface CachedAccessToken { + expiresAt: number; + value: string; +} + +interface MoneriumHttpResult { + body: T; + status: number; +} + +export class MoneriumApiError extends ProviderHttpError { + constructor(params: { status: number; endpoint: string; method: string }) { + super({ ...params, provider: "monerium", responseBody: REDACTED_PROVIDER_RESPONSE }); + } +} + +export class MoneriumContractError extends Error { + public readonly providerContractViolation = true; + + constructor(operation: string) { + super(`Monerium returned an invalid successful response for ${operation}`); + this.name = "MoneriumContractError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** @see https://docs.monerium.com/whitelabel#link-wallet */ +export function buildMoneriumWalletLinkMessage(): typeof MONERIUM_ADDRESS_OWNERSHIP_MESSAGE { + return MONERIUM_ADDRESS_OWNERSHIP_MESSAGE; +} + +/** @see https://docs.monerium.com/whitelabel#signing-an-order */ +export function buildMoneriumSepaRedemptionMessage(amount: string, iban: string, timestamp: Date | string): string { + const minute = timestamp instanceof Date ? `${timestamp.toISOString().slice(0, 16)}Z` : timestamp; + return `Send EUR ${amount} to ${iban} at ${minute}`; +} + +export class MoneriumApiService { + private static instance: MoneriumApiService; + + private readonly baseUrl: string; + + private readonly clientId: string; + + private readonly clientSecret: string; + + private cachedToken: CachedAccessToken | undefined; + + private tokenPromise: Promise | undefined; + + private constructor() { + if (typeof window !== "undefined") { + throw new Error("MoneriumApiService is server-only"); + } + 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; + } + + public static getInstance(): MoneriumApiService { + if (!MoneriumApiService.instance) { + MoneriumApiService.instance = new MoneriumApiService(); + } + return MoneriumApiService.instance; + } + + private async acquireToken(): Promise { + const endpoint = "/auth/token"; + const form = new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: "client_credentials" + }); + let response: Response; + try { + response = await fetch(`${this.baseUrl}${endpoint}`, { + body: form, + headers: { + Accept: API_V2_MEDIA_TYPE, + "Content-Type": "application/x-www-form-urlencoded" + }, + method: "POST", + signal: AbortSignal.timeout(MONERIUM_REQUEST_TIMEOUT_MS) + }); + } catch { + throw new MoneriumApiError({ endpoint, method: "POST", status: 0 }); + } + if (!response.ok) { + throw new MoneriumApiError({ endpoint, method: "POST", status: response.status }); + } + + let token; + try { + token = moneriumAccessTokenResponseSchema.parse(await this.readJson(response, endpoint, "POST")); + } catch { + throw new MoneriumContractError("POST /auth/token"); + } + return { + expiresAt: Date.now() + token.expires_in * 1_000, + value: token.access_token + }; + } + + private async getAccessToken(): Promise { + if (this.cachedToken && this.cachedToken.expiresAt - TOKEN_EXPIRY_SKEW_MS > Date.now()) { + return this.cachedToken.value; + } + if (!this.tokenPromise) { + this.tokenPromise = this.acquireToken().finally(() => { + this.tokenPromise = undefined; + }); + } + this.cachedToken = await this.tokenPromise; + return this.cachedToken.value; + } + + private buildUrl(path: string, query?: Record): string { + const url = new URL(`${this.baseUrl}${path}`); + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) url.searchParams.set(key, value); + } + return url.toString(); + } + + private async fetchAuthenticated( + path: string, + method: HttpMethod, + body?: unknown, + query?: Record + ): Promise { + const url = this.buildUrl(path, query); + 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 (this.cachedToken?.value === token) this.cachedToken = undefined; + token = await this.getAccessToken(); + response = await this.performFetch(url, path, method, token, serializedBody); + } + return response; + } + + private async performFetch( + url: string, + path: string, + method: HttpMethod, + token: string, + body: BodyInit | undefined + ): Promise { + const headers: Record = { + Accept: API_V2_MEDIA_TYPE, + Authorization: `Bearer ${token}` + }; + if (body !== undefined && !(body instanceof FormData)) headers["Content-Type"] = "application/json"; + + try { + return await fetch(url, { + body, + headers, + method, + signal: AbortSignal.timeout(MONERIUM_REQUEST_TIMEOUT_MS) + }); + } catch { + throw new MoneriumApiError({ endpoint: this.redactEndpoint(path), method, status: 0 }); + } + } + + private async request( + path: string, + method: HttpMethod, + options: { + acceptedStatuses?: number[]; + body?: unknown; + query?: Record; + } = {} + ): Promise> { + const response = await this.fetchAuthenticated(path, method, options.body, options.query); + const acceptedStatuses = options.acceptedStatuses ?? [200]; + if (!acceptedStatuses.includes(response.status)) { + throw new MoneriumApiError({ endpoint: this.redactEndpoint(path), method, status: response.status }); + } + return { + body: (response.status === 304 ? undefined : await this.readJson(response, path, method)) as T, + status: response.status + }; + } + + private async readJson(response: Response, endpoint: string, method: string): Promise { + const text = await response.text(); + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + throw new MoneriumContractError(`${method} ${this.redactEndpoint(endpoint)}`); + } + } + + private redactEndpoint(path: string): string { + return path + .replace(/^\/profiles\/[^/]+$/, "/profiles/:profile") + .replace(/^\/addresses\/[^/]+$/, "/addresses/:address") + .replace(/^\/ibans\/[^/]+$/, "/ibans/:iban") + .replace(/^\/orders\/[^/]+$/, "/orders/:order") + .replace(/^\/webhooks\/[^/]+$/, "/webhooks/:subscription"); + } + + private parseResponse(schema: ZodType, body: unknown, operation: string): T { + const result = schema.safeParse(body); + if (!result.success) throw new MoneriumContractError(operation); + return result.data; + } + + public async listProfiles(filters: { kind?: MoneriumProfileKind; state?: MoneriumProfileState } = {}) { + const response = await this.request("/profiles", "GET", { query: filters }); + return this.parseResponse(moneriumListProfilesResponseSchema, response.body, "GET /profiles"); + } + + public async getProfile(profileId: string): Promise { + const response = await this.request(`/profiles/${encodeURIComponent(profileId)}`, "GET"); + return this.parseResponse(moneriumProfileSchema, response.body, "GET /profiles/:profile"); + } + + public async listAddresses(filters: { chain?: MoneriumChain; profile?: string } = {}) { + const response = await this.request("/addresses", "GET", { query: filters }); + return this.parseResponse(moneriumListAddressesResponseSchema, response.body, "GET /addresses"); + } + + public async getAddress(address: string): Promise { + const response = await this.request(`/addresses/${encodeURIComponent(address)}`, "GET"); + return this.parseResponse(moneriumAddressSchema, response.body, "GET /addresses/:address"); + } + + public async linkAddress(request: MoneriumLinkAddressRequest): Promise { + const body = moneriumLinkAddressRequestSchema.parse(request); + const response = await this.request("/addresses", "POST", { + acceptedStatuses: [201, 202], + body + }); + if (response.status === 201) return { httpStatus: 201 }; + return { + httpStatus: 202, + ...this.parseResponse(moneriumAcceptedResponseSchema, response.body, "POST /addresses") + }; + } + + public async listIbans(filters: { chain?: MoneriumChain; profile?: string } = {}) { + const response = await this.request("/ibans", "GET", { query: filters }); + return this.parseResponse(moneriumListIbansResponseSchema, response.body, "GET /ibans"); + } + + public async getIban(iban: string): Promise { + const response = await this.request(`/ibans/${encodeURIComponent(iban)}`, "GET"); + return this.parseResponse(moneriumIbanSchema, response.body, "GET /ibans/:iban"); + } + + public async requestIban(request: MoneriumIbanDestinationRequest): Promise { + const body = moneriumIbanDestinationRequestSchema.parse(request); + const response = await this.request("/ibans", "POST", { + acceptedStatuses: [202, 304], + body + }); + return { httpStatus: response.status as 202 | 304 }; + } + + public async updateIbanDestination(iban: string, request: MoneriumIbanDestinationRequest): Promise { + const body = moneriumIbanDestinationRequestSchema.parse(request); + await this.request(`/ibans/${encodeURIComponent(iban)}`, "PATCH", { body }); + } + + public async listOrders( + filters: { address?: string; memo?: string; profile?: string; state?: MoneriumOrderFilterState; txHash?: string } = {} + ): Promise { + const response = await this.request("/orders", "GET", { query: filters }); + return this.parseResponse(moneriumListOrdersResponseSchema, response.body, "GET /orders"); + } + + public async getOrder(orderId: string): Promise { + const response = await this.request(`/orders/${encodeURIComponent(orderId)}`, "GET"); + return this.parseResponse(moneriumOrderSchema, response.body, "GET /orders/:order"); + } + + public async createRedemptionOrder(request: MoneriumRedeemOrderRequest): Promise { + const body = moneriumRedeemOrderRequestSchema.parse(request); + const response = await this.request("/orders", "POST", { + acceptedStatuses: [200, 202], + body + }); + if (response.status === 200) { + return { httpStatus: 200, order: this.parseResponse(moneriumOrderSchema, response.body, "POST /orders") }; + } + return { + httpStatus: 202, + ...this.parseResponse(moneriumAcceptedResponseSchema, response.body, "POST /orders") + }; + } + + public async uploadFile(file: Blob, fileName?: string): Promise { + const name = fileName ?? (file instanceof File ? file.name : "upload"); + if (name.length === 0 || name.length > 100) throw new Error("Monerium filenames must contain 1 to 100 characters"); + if (file.size > MAX_FILE_SIZE_BYTES) throw new Error("Monerium files must not exceed 5 MB"); + if (!ALLOWED_FILE_TYPES.has(file.type)) throw new Error("Monerium files must be PDF or JPEG"); + + const form = new FormData(); + form.append("file", file, name); + const response = await this.request("/files", "POST", { body: form }); + return this.parseResponse(moneriumUploadedFileSchema, response.body, "POST /files"); + } + + public async createWebhook(request: MoneriumCreateWebhookRequest): Promise { + const body = moneriumCreateWebhookRequestSchema.parse(request); + const response = await this.request("/webhooks", "POST", { + acceptedStatuses: [201], + body + }); + return this.parseResponse(moneriumWebhookSubscriptionSchema, response.body, "POST /webhooks"); + } + + public async listWebhooks(): Promise { + const response = await this.request("/webhooks", "GET"); + return this.parseResponse(moneriumListWebhooksResponseSchema, response.body, "GET /webhooks"); + } + + public async updateWebhook( + subscriptionId: string, + request: MoneriumUpdateWebhookRequest + ): Promise { + const body = moneriumUpdateWebhookRequestSchema.parse(request); + const response = await this.request( + `/webhooks/${encodeURIComponent(subscriptionId)}`, + "PATCH", + { body } + ); + return this.parseResponse(moneriumWebhookSubscriptionSchema, response.body, "PATCH /webhooks/:subscription"); + } +} diff --git a/packages/shared/src/services/monerium/schemas.test.ts b/packages/shared/src/services/monerium/schemas.test.ts new file mode 100644 index 000000000..f9ea717c0 --- /dev/null +++ b/packages/shared/src/services/monerium/schemas.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from "bun:test"; +import { + moneriumAccessTokenResponseSchema, + moneriumAddressSchema, + moneriumCreateWebhookRequestSchema, + moneriumIbanSchema, + moneriumLinkAddressRequestSchema, + moneriumListOrdersResponseSchema, + moneriumProfileSchema, + moneriumRedeemOrderRequestSchema, + moneriumUploadedFileSchema, + moneriumWebhookEventSchema +} from "./schemas"; +import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE } from "./types"; + +const PROFILE_ID = "123e4567-e89b-42d3-a456-426614174000"; +const RESOURCE_ID = "223e4567-e89b-42d3-a456-426614174001"; +const ADDRESS = "0x59cFC408d310697f9D3598e1BE75B0157a072407"; +const IBAN = "EE521273842688571285"; + +function profile() { + return { + details: { state: "approved" }, + form: { state: "approved" }, + id: PROFILE_ID, + kind: "personal", + name: "Jane Doe", + state: "approved", + unknownProviderField: true, + verifications: [{ kind: "idDocument", state: "approved" }] + }; +} + +function order() { + return { + address: ADDRESS, + amount: "100.00", + chain: "ethereum", + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: IBAN, standard: "iban" } + }, + currency: "eur", + id: RESOURCE_ID, + kind: "redeem", + memo: "Powered by Monerium", + meta: { placedAt: "2026-08-25T12:00:00Z" }, + profile: PROFILE_ID, + state: "placed" + }; +} + +function redeemRequest(amount = "100.00") { + const timestamp = `${new Date(Date.now() + 60_000).toISOString().slice(0, 16)}Z`; + return { + address: ADDRESS, + amount, + chain: "ethereum", + counterpart: { + details: { country: "EE", firstName: "Jane", lastName: "Doe" }, + identifier: { iban: IBAN, standard: "iban" } + }, + currency: "eur", + kind: "redeem", + message: `Send EUR ${amount} to ${IBAN} at ${timestamp}`, + signature: `0x${"ab".repeat(65)}` + }; +} + +describe("Monerium profile and token schemas", () => { + test("accept documented responses with unknown fields", () => { + expect( + moneriumAccessTokenResponseSchema.safeParse({ + access_token: "access-token", + expires_in: 3600, + scope: "openid", + token_type: "Bearer" + }).success + ).toBe(true); + expect(moneriumProfileSchema.safeParse(profile()).success).toBe(true); + }); + + test("reject missing compliance state and unknown provider enums", () => { + const missingDetails = profile(); + delete (missingDetails as Record).details; + expect(moneriumProfileSchema.safeParse(missingDetails).success).toBe(false); + expect(moneriumProfileSchema.safeParse({ ...profile(), state: "suspended" }).success).toBe(false); + }); +}); + +describe("Monerium address and IBAN schemas", () => { + test("accepts combined off-chain EIP-1271 signature bytes as opaque hex", () => { + const combinedSignature = `0x${"12".repeat(130)}`; + expect( + moneriumLinkAddressRequestSchema.safeParse({ + address: ADDRESS, + chain: "ethereum", + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature: combinedSignature + }).success + ).toBe(true); + }); + + test("accepts the on-chain EIP-1271 marker but rejects altered messages and odd hex", () => { + const base = { + address: ADDRESS, + chain: "ethereum", + message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + profile: PROFILE_ID, + signature: "0x" + }; + expect(moneriumLinkAddressRequestSchema.safeParse(base).success).toBe(true); + expect(moneriumLinkAddressRequestSchema.safeParse({ ...base, message: `${base.message} ` }).success).toBe(false); + expect(moneriumLinkAddressRequestSchema.safeParse({ ...base, signature: "0x123" }).success).toBe(false); + }); + + test("validates linked address and IBAN response identifiers", () => { + expect(moneriumAddressSchema.safeParse({ address: ADDRESS, chains: ["ethereum"], profile: PROFILE_ID }).success).toBe( + true + ); + expect( + moneriumIbanSchema.safeParse({ + address: ADDRESS, + bic: "CBHFLU2LXXX", + chain: "ethereum", + iban: IBAN, + name: "Jane Doe", + profile: PROFILE_ID + }).success + ).toBe(true); + expect(moneriumIbanSchema.safeParse({ address: ADDRESS, iban: "not-an-iban" }).success).toBe(false); + }); +}); + +describe("Monerium order schemas", () => { + test("pins the exact signed SEPA message and decimal amount representation", () => { + const request = redeemRequest(); + expect(moneriumRedeemOrderRequestSchema.safeParse(request).success).toBe(true); + expect( + moneriumRedeemOrderRequestSchema.safeParse({ + ...request, + message: request.message.replace(IBAN, `${IBAN.slice(0, 4)}...${IBAN.slice(-4)}`) + }).success + ).toBe(true); + expect( + moneriumRedeemOrderRequestSchema.safeParse({ ...redeemRequest(), message: `Send EUR 100 to ${IBAN}` }).success + ).toBe(false); + expect(moneriumRedeemOrderRequestSchema.safeParse(redeemRequest("100.001")).success).toBe(false); + }); + + test("requires supporting evidence at the EUR 15,000 threshold", () => { + expect(moneriumRedeemOrderRequestSchema.safeParse(redeemRequest("14999.99")).success).toBe(true); + expect(moneriumRedeemOrderRequestSchema.safeParse(redeemRequest("15000.00")).success).toBe(false); + expect( + moneriumRedeemOrderRequestSchema.safeParse({ + ...redeemRequest("15000.00"), + supportingDocumentId: RESOURCE_ID + }).success + ).toBe(true); + }); + + test("accepts documented order envelopes and rejects provider state drift", () => { + expect(moneriumListOrdersResponseSchema.safeParse({ orders: [order()] }).success).toBe(true); + expect(moneriumListOrdersResponseSchema.safeParse({ orders: [{ ...order(), state: "settled" }] }).success).toBe(false); + }); +}); + +describe("Monerium file and webhook schemas", () => { + test("validates uploaded-file metadata", () => { + expect( + moneriumUploadedFileSchema.safeParse({ + hash: "sha256:abc", + id: RESOURCE_ID, + meta: { + createdAt: "2026-08-25T12:00:00Z", + updatedAt: "2026-08-25T12:00:00Z", + uploadedBy: PROFILE_ID + }, + name: "evidence.pdf", + size: 1234, + type: "application/pdf" + }).success + ).toBe(true); + }); + + test("requires HTTPS and a 24-64 byte webhook secret", () => { + const secret = `whsec_${btoa("a".repeat(32))}`; + expect(moneriumCreateWebhookRequestSchema.safeParse({ secret, url: "https://example.com/monerium" }).success).toBe( + true + ); + expect(moneriumCreateWebhookRequestSchema.safeParse({ secret: "whsec_dGlueQ==", url: "http://example.com" }).success).toBe( + false + ); + }); + + test("accepts partial profile webhook snapshots and rejects unknown event types", () => { + expect( + moneriumWebhookEventSchema.safeParse({ + data: { id: PROFILE_ID, kind: "personal", state: "approved" }, + timestamp: "2026-08-25T12:00:00Z", + type: "profile.updated" + }).success + ).toBe(true); + expect( + moneriumWebhookEventSchema.safeParse({ timestamp: "2026-08-25T12:00:00Z", type: "profile.deleted" }).success + ).toBe(false); + }); + + test("accepts the documented partial iban.updated snapshot", () => { + expect( + moneriumWebhookEventSchema.safeParse({ + data: { + address: ADDRESS, + chain: "ethereum", + iban: "EE52 1273 8426 8857 1285", + profile: PROFILE_ID, + state: "approved" + }, + timestamp: "2026-08-25T12:00:00Z", + type: "iban.updated" + }).success + ).toBe(true); + }); +}); diff --git a/packages/shared/src/services/monerium/schemas.ts b/packages/shared/src/services/monerium/schemas.ts new file mode 100644 index 000000000..7c565859c --- /dev/null +++ b/packages/shared/src/services/monerium/schemas.ts @@ -0,0 +1,320 @@ +import { z } from "zod"; +import { + MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, + MONERIUM_CHAINS, + MONERIUM_ORDER_FILTER_STATES, + MONERIUM_ORDER_STATES, + MONERIUM_PROFILE_KINDS, + MONERIUM_PROFILE_STATES, + MONERIUM_SECTION_STATES, + MONERIUM_VERIFICATION_KINDS, + MONERIUM_WEBHOOK_TYPES, + type MoneriumAccessTokenResponse, + type MoneriumAddress, + type MoneriumCreateWebhookRequest, + type MoneriumIban, + type MoneriumIbanDestinationRequest, + type MoneriumIbanUpdatedData, + type MoneriumLinkAddressRequest, + type MoneriumListAddressesResponse, + type MoneriumListIbansResponse, + type MoneriumListOrdersResponse, + type MoneriumListProfilesResponse, + type MoneriumListWebhooksResponse, + type MoneriumOrder, + type MoneriumProfile, + type MoneriumProfileSummary, + type MoneriumRedeemOrderRequest, + type MoneriumUpdateWebhookRequest, + type MoneriumUploadedFile, + type MoneriumWebhookEvent, + type MoneriumWebhookSubscription +} from "./types"; + +/** + * Monerium API v2 wire contracts consumed by Vortex. Unknown provider fields pass; + * missing or renamed consumed fields fail. Request schemas additionally pin the + * signing-message and threshold semantics that must agree with the submitted signature. + */ + +const UUID = z.string().uuid(); +const EVM_ADDRESS = z.string().regex(/^0x[0-9a-fA-F]{40}$/); +const HEX_BYTES = z.string().regex(/^0x(?:[0-9a-fA-F]{2})*$/); +const IBAN = z.string().regex(/^[A-Z]{2}[0-9A-Z ]{13,32}$/); +const NORMALIZED_IBAN = z.string().regex(/^[A-Z]{2}[0-9A-Z]{13,32}$/); +const DECIMAL_AMOUNT = z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/); +const COUNTRY_CODE = z.string().regex(/^[A-Z]{2}$/); +const RFC3339_MINUTE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/; + +const moneriumChainSchema = z.enum(MONERIUM_CHAINS); +const moneriumTimestampSchema = z.string().datetime({ offset: true }); + +export const moneriumAccessTokenResponseSchema = z.looseObject({ + access_token: z.string().min(1), + expires_in: z.number().int().positive(), + refresh_token: z.string().min(1).optional(), + token_type: z.string().min(1) +}) satisfies z.ZodType; + +export const moneriumProfileSummarySchema = z.looseObject({ + id: UUID, + kind: z.enum(MONERIUM_PROFILE_KINDS), + name: z.string().min(1), + state: z.enum(MONERIUM_PROFILE_STATES) +}) satisfies z.ZodType; + +export const moneriumProfileSchema = moneriumProfileSummarySchema.extend({ + details: z.looseObject({ state: z.enum(MONERIUM_SECTION_STATES) }), + form: z.looseObject({ state: z.enum(MONERIUM_SECTION_STATES) }), + verifications: z.array( + z.looseObject({ + kind: z.enum(MONERIUM_VERIFICATION_KINDS), + state: z.enum(MONERIUM_SECTION_STATES) + }) + ) +}) satisfies z.ZodType; + +export const moneriumListProfilesResponseSchema = z.looseObject({ + profiles: z.array(moneriumProfileSummarySchema) +}) satisfies z.ZodType; + +export const moneriumAddressSchema = z.looseObject({ + address: EVM_ADDRESS, + chains: z.array(moneriumChainSchema), + profile: UUID +}) satisfies z.ZodType; + +export const moneriumListAddressesResponseSchema = z.looseObject({ + addresses: z.array(moneriumAddressSchema) +}) satisfies z.ZodType; + +export const moneriumLinkAddressRequestSchema = z.looseObject({ + address: EVM_ADDRESS, + chain: moneriumChainSchema, + message: z.literal(MONERIUM_ADDRESS_OWNERSHIP_MESSAGE), + profile: UUID, + signature: HEX_BYTES +}) satisfies z.ZodType; + +export const moneriumAcceptedResponseSchema = z.looseObject({ + code: z.literal(202), + status: z.literal("Accepted") +}); + +export const moneriumIbanSchema = z.looseObject({ + address: EVM_ADDRESS, + bic: z.string().min(8).max(11), + chain: moneriumChainSchema, + iban: IBAN, + name: z.string().min(1), + profile: UUID +}) satisfies z.ZodType; + +export const moneriumListIbansResponseSchema = z.looseObject({ + ibans: z.array(moneriumIbanSchema) +}) satisfies z.ZodType; + +export const moneriumIbanDestinationRequestSchema = z.looseObject({ + address: EVM_ADDRESS, + chain: moneriumChainSchema +}) satisfies z.ZodType; + +const moneriumIbanIdentifierSchema = z.looseObject({ + iban: NORMALIZED_IBAN, + standard: z.literal("iban") +}); + +const moneriumChainIdentifierSchema = z.looseObject({ + address: EVM_ADDRESS, + chain: moneriumChainSchema, + standard: z.literal("chain") +}); + +const moneriumPersonalDetailsSchema = z.looseObject({ + country: COUNTRY_CODE, + firstName: z.string().min(1), + lastName: z.string().min(1) +}); + +const moneriumCorporateDetailsSchema = z.looseObject({ + companyName: z.string().min(1), + country: COUNTRY_CODE +}); + +function amountRequiresSupportingDocument(amount: string): boolean { + const integer = amount.split(".")[0].replace(/^0+(?=\d)/, ""); + return integer.length > 5 || (integer.length === 5 && integer >= "15000"); +} + +export const moneriumRedeemOrderRequestSchema = z + .looseObject({ + address: EVM_ADDRESS, + amount: DECIMAL_AMOUNT, + chain: moneriumChainSchema, + counterpart: z.looseObject({ + details: z.union([moneriumPersonalDetailsSchema, moneriumCorporateDetailsSchema]), + identifier: moneriumIbanIdentifierSchema + }), + currency: z.literal("eur"), + id: UUID.optional(), + kind: z.literal("redeem"), + memo: z.string().min(5).max(140).optional(), + message: z.string().min(1), + referenceNumber: z.string().max(35).optional(), + signature: HEX_BYTES, + supportingDocumentId: UUID.optional() + }) + .superRefine((request, context) => { + const iban = request.counterpart.identifier.iban; + const destinations = [iban, `${iban.slice(0, 4)}...${iban.slice(-4)}`]; + const prefix = destinations + .map(destination => `Send EUR ${request.amount} to ${destination} at `) + .find(value => request.message.startsWith(value)); + const timestamp = prefix ? request.message.slice(prefix.length) : ""; + const timestampMs = Date.parse(timestamp); + if (!prefix || !RFC3339_MINUTE.test(timestamp) || Number.isNaN(timestampMs)) { + context.addIssue({ + code: "custom", + message: "message must exactly match the Monerium SEPA signing format", + path: ["message"] + }); + } else if (timestampMs < Date.now() - 5 * 60 * 1_000) { + context.addIssue({ + code: "custom", + message: "message timestamp must be no more than five minutes in the past", + path: ["message"] + }); + } + if (amountRequiresSupportingDocument(request.amount) && !request.supportingDocumentId) { + context.addIssue({ + code: "custom", + message: "supportingDocumentId is required for amounts of EUR 15,000 or more", + path: ["supportingDocumentId"] + }); + } + }) satisfies z.ZodType; + +const moneriumOrderSchemaInternal = z.looseObject({ + address: EVM_ADDRESS, + amount: DECIMAL_AMOUNT, + chain: moneriumChainSchema, + counterpart: z.looseObject({ + details: z.looseObject({}).optional(), + identifier: z.union([ + moneriumIbanIdentifierSchema, + moneriumChainIdentifierSchema, + z.looseObject({ standard: z.string().min(1) }) + ]) + }), + currency: z.enum(["eur", "usd", "gbp", "isk"]), + id: UUID, + kind: z.enum(["issue", "redeem"]), + memo: z.string(), + meta: z.looseObject({ + placedAt: moneriumTimestampSchema, + processedAt: moneriumTimestampSchema.optional(), + rejectedReason: z.string().optional(), + supportingDocumentId: UUID.optional(), + txHashes: z.array(z.string().min(1)).optional() + }), + profile: UUID, + referenceNumber: z.string().optional(), + state: z.enum(MONERIUM_ORDER_STATES) +}); + +export const moneriumOrderSchema = moneriumOrderSchemaInternal satisfies z.ZodType; + +export const moneriumListOrdersResponseSchema = z.looseObject({ + orders: z.array(moneriumOrderSchema) +}) satisfies z.ZodType; + +export const moneriumOrderFilterStateSchema = z.enum(MONERIUM_ORDER_FILTER_STATES); + +export const moneriumUploadedFileSchema = z.looseObject({ + hash: z.string().min(1), + id: UUID, + meta: z.looseObject({ + createdAt: moneriumTimestampSchema, + updatedAt: moneriumTimestampSchema, + uploadedBy: z.string().min(1) + }), + name: z.string().min(1), + size: z.number().int().nonnegative(), + type: z.string().min(1) +}) satisfies z.ZodType; + +const moneriumWebhookSecretSchema = z + .string() + .regex(/^whsec_[A-Za-z0-9+/]+={0,2}$/) + .refine(secret => { + try { + const byteLength = atob(secret.slice("whsec_".length)).length; + return byteLength >= 24 && byteLength <= 64; + } catch { + return false; + } + }, "webhook secret must contain 24 to 64 base64-encoded random bytes"); + +export const moneriumCreateWebhookRequestSchema = z.looseObject({ + secret: moneriumWebhookSecretSchema, + types: z.array(z.enum(MONERIUM_WEBHOOK_TYPES)).optional(), + url: z.string().url().startsWith("https://") +}) satisfies z.ZodType; + +export const moneriumWebhookSubscriptionSchema = z.looseObject({ + id: UUID, + state: z.enum(["active", "inactive"]), + types: z.array(z.enum(MONERIUM_WEBHOOK_TYPES)), + url: z.string().url() +}) satisfies z.ZodType; + +export const moneriumListWebhooksResponseSchema = z.looseObject({ + subscriptions: z.array(moneriumWebhookSubscriptionSchema) +}) satisfies z.ZodType; + +export const moneriumUpdateWebhookRequestSchema = z + .looseObject({ + state: z.enum(["active", "inactive"]).optional(), + types: z.array(z.enum(MONERIUM_WEBHOOK_TYPES)).optional() + }) + .refine( + request => request.state !== undefined || request.types !== undefined, + "at least one update field is required" + ) satisfies z.ZodType; + +const moneriumWebhookProfileSchema = z.looseObject({ + id: UUID, + kind: z.enum(MONERIUM_PROFILE_KINDS), + state: z.enum(MONERIUM_PROFILE_STATES) +}); + +export const moneriumIbanUpdatedDataSchema = z.looseObject({ + address: EVM_ADDRESS, + bic: z.string().min(8).max(11).optional(), + chain: moneriumChainSchema, + iban: IBAN, + name: z.string().min(1).optional(), + profile: UUID, + state: z.string().min(1).optional() +}) satisfies z.ZodType; + +export const moneriumWebhookEventSchema = z.discriminatedUnion("type", [ + z.looseObject({ timestamp: moneriumTimestampSchema, type: z.literal("subscription.created") }), + z.looseObject({ data: moneriumOrderSchema, timestamp: moneriumTimestampSchema, type: z.literal("order.created") }), + z.looseObject({ data: moneriumOrderSchema, timestamp: moneriumTimestampSchema, type: z.literal("order.updated") }), + z.looseObject({ data: moneriumWebhookProfileSchema, timestamp: moneriumTimestampSchema, type: z.literal("profile.updated") }), + z.looseObject({ + data: z.looseObject({ + errors: z.array(z.looseObject({ field: z.string().min(1), reason: z.string().min(1) })), + id: UUID, + kind: z.enum(MONERIUM_PROFILE_KINDS) + }), + timestamp: moneriumTimestampSchema, + type: z.literal("profile.error") + }), + z.looseObject({ + data: moneriumIbanUpdatedDataSchema, + timestamp: moneriumTimestampSchema, + type: z.literal("iban.updated") + }) +]) satisfies z.ZodType; diff --git a/packages/shared/src/services/monerium/types.ts b/packages/shared/src/services/monerium/types.ts new file mode 100644 index 000000000..6b5cde829 --- /dev/null +++ b/packages/shared/src/services/monerium/types.ts @@ -0,0 +1,263 @@ +export const MONERIUM_PROFILE_STATES = ["created", "incomplete", "pending", "approved", "rejected"] as const; +export type MoneriumProfileState = (typeof MONERIUM_PROFILE_STATES)[number]; + +export const MONERIUM_PROFILE_KINDS = ["personal", "corporate"] as const; +export type MoneriumProfileKind = (typeof MONERIUM_PROFILE_KINDS)[number]; + +export const MONERIUM_SECTION_STATES = ["incomplete", "pending", "approved", "rejected"] as const; +export type MoneriumSectionState = (typeof MONERIUM_SECTION_STATES)[number]; + +export const MONERIUM_VERIFICATION_KINDS = [ + "idDocument", + "facialSimilarity", + "proofOfResidency", + "sourceOfFunds", + "corporateName", + "corporateAddress", + "registrationNumber", + "dateOfRegistration", + "beneficialOwnership", + "powerOfAttorney" +] as const; +export type MoneriumVerificationKind = (typeof MONERIUM_VERIFICATION_KINDS)[number]; + +export const MONERIUM_CHAINS = [ + "ethereum", + "gnosis", + "polygon", + "arbitrum", + "linea", + "base", + "noble", + "sepolia", + "chiado", + "amoy", + "arbitrumsepolia", + "lineasepolia", + "basesepolia", + "grand" +] as const; +export type MoneriumChain = (typeof MONERIUM_CHAINS)[number]; + +export const MONERIUM_ORDER_STATES = ["placed", "pending", "processed", "rejected"] as const; +export type MoneriumOrderState = (typeof MONERIUM_ORDER_STATES)[number]; + +export const MONERIUM_ORDER_FILTER_STATES = ["pending", "processed", "rejected"] as const; +export type MoneriumOrderFilterState = (typeof MONERIUM_ORDER_FILTER_STATES)[number]; + +export const MONERIUM_WEBHOOK_TYPES = [ + "iban.updated", + "order.created", + "order.updated", + "profile.error", + "profile.updated" +] as const; +export type MoneriumWebhookType = (typeof MONERIUM_WEBHOOK_TYPES)[number]; + +export const MONERIUM_ADDRESS_OWNERSHIP_MESSAGE = "I hereby declare that I am the address owner."; + +export interface MoneriumAccessTokenResponse { + access_token: string; + expires_in: number; + refresh_token?: string; + token_type: string; +} + +export interface MoneriumProfileSummary { + id: string; + kind: MoneriumProfileKind; + name: string; + state: MoneriumProfileState; +} + +export interface MoneriumProfile extends MoneriumProfileSummary { + details: { state: MoneriumSectionState }; + form: { state: MoneriumSectionState }; + verifications: Array<{ kind: MoneriumVerificationKind; state: MoneriumSectionState }>; +} + +export interface MoneriumListProfilesResponse { + profiles: MoneriumProfileSummary[]; +} + +export interface MoneriumAddress { + address: string; + chains: MoneriumChain[]; + profile: string; +} + +export interface MoneriumListAddressesResponse { + addresses: MoneriumAddress[]; +} + +export interface MoneriumLinkAddressRequest { + address: string; + chain: MoneriumChain; + message: typeof MONERIUM_ADDRESS_OWNERSHIP_MESSAGE; + profile: string; + /** EOA signature, combined off-chain EIP-1271 signature bytes, or `0x` for on-chain EIP-1271 approval. */ + signature: string; +} + +export interface MoneriumAcceptedResponse { + code: 202; + status: "Accepted"; +} + +export type MoneriumLinkAddressResult = { httpStatus: 201 } | ({ httpStatus: 202 } & MoneriumAcceptedResponse); + +export interface MoneriumIban { + address: string; + bic: string; + chain: MoneriumChain; + iban: string; + name: string; + profile: string; +} + +export interface MoneriumIbanUpdatedData { + address: string; + bic?: string; + chain: MoneriumChain; + iban: string; + name?: string; + profile: string; + state?: string; +} + +export interface MoneriumListIbansResponse { + ibans: MoneriumIban[]; +} + +export interface MoneriumIbanDestinationRequest { + address: string; + chain: MoneriumChain; +} + +export type MoneriumRequestIbanResult = { httpStatus: 202 | 304 }; + +export interface MoneriumIbanIdentifier { + iban: string; + standard: "iban"; +} + +export interface MoneriumChainIdentifier { + address: string; + chain: MoneriumChain; + standard: "chain"; +} + +export interface MoneriumPersonalCounterpartDetails { + country: string; + firstName: string; + lastName: string; +} + +export interface MoneriumCorporateCounterpartDetails { + companyName: string; + country: string; +} + +export interface MoneriumRedeemOrderRequest { + address: string; + amount: string; + chain: MoneriumChain; + counterpart: { + details: MoneriumPersonalCounterpartDetails | MoneriumCorporateCounterpartDetails; + identifier: MoneriumIbanIdentifier; + }; + currency: "eur"; + id?: string; + kind: "redeem"; + memo?: string; + message: string; + referenceNumber?: string; + signature: string; + supportingDocumentId?: string; +} + +export interface MoneriumOrder { + address: string; + amount: string; + chain: MoneriumChain; + counterpart: { + details?: Record; + identifier: MoneriumIbanIdentifier | MoneriumChainIdentifier | ({ standard: string } & Record); + }; + currency: "eur" | "usd" | "gbp" | "isk"; + id: string; + kind: "issue" | "redeem"; + memo: string; + meta: { + placedAt: string; + processedAt?: string; + rejectedReason?: string; + supportingDocumentId?: string; + txHashes?: string[]; + }; + profile: string; + referenceNumber?: string; + state: MoneriumOrderState; +} + +export interface MoneriumListOrdersResponse { + orders: MoneriumOrder[]; +} + +export type MoneriumCreateOrderResult = + | { httpStatus: 200; order: MoneriumOrder } + | ({ httpStatus: 202 } & MoneriumAcceptedResponse); + +export interface MoneriumUploadedFile { + hash: string; + id: string; + meta: { + createdAt: string; + updatedAt: string; + uploadedBy: string; + }; + name: string; + size: number; + type: string; +} + +export interface MoneriumCreateWebhookRequest { + secret: string; + types?: MoneriumWebhookType[]; + url: string; +} + +export interface MoneriumWebhookSubscription { + id: string; + state: "active" | "inactive"; + types: MoneriumWebhookType[]; + url: string; +} + +export interface MoneriumListWebhooksResponse { + subscriptions: MoneriumWebhookSubscription[]; +} + +export interface MoneriumUpdateWebhookRequest { + state?: "active" | "inactive"; + types?: MoneriumWebhookType[]; +} + +export type MoneriumWebhookEvent = + | { timestamp: string; type: "subscription.created" } + | { data: MoneriumOrder; timestamp: string; type: "order.created" | "order.updated" } + | { + data: Pick; + timestamp: string; + type: "profile.updated"; + } + | { + data: { + errors: Array<{ field: string; reason: string }>; + id: string; + kind: MoneriumProfileKind; + }; + timestamp: string; + type: "profile.error"; + } + | { data: MoneriumIbanUpdatedData; timestamp: string; type: "iban.updated" };