diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts index db25244c6..6419f2065 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -5,19 +5,23 @@ 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 MoneriumConversionExecution, { + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.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"; +import { refundAccountFor } from "../../services/monerium-b2b/refund-wallet"; 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", () => { @@ -68,7 +72,6 @@ describe("monerium b2b account mapping admin route", () => { contactEmail: "ops@client.example.com", destination: DESTINATION, externalSubjectId: "client-1", - fallbackAddress: FALLBACK, forwarderAddress: FORWARDER, managerProfileId, moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e", @@ -122,9 +125,9 @@ describe("monerium b2b account mapping admin route", () => { const row = await MoneriumAccount.findByPk(account.accountId); expect(row).toMatchObject({ destination: DESTINATION, - fallbackAddress: FALLBACK, - feeBps: 0, + floorPpm: 1500, forwarderAddress: FORWARDER, + targetPpm: 1250, vortexProfileId: account.profileId }); }); @@ -149,8 +152,6 @@ describe("monerium b2b account mapping admin route", () => { const managerProfileId = await createManager(); await MoneriumAccount.create({ destination: DESTINATION, - fallbackAddress: FALLBACK, - feeBps: 0, forwarderAddress: FORWARDER, profileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" }); @@ -191,8 +192,8 @@ describe("monerium b2b account mapping admin route", () => { ); expect(differentSubject.status).toBe(409); - // Same everything, different feeBps: divergence, not a silent idempotent replay. - const differentFee = await post(validBody(managerProfileId, { feeBps: 25 })); + // Same everything, different fee policy: divergence, not a silent idempotent replay. + const differentFee = await post(validBody(managerProfileId, { targetPpm: 1_000 })); expect(differentFee.status).toBe(409); expect(await MoneriumAccount.count()).toBe(1); @@ -203,20 +204,51 @@ describe("monerium b2b account mapping admin route", () => { }); it("compares submitted account data against the deployed clone config", () => { + const refundWallet = "0x9999999999999999999999999999999999999999"; const expected = { destination: DESTINATION.toLowerCase(), factory: FACTORY.toLowerCase(), - fallbackAddress: FALLBACK.toLowerCase(), - feeBps: 0 + floorPpm: 1500, + recoveryAddress: refundWallet, + targetPpm: 1250 + }; + const matching = { + destination: DESTINATION, + factory: FACTORY, + floorPpm: 1500, + isForwarder: true, + recoveryAddress: refundWallet, + targetPpm: 1250 }; - 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"); + expect( + forwarderConfigMismatch(expected, { ...matching, destination: "0x3333333333333333333333333333333333333333" }) + ).toContain("destination"); + expect( + forwarderConfigMismatch(expected, { ...matching, recoveryAddress: "0x7777777777777777777777777777777777777777" }) + ).toContain("refund wallet"); + expect(forwarderConfigMismatch(expected, { ...matching, targetPpm: 1_000 })).toContain("targetPpm"); + expect(forwarderConfigMismatch(expected, { ...matching, floorPpm: 2_000 })).toContain("floorPpm"); + }); + + it("returns a client's derived refund wallet for deploying its forwarder", async () => { + const savedSeed = config.moneriumB2b.refundSeed; + config.moneriumB2b.refundSeed = `0x${"11".repeat(32)}`; + try { + const profileId = "0B8E7C2A-8F4E-4D43-9F2B-2F9F3C1D5A6E"; + const response = await fetch(`${baseUrl}/refund-address?moneriumProfileId=${profileId}`, { headers: ADMIN_HEADERS }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + moneriumProfileId: profileId.toLowerCase(), + refundAddress: refundAccountFor(profileId.toLowerCase()).address + }); + expect((await fetch(`${baseUrl}/refund-address?moneriumProfileId=nope`, { headers: ADMIN_HEADERS })).status).toBe(400); + } finally { + config.moneriumB2b.refundSeed = savedSeed; + } }); it("rejects invalid input and unknown managers", async () => { @@ -225,10 +257,11 @@ describe("monerium b2b account mapping admin route", () => { for (const overrides of [ { forwarderAddress: "not-an-address" }, { destination: "0x12345" }, - { fallbackAddress: "" }, { moneriumProfileId: "not-a-uuid" }, - { feeBps: 3.5 }, - { feeBps: -1 }, + { targetPpm: 3.5 }, + { floorPpm: -1 }, + { floorPpm: 10_001 }, + { floorPpm: 1_000, targetPpm: 1_200 }, { externalSubjectId: "" }, { contactEmail: "not-an-email" } ]) { @@ -291,6 +324,88 @@ describe("monerium b2b account mapping admin route", () => { expect((await patchStatus(crypto.randomUUID(), "active")).status).toBe(404); }); + it("marks a settling deposit for recovery and lets an operator close or retry it", async () => { + const managerProfileId = await createManager(); + const created = await post(validBody(managerProfileId)); + const { account } = (await created.json()) as { account: { accountId: string } }; + const deposit = await MoneriumFiatDeposit.create({ + accountId: account.accountId, + amountRaw: "100000000000000000000", + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Converting, + txHash: "0xmint" + }); + const recover = (depositId: string) => + fetch(`${baseUrl}/deposits/${depositId}/recover`, { headers: ADMIN_HEADERS, method: "POST" }); + const patchStatus = (depositId: string, status: unknown) => + fetch(`${baseUrl}/deposits/${depositId}/status`, { + body: JSON.stringify({ status }), + headers: ADMIN_HEADERS, + method: "PATCH" + }); + + // A pending keeper transaction must settle first: the amounts to recover depend on it. + const pending = await MoneriumConversionExecution.create({ + accountId: account.accountId, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: "60000000000000000000", + status: MoneriumConversionExecutionStatus.Pending + }); + const blocked = await recover(deposit.id); + expect(blocked.status).toBe(409); + expect(await blocked.json()).toMatchObject({ error: { message: expect.stringContaining("pending execution") } }); + await pending.update({ status: MoneriumConversionExecutionStatus.Failed }); + + const badReason = await fetch(`${baseUrl}/deposits/${deposit.id}/recover`, { + body: JSON.stringify({ reason: "because" }), + headers: ADMIN_HEADERS, + method: "POST" + }); + expect(badReason.status).toBe(400); + + const marked = await fetch(`${baseUrl}/deposits/${deposit.id}/recover`, { + body: JSON.stringify({ reason: "compliance" }), + headers: ADMIN_HEADERS, + method: "POST" + }); + expect(marked.status).toBe(200); + expect(await marked.json()).toMatchObject({ deposit: { depositId: deposit.id, status: "recovering" } }); + const markedRow = await MoneriumFiatDeposit.findByPk(deposit.id); + expect(markedRow?.status).toBe(MoneriumFiatDepositStatus.Recovering); + expect(markedRow?.refundReason).toBe("compliance"); + expect(markedRow?.refundStartedAt).not.toBeNull(); + + // Forward-only: a recovering deposit cannot be marked again, but closes or retries. + expect((await recover(deposit.id)).status).toBe(409); + expect((await patchStatus(deposit.id, "forwarded")).status).toBe(400); + const failed = await patchStatus(deposit.id, "recovery_failed"); + expect(failed.status).toBe(200); + const retried = await patchStatus(deposit.id, "recovering"); + expect(retried.status).toBe(200); + const refunded = await patchStatus(deposit.id, "refunded"); + expect(refunded.status).toBe(200); + expect(await refunded.json()).toEqual({ deposit: { depositId: deposit.id, status: "refunded" } }); + expect((await patchStatus(deposit.id, "refunded")).status).toBe(200); + const reopened = await patchStatus(deposit.id, "recovering"); + expect(reopened.status).toBe(409); + expect(await reopened.json()).toMatchObject({ + error: { + code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", + message: "Monerium deposit cannot transition from refunded to recovering" + } + }); + expect((await MoneriumFiatDeposit.findByPk(deposit.id))?.status).toBe(MoneriumFiatDepositStatus.Refunded); + expect((await patchStatus(crypto.randomUUID(), "refunded")).status).toBe(404); + + expect((await recover(crypto.randomUUID())).status).toBe(404); + expect((await recover("not-a-uuid")).status).toBe(400); + }); + it("refuses managers not allowed to provision business customers", async () => { const profile = await createTestUser(); await ManagedProfileManager.create({ diff --git a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts index 463239d42..3e8f89a14 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -2,10 +2,14 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../../config/logger"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import { sendError } from "../../helpers/sendError"; import { UUID_PATTERN } from "../../helpers/uuid"; import { ManagedProfileProvisioningError } from "../../services/managed-profile-provisioning.service"; import { MoneriumB2bProvisioningError, provisionMoneriumB2bAccount } from "../../services/monerium-b2b/account-provisioning"; +import { markDepositForRecovery } from "../../services/monerium-b2b/conversion-executor"; +import { setDepositStatus } from "../../services/monerium-b2b/recovery"; +import { refundAccountFor } from "../../services/monerium-b2b/refund-wallet"; export async function postMoneriumB2bAccount(req: Request, res: Response): Promise { try { @@ -13,11 +17,11 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi contactEmail, destination, externalSubjectId, - fallbackAddress, - feeBps, + floorPpm, forwarderAddress, managerProfileId, - moneriumProfileId + moneriumProfileId, + targetPpm } = req.body ?? {}; if ( typeof managerProfileId !== "string" || @@ -29,14 +33,14 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi typeof contactEmail !== "string" || typeof forwarderAddress !== "string" || typeof destination !== "string" || - typeof fallbackAddress !== "string" || - (feeBps !== undefined && typeof feeBps !== "number") + (targetPpm !== undefined && typeof targetPpm !== "number") || + (floorPpm !== undefined && typeof floorPpm !== "number") ) { sendError( res, httpStatus.BAD_REQUEST, "MONERIUM_B2B_INVALID_INPUT", - "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; feeBps must be a number when present" + "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, and destination are required; targetPpm and floorPpm must be numbers when present" ); return; } @@ -45,11 +49,11 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi contactEmail, destination, externalSubjectId, - fallbackAddress, - feeBps, + floorPpm, forwarderAddress, managerProfileId, - moneriumProfileId + moneriumProfileId, + targetPpm }); res.status(result.created ? httpStatus.CREATED : httpStatus.OK).json({ account: result }); } catch (error) { @@ -110,8 +114,8 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st ); return; } - // Activation requires the issued IBAN: the penny test (runbook §7) cannot have - // happened without it, and the association monitor needs the reference state. + // Activation requires the issued IBAN: the client cannot pay in without it, and the + // association monitor needs the reference state. if (status === MoneriumAccountStatus.Active && account.iban === null) { sendError( res, @@ -131,3 +135,100 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to update Monerium B2B account status"); } } + +/** + * POST /v1/admin/monerium-b2b/deposits/:depositId/recover — marks a settling deposit for + * the refund path (runbook §2.7). The keeper moves its unconverted EURe and converted + * USDC to the recovery wallet once the clone's batch has been open for RECOVERY_DELAY; + * the bank refund itself follows the runbook until it is automated. + */ +export async function postMoneriumB2bDepositRecovery(req: Request<{ depositId: string }>, res: Response): Promise { + try { + if (!UUID_PATTERN.test(req.params.depositId)) { + sendError(res, httpStatus.BAD_REQUEST, "MONERIUM_B2B_INVALID_INPUT", "depositId must be a UUID"); + return; + } + // Partner-visible reason (DEPOSIT_UPDATED refund.reason); a missed window is marked by the deadline job. + const reason = req.body?.reason ?? "operator"; + if (!["compliance", "incident", "operator"].includes(reason)) { + sendError(res, httpStatus.BAD_REQUEST, "MONERIUM_B2B_INVALID_INPUT", "reason must be compliance, incident or operator"); + return; + } + const refusal = await markDepositForRecovery(req.params.depositId, reason); + if (refusal === "deposit not found") { + sendError(res, httpStatus.NOT_FOUND, "MONERIUM_B2B_DEPOSIT_NOT_FOUND", "Monerium deposit not found"); + return; + } + if (refusal) { + sendError(res, httpStatus.CONFLICT, "MONERIUM_B2B_INVALID_STATUS_TRANSITION", refusal); + return; + } + res + .status(httpStatus.OK) + .json({ deposit: { depositId: req.params.depositId, status: MoneriumFiatDepositStatus.Recovering } }); + } catch (error) { + logger.error("Error marking Monerium B2B deposit for recovery:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to mark the deposit for recovery"); + } +} + +const OPERATOR_DEPOSIT_STATUSES: readonly string[] = [ + MoneriumFiatDepositStatus.Refunded, + MoneriumFiatDepositStatus.RecoveryFailed, + MoneriumFiatDepositStatus.Recovering +]; + +/** + * PATCH /v1/admin/monerium-b2b/deposits/:depositId/status — closes or retries a + * recovery by hand: `refunded` once the bank refund went out, `recovery_failed` when it + * cannot, `recovering` to retry a failed one. Forward-only like every deposit transition. + */ +export async function patchMoneriumB2bDepositStatus(req: Request<{ depositId: string }>, res: Response): Promise { + try { + const { status } = req.body ?? {}; + if (!UUID_PATTERN.test(req.params.depositId) || typeof status !== "string" || !OPERATOR_DEPOSIT_STATUSES.includes(status)) { + sendError( + res, + httpStatus.BAD_REQUEST, + "MONERIUM_B2B_INVALID_INPUT", + `depositId must be a UUID and status must be one of ${OPERATOR_DEPOSIT_STATUSES.join(", ")}` + ); + return; + } + const deposit = await MoneriumFiatDeposit.findByPk(req.params.depositId); + if (!deposit) { + sendError(res, httpStatus.NOT_FOUND, "MONERIUM_B2B_DEPOSIT_NOT_FOUND", "Monerium deposit not found"); + return; + } + const targetStatus = status as MoneriumFiatDepositStatus; + const refusal = await setDepositStatus(deposit, targetStatus); + if (refusal) { + sendError(res, httpStatus.CONFLICT, "MONERIUM_B2B_INVALID_STATUS_TRANSITION", refusal); + return; + } + res.status(httpStatus.OK).json({ deposit: { depositId: deposit.id, status: targetStatus } }); + } catch (error) { + logger.error("Error updating Monerium B2B deposit status:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to update Monerium B2B deposit status"); + } +} + +/** + * GET /v1/admin/monerium-b2b/refund-address?moneriumProfileId= — the client's derived + * refund wallet, to pass as `recoveryAddress` when deploying its forwarder (runbook §1.2). + * Returns the address only; the key never leaves the backend. + */ +export async function getMoneriumB2bRefundAddress(req: Request, res: Response): Promise { + const moneriumProfileId = req.query.moneriumProfileId; + if (typeof moneriumProfileId !== "string" || !UUID_PATTERN.test(moneriumProfileId)) { + sendError(res, httpStatus.BAD_REQUEST, "MONERIUM_B2B_INVALID_INPUT", "moneriumProfileId must be a UUID"); + return; + } + try { + const profileId = moneriumProfileId.toLowerCase(); + res.status(httpStatus.OK).json({ moneriumProfileId: profileId, refundAddress: refundAccountFor(profileId).address }); + } catch (error) { + logger.error("Error deriving a Monerium B2B refund address:", error); + sendError(res, httpStatus.SERVICE_UNAVAILABLE, "MONERIUM_B2B_NOT_CONFIGURED", "MONERIUM_B2B_REFUND_SEED is not configured"); + } +} diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index 13a48f67f..0fcd87c51 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -3,14 +3,16 @@ import httpStatus from "http-status"; import { Op } from "sequelize"; import logger from "../../config/logger"; import { config } from "../../config/vars"; +import ManagedProfile from "../../models/managedProfile.model"; +import ManagedProfileManager from "../../models/managedProfileManager.model"; 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 { sendError } from "../helpers/sendError"; -import { getEffectiveUserId } from "../middlewares/effectiveUser"; +import { UUID_PATTERN } from "../helpers/uuid"; +import { getAuthenticatedProfileId, getEffectiveUserId } from "../middlewares/effectiveUser"; import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { accountSnapshot, depositSnapshots, findRelationship } from "../services/monerium-b2b/manager-events"; import { UNATTRIBUTED_ORDER_PREFIX } from "../services/monerium-b2b/mint-watcher"; import { MONERIUM_ID_HEADER, @@ -86,19 +88,7 @@ export const getMoneriumB2bAccount = async (req: Request, res: Response, next: N 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 - } - }); + res.status(httpStatus.OK).json({ account: accountSnapshot(account, await findRelationship(account)) }); } catch (error) { next(error); } @@ -108,8 +98,8 @@ 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". + * each with its chunk conversions and, once the whole deposit reached the destination, + * the forward transaction. This is the polling surface for "payment received / converted". */ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: NextFunction): Promise => { try { @@ -133,45 +123,61 @@ export const listMoneriumB2bDeposits = async (req: Request, res: Response, next: 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: await depositSnapshots(account, await findRelationship(account), rows), + pagination: { limit, offset, total: count } + }); + } catch (error) { + next(error); + } +}; + +/** + * GET /v1/monerium-b2b/accounts — every onramp account of the calling manager's active + * managed profiles, newest first, optionally narrowed to one Monerium profile. Manager + * credential only: no delegation header, no child credential. + */ +export const listMoneriumB2bAccounts = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const managerProfileId = getAuthenticatedProfileId(req); + const manager = managerProfileId ? await ManagedProfileManager.findByPk(managerProfileId) : null; + if ( + !manager?.isActive || + !manager.allowedCorridors.includes("EU") || + (manager.allowedCustomerTypes !== null && !manager.allowedCustomerTypes.includes("business")) + ) { + sendError( + res, + httpStatus.FORBIDDEN, + "MANAGED_PROFILE_ACCESS_DENIED", + "The authenticated profile does not manage business EUR onramp accounts" + ); + return; } + const moneriumProfileId = req.query.moneriumProfileId; + if (moneriumProfileId !== undefined && (typeof moneriumProfileId !== "string" || !UUID_PATTERN.test(moneriumProfileId))) { + sendError(res, httpStatus.BAD_REQUEST, "MONERIUM_B2B_INVALID_INPUT", "moneriumProfileId must be a UUID"); + 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; + // ponytail: the manager's children go into one IN list; page the relationship query if a partner ever has thousands. + const relationships = await ManagedProfile.findAll({ where: { managerProfileId: manager.profileId, status: "active" } }); + const byProfile = new Map(relationships.map(relationship => [relationship.profileId, relationship])); + const { count, rows } = await MoneriumAccount.findAndCountAll({ + limit, + offset, + order: [["created_at", "DESC"]], + where: { + vortexProfileId: { [Op.in]: [...byProfile.keys()] }, + ...(moneriumProfileId ? { profileId: moneriumProfileId } : {}) + } + }); 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() - }; - }), + accounts: rows.map(account => accountSnapshot(account, byProfile.get(account.vortexProfileId as string))), pagination: { limit, offset, total: count } }); } catch (error) { 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 index cf405f2c4..001554c1b 100644 --- a/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts +++ b/apps/api/src/api/routes/v1/admin/monerium-b2b.route.ts @@ -1,5 +1,11 @@ import { Router } from "express"; -import { patchMoneriumB2bAccountStatus, postMoneriumB2bAccount } from "../../../controllers/admin/moneriumB2b.controller"; +import { + getMoneriumB2bRefundAddress, + patchMoneriumB2bAccountStatus, + patchMoneriumB2bDepositStatus, + postMoneriumB2bAccount, + postMoneriumB2bDepositRecovery +} from "../../../controllers/admin/moneriumB2b.controller"; import { adminAuth } from "../../../middlewares/adminAuth"; const router: Router = Router({ mergeParams: true }); @@ -10,7 +16,16 @@ router.use(adminAuth); // deployed forwarder as a B2B onramp account. Idempotent. router.post("/accounts", postMoneriumB2bAccount); -// Operator lifecycle transitions (activate after the penny test, suspend, close). +// The client's derived refund wallet, passed as `recoveryAddress` when deploying its forwarder. +router.get("/refund-address", getMoneriumB2bRefundAddress); + +// Operator lifecycle transitions (activate, suspend, close). router.patch("/accounts/:accountId/status", patchMoneriumB2bAccountStatus); +// Refund path (runbook §2.7): mark a settling deposit for recovery — the keeper moves +// its funds to the client's refund wallet once the clone allows it — and close or retry +// a recovery by hand. +router.post("/deposits/:depositId/recover", postMoneriumB2bDepositRecovery); +router.patch("/deposits/:depositId/status", patchMoneriumB2bDepositStatus); + export default router; diff --git a/apps/api/src/api/routes/v1/monerium-b2b.route.ts b/apps/api/src/api/routes/v1/monerium-b2b.route.ts index 8c8705438..6be2253ed 100644 --- a/apps/api/src/api/routes/v1/monerium-b2b.route.ts +++ b/apps/api/src/api/routes/v1/monerium-b2b.route.ts @@ -1,7 +1,11 @@ import { Router } from "express"; import * as moneriumB2bController from "../../controllers/monerium-b2b.controller"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; -import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; +import { + authorizeManagedProfile, + rejectDirectManagedCredential, + rejectManagedProfileSelection +} from "../../middlewares/managedProfileAuth"; const router = Router(); @@ -16,4 +20,13 @@ const accountAuth = [requirePartnerOrUserAuth(), authorizeManagedProfile({ corri router.get("/account", ...accountAuth, moneriumB2bController.getMoneriumB2bAccount); router.get("/deposits", ...accountAuth, moneriumB2bController.listMoneriumB2bDeposits); +// Manager-level read: every onramp account of the caller's managed profiles (manager key only). +router.get( + "/accounts", + requirePartnerOrUserAuth(), + rejectDirectManagedCredential, + rejectManagedProfileSelection, + moneriumB2bController.listMoneriumB2bAccounts +); + export default router; diff --git a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts index d97c9d8cc..8fed6c169 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -8,6 +8,7 @@ import ProviderCustomer, { VerificationStatus } from "../../../models/providerCu import { UUID_PATTERN } from "../../helpers/uuid"; import { type ProvisionManagedProfileResult, provisionManagedProfile } from "../managed-profile-provisioning.service"; import { getPublicClient } from "./chain"; +import { refundAccountFor } from "./refund-wallet"; const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/i; @@ -25,11 +26,12 @@ export interface ProvisionMoneriumB2bAccountInput { contactEmail: string; destination: string; externalSubjectId: string; - fallbackAddress: string; - feeBps?: number; + /** Fee policy in ppm below the reference rate; defaults to the agreed launch policy. */ + floorPpm?: number; forwarderAddress: string; managerProfileId: string; moneriumProfileId: string; + targetPpm?: number; } export interface ProvisionMoneriumB2bAccountResult { @@ -49,18 +51,45 @@ function normalizeAddress(value: string, name: string): string { return value.trim().toLowerCase(); } +/** Launch fee policy (docs/adr-0005-monerium-b2b-onramp.md, B1): 12.5 bps target, 15 bps floor. */ +export const DEFAULT_TARGET_PPM = 1_250; +export const DEFAULT_FLOOR_PPM = 1_500; +// Mirrors the implementation's immutable MAX_FEE_PPM (ADR-0005 table); the contract re-validates at deploy. +const MAX_FEE_PPM = 10_000; + +/** Mirrors the contract's _validateFeePolicy: both in [0, MAX_FEE_PPM], target never above floor. */ +export function isValidFeePolicy(targetPpm: number, floorPpm: number): boolean { + return ( + Number.isInteger(targetPpm) && + Number.isInteger(floorPpm) && + targetPpm >= 0 && + floorPpm <= MAX_FEE_PPM && + targetPpm <= floorPpm + ); +} + const forwarderConfigAbi = parseAbi([ "function destination() view returns (address)", - "function fallbackAddress() view returns (address)", - "function feeBps() view returns (uint16)", + "function recoveryAddress() view returns (address)", + "function targetPpm() view returns (uint32)", + "function floorPpm() view returns (uint32)", "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 interface ForwarderPolicyConfig { + destination: string; + factory: string; + floorPpm: number; + /** The client's derived refund wallet (refund-wallet.ts): the clone's only recovery target. */ + recoveryAddress: string; + targetPpm: number; +} + export function forwarderConfigMismatch( - expected: { destination: string; factory: string; fallbackAddress: string; feeBps: number }, - onchain: { destination: string; factory: string; fallbackAddress: string; feeBps: number; isForwarder: boolean } + expected: ForwarderPolicyConfig, + onchain: ForwarderPolicyConfig & { isForwarder: boolean } ): string | null { if (onchain.factory.toLowerCase() !== expected.factory.toLowerCase()) { return `on-chain factory ${onchain.factory} differs from the trusted factory`; @@ -71,11 +100,14 @@ export function forwarderConfigMismatch( 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.recoveryAddress.toLowerCase() !== expected.recoveryAddress.toLowerCase()) { + return `on-chain recovery address ${onchain.recoveryAddress} is not this client's refund wallet ${expected.recoveryAddress}`; + } + if (onchain.targetPpm !== expected.targetPpm) { + return `on-chain targetPpm ${onchain.targetPpm} differs from the submitted ${expected.targetPpm}`; } - if (onchain.feeBps !== expected.feeBps) { - return `on-chain feeBps ${onchain.feeBps} differs from the submitted ${expected.feeBps}`; + if (onchain.floorPpm !== expected.floorPpm) { + return `on-chain floorPpm ${onchain.floorPpm} differs from the submitted ${expected.floorPpm}`; } return null; } @@ -91,8 +123,9 @@ export function forwarderConfigMismatch( async function verifyForwarderOnChain( forwarderAddress: string, destination: string, - fallbackAddress: string, - feeBps: number + moneriumProfileId: string, + targetPpm: number, + floorPpm: number ): Promise { if (!config.moneriumB2b.rpcUrl) { return; @@ -106,12 +139,13 @@ async function verifyForwarderOnChain( } const client = getPublicClient(); const address = forwarderAddress as Address; - let onchain: { destination: string; factory: string; fallbackAddress: string; feeBps: number; isForwarder: boolean }; + let onchain: ForwarderPolicyConfig & { isForwarder: boolean }; try { - const [onchainDestination, onchainFallback, onchainFeeBps, factory] = await Promise.all([ + const [onchainDestination, onchainRecovery, onchainTargetPpm, onchainFloorPpm, 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: "recoveryAddress" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "targetPpm" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "floorPpm" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "FACTORY" }) ]); const isForwarder = await client.readContract({ @@ -123,9 +157,10 @@ async function verifyForwarderOnChain( onchain = { destination: onchainDestination, factory, - fallbackAddress: onchainFallback, - feeBps: onchainFeeBps, - isForwarder + floorPpm: onchainFloorPpm, + isForwarder, + recoveryAddress: onchainRecovery, + targetPpm: onchainTargetPpm }; } catch (error) { throw new MoneriumB2bProvisioningError( @@ -135,7 +170,11 @@ async function verifyForwarderOnChain( }` ); } - const mismatch = forwarderConfigMismatch({ destination, factory: trustedFactory, fallbackAddress, feeBps }, onchain); + const recoveryAddress = refundAccountFor(moneriumProfileId).address; + const mismatch = forwarderConfigMismatch( + { destination, factory: trustedFactory, floorPpm, recoveryAddress, targetPpm }, + onchain + ); if (mismatch) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", @@ -223,14 +262,14 @@ function accountMatchesInput( childProfileId: string, forwarderAddress: string, destination: string, - fallbackAddress: string, - feeBps: number + targetPpm: number, + floorPpm: number ): boolean { return ( account.forwarderAddress.toLowerCase() === forwarderAddress && account.destination.toLowerCase() === destination && - account.fallbackAddress.toLowerCase() === fallbackAddress && - account.feeBps === feeBps && + account.targetPpm === targetPpm && + account.floorPpm === floorPpm && (account.vortexProfileId === null || account.vortexProfileId === childProfileId) ); } @@ -251,15 +290,18 @@ export async function provisionMoneriumB2bAccount( } 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"); + const targetPpm = input.targetPpm ?? DEFAULT_TARGET_PPM; + const floorPpm = input.floorPpm ?? DEFAULT_FLOOR_PPM; + if (!isValidFeePolicy(targetPpm, floorPpm)) { + throw new MoneriumB2bProvisioningError( + "MONERIUM_B2B_INVALID_INPUT", + "targetPpm and floorPpm must be integers between 0 and 10000 with targetPpm <= floorPpm" + ); } // 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); + await verifyForwarderOnChain(forwarderAddress, destination, moneriumProfileId, targetPpm, floorPpm); let result: { account: { created: boolean; row: MoneriumAccount }; managedProfile: ProvisionManagedProfileResult }; try { @@ -282,7 +324,7 @@ export async function provisionMoneriumB2bAccount( const existing = await MoneriumAccount.findOne({ transaction, where: { profileId: moneriumProfileId } }); if (existing) { - if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, fallbackAddress, feeBps)) { + if (!accountMatchesInput(existing, managedProfile.profileId, forwarderAddress, destination, targetPpm, floorPpm)) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", "The Monerium profile is already mapped with different account data" @@ -315,11 +357,11 @@ export async function provisionMoneriumB2bAccount( const row = await MoneriumAccount.create( { destination, - fallbackAddress, - feeBps, + floorPpm, forwarderAddress, profileId: moneriumProfileId, status: MoneriumAccountStatus.Onboarding, + targetPpm, vortexProfileId: managedProfile.profileId }, { transaction } diff --git a/apps/api/src/api/services/monerium-b2b/chain.ts b/apps/api/src/api/services/monerium-b2b/chain.ts index e59eb8467..be869641d 100644 --- a/apps/api/src/api/services/monerium-b2b/chain.ts +++ b/apps/api/src/api/services/monerium-b2b/chain.ts @@ -7,9 +7,11 @@ import { Hex, http, PublicClient, + parseAbi, parseAbiItem, Transport, - WalletClient + WalletClient, + zeroAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../config/logger"; @@ -52,11 +54,13 @@ export const NOTIFY_CONFIRMATION_DEPTH = 32; 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). +// The forwarder events the keeper decodes (receipts and getLogs-based crash recovery), one +// per keeper transaction kind. export const swapExecutedEvent = parseAbiItem( - "event SwapExecuted(address indexed caller, uint256 eureIn, uint256 usdcOut, uint256 fee, uint256 forwarded)" + "event SwapExecuted(address indexed caller, uint256 routeIndex, uint256 eureIn, uint256 usdcOut, uint256 referenceRate, uint256 fee, uint256 subsidy)" ); +export const forwardedEvent = parseAbiItem("event Forwarded(address indexed caller, uint256 amount)"); +export const recoveredEvent = parseAbiItem("event Recovered(address indexed caller, uint256 eureAmount, uint256 usdcAmount)"); export const erc20Abi = [ { @@ -65,55 +69,144 @@ export const erc20Abi = [ outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" + }, + { + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" } + ], + name: "allowance", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function" + }, + { + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" } + ], + name: "approve", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" + }, + { + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" } + ], + name: "transfer", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" } ] as const; +/** Uniswap V3 SwapRouter02 `exactInput`, the same call the forwarder makes — used by the refund's reverse swap. */ +export const swapRouter02Abi = parseAbi([ + "function exactInput((bytes path, address recipient, uint256 amountIn, uint256 amountOutMinimum) params) payable returns (uint256 amountOut)" +]); + 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", + inputs: [ + { name: "referenceRate", type: "uint256" }, + { name: "routeIndex", type: "uint256" }, + { name: "amountIn", type: "uint256" }, + { name: "maxSubsidy", type: "uint256" } + ], + name: "swap", 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" + inputs: [{ name: "amount", type: "uint256" }], + name: "forward", + outputs: [], + stateMutability: "nonpayable", + type: "function" }, + { inputs: [], name: "forwardAll", outputs: [], stateMutability: "nonpayable", type: "function" }, { - 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: "eureAmount", type: "uint256" }, + { name: "usdcAmount", type: "uint256" } ], - name: "SwapExecuted", - type: "event" + name: "recover", + outputs: [], + stateMutability: "nonpayable", + type: "function" }, { - anonymous: false, - inputs: [{ indexed: false, name: "paused", type: "bool" }], - name: "GuardianPausedSet", - type: "event" - } + inputs: [{ name: "paused", type: "bool" }], + name: "setGuardianPaused", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, + { inputs: [], name: "batchOpenedAt", outputs: [{ name: "", type: "uint64" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "RECOVERY_DELAY", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "recoveryAddress", outputs: [{ name: "", type: "address" }], 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: "ROUTER", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "FACTORY", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "USDC", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "ORACLE", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "ORACLE_DECIMALS", outputs: [{ name: "", type: "uint8" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "SLIPPAGE_BPS", outputs: [{ name: "", type: "uint16" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "MAX_FEE_PPM", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, + { + inputs: [], + name: "MAX_REFERENCE_DEVIATION_BPS", + outputs: [{ name: "", type: "uint16" }], + stateMutability: "view", + type: "function" + }, + { inputs: [], name: "targetPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" } ] 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" } + { inputs: [], name: "MIN_SWAP_FLOOR", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "subsidyVault", outputs: [{ name: "", type: "address" }], stateMutability: "view", type: "function" }, + { inputs: [], name: "routeCount", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" }, + { + inputs: [{ name: "index", type: "uint256" }], + name: "route", + outputs: [ + { name: "path", type: "bytes" }, + { name: "enabled", type: "bool" } + ], + stateMutability: "view", + type: "function" + } ] as const; +// VortexSubsidyVault: the guardian-tunable limits the keeper projects a swap against. +export const subsidyVaultAbi = parseAbi([ + "function maxSubsidyPpm() view returns (uint32)", + "function dailyBudget() view returns (uint256)", + "function spentToday() view returns (uint256)", + "function currentDay() view returns (uint256)", + "function paused() view returns (bool)" +]); + +export const chainlinkAbi = parseAbi([ + "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)" +]); + +/** Uniswap V3 QuoterV2 on Ethereum mainnet (the pinned quoting contract, PRD §7.4). */ +export const MAINNET_QUOTER_V2: Address = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"; + +export const quoterV2Abi = parseAbi([ + "function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)" +]); + // ------------------------------------------------------------------ clients export type KeeperWalletClient = WalletClient; @@ -197,6 +290,17 @@ export function getGuardianWalletClient(): KeeperWalletClient | null { return guardianClientCache; } +/** Client for one client's derived refund wallet (refund-wallet.ts): signs its reverse swap and redeem. */ +export function getRefundWalletClient(account: Account): KeeperWalletClient { + return createWalletClient({ account, transport: http(submissionRpcUrl()) }); +} + +/** Float-wallet client (MONERIUM_B2B_FLOAT_PRIVATE_KEY): the EURe float that tops a refund up to the exact amount. */ +export function getFloatWalletClient(): KeeperWalletClient | null { + const key = config.moneriumB2b.floatPrivateKey; + return key ? createWalletClient({ account: privateKeyToAccount(key as Hex), transport: http(submissionRpcUrl()) }) : null; +} + // ------------------------------------------------------------------ cached chain lookups let chainIdCache: number | null = null; @@ -208,13 +312,24 @@ export async function getChainId(): Promise { return chainIdCache; } -interface ForwarderImmutables { +export interface ForwarderImmutables { eure: Address; factory: Address; + router: Address; + maxFeePpm: number; + maxReferenceDeviationBps: number; + oracle: Address; + oracleDecimals: number; + /** Seconds a batch must have been open before the clone accepts `recover` (registry P3). */ + recoveryDelaySeconds: number; + /** This clone's fixed recovery address: the client's refund wallet. */ + recoveryAddress: Address; + slippageBps: number; + usdc: Address; } -// EURE/FACTORY are implementation-level immutables shared by every clone, so one -// lookup per forwarder address is enough for the process lifetime. +// Implementation-level immutables shared by every clone plus the clone's own fixed +// recovery address, so one lookup per forwarder address is enough for the process lifetime. const forwarderImmutablesCache = new Map(); export async function getForwarderImmutables(forwarderAddress: Address): Promise { @@ -224,11 +339,120 @@ export async function getForwarderImmutables(forwarderAddress: Address): Promise 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 read = < + T extends + | "EURE" + | "FACTORY" + | "USDC" + | "ORACLE" + | "ORACLE_DECIMALS" + | "SLIPPAGE_BPS" + | "MAX_FEE_PPM" + | "MAX_REFERENCE_DEVIATION_BPS" + | "RECOVERY_DELAY" + | "recoveryAddress" + | "ROUTER" + >( + functionName: T + ) => client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName }); + const [ + eure, + factory, + usdc, + oracle, + oracleDecimals, + slippageBps, + maxFeePpm, + maxReferenceDeviationBps, + recoveryDelay, + recoveryAddress, + router + ] = await Promise.all([ + read("EURE"), + read("FACTORY"), + read("USDC"), + read("ORACLE"), + read("ORACLE_DECIMALS"), + read("SLIPPAGE_BPS"), + read("MAX_FEE_PPM"), + read("MAX_REFERENCE_DEVIATION_BPS"), + read("RECOVERY_DELAY"), + read("recoveryAddress"), + read("ROUTER") ]); - const immutables = { eure, factory }; + const immutables: ForwarderImmutables = { + eure, + factory, + maxFeePpm: Number(maxFeePpm), + maxReferenceDeviationBps: Number(maxReferenceDeviationBps), + oracle, + oracleDecimals: Number(oracleDecimals), + recoveryAddress, + recoveryDelaySeconds: Number(recoveryDelay), + router, + slippageBps: Number(slippageBps), + usdc + }; forwarderImmutablesCache.set(key, immutables); return immutables; } + +// ------------------------------------------------------------------ routes + vault readers + +/** Enabled swap routes on the factory whitelist, by stable index. */ +export async function readEnabledRoutes(factory: Address): Promise> { + const client = getPublicClient(); + const count = Number(await client.readContract({ abi: factoryAbi, address: factory, functionName: "routeCount" })); + const routes = await Promise.all( + Array.from({ length: count }, (_, index) => + client + .readContract({ abi: factoryAbi, address: factory, args: [BigInt(index)], functionName: "route" }) + .then(([path, enabled]) => ({ enabled, index, path })) + ) + ); + return routes.filter(route => route.enabled).map(({ index, path }) => ({ index, path })); +} + +/** Static QuoterV2 quote for `amountIn` over a packed path. Mainnet only (MAINNET_QUOTER_V2 pin). */ +export async function quoteRouteOutput(path: Hex, amountIn: bigint): Promise { + const { result } = await getPublicClient().simulateContract({ + abi: quoterV2Abi, + address: MAINNET_QUOTER_V2, + args: [path, amountIn], + functionName: "quoteExactInput" + }); + return result[0]; +} + +export interface SubsidyVaultState { + balance: bigint; + dailyBudget: bigint; + maxSubsidyPpm: number; + paused: boolean; + /** Spent in the current UTC day; zero when the vault's day counter has rolled over. */ + spentToday: bigint; +} + +/** Live limits and balance of the factory's subsidy vault; null when none is configured. */ +export async function readSubsidyVaultState(vault: Address, usdc: Address): Promise { + if (vault === zeroAddress) { + return null; + } + const client = getPublicClient(); + const [balance, dailyBudget, maxSubsidyPpm, paused, spentToday, currentDay] = await Promise.all([ + client.readContract({ abi: erc20Abi, address: usdc, args: [vault], functionName: "balanceOf" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "dailyBudget" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "maxSubsidyPpm" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "paused" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "spentToday" }), + client.readContract({ abi: subsidyVaultAbi, address: vault, functionName: "currentDay" }) + ]); + const today = BigInt(Math.floor(Date.now() / 86_400_000)); + return { + balance, + dailyBudget, + maxSubsidyPpm: Number(maxSubsidyPpm), + paused, + spentToday: currentDay === today ? spentToday : 0n + }; +} 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 deleted file mode 100644 index 5257a2b2a..000000000 --- a/apps/api/src/api/services/monerium-b2b/conversion-allocation.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 index 514c97c84..265fa3321 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts @@ -1,211 +1,438 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import { FindOptions, Transaction } from "sequelize"; -import { encodeFunctionData } from "viem"; +import { Address, encodeAbiParameters, encodeEventTopics, encodeFunctionData, Hex, TransactionReceipt } from "viem"; import sequelize from "../../../config/database"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import * as chain from "./chain"; +import { parseSubsidyLadder } from "../../../config/vars"; import { - AllocatableDeposit, - allocateUsdcProRata, - broadcastSwapSequence, + broadcastExecutionSequence, + chunkElapsedSeconds, classifyHashlessPending, conversionAmountsFromSwapEvent, - isExpectedSwapTransaction, + expectedCalldata, + finalizeExecution, + isExpectedTransaction, + maxSubsidyBpsFor, + planAction, + planChunk, + pricePlannedSwap, + projectSwap, recoveryBlockRanges, runConversionExecutor, - selectDepositsForExecution + settlementState } 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. +import * as referenceRate from "./reference-rate"; +import { ReferenceQuote } from "./reference-rate"; const EUR = 10n ** 18n; const USDC = 10n ** 6n; -function deposit(id: string, amountRaw: bigint): AllocatableDeposit { - return { amountRaw, id }; -} +// One deposit converts in chunks (1 deposit : N swaps); the chunk plan never leaves a +// sub-minimum dust remainder when the last two chunks can share it. +describe("planChunk", () => { + const MIN = 25n * EUR; + const CAP = 10_000n * EUR; -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("converts a deposit at or below the cap in one chunk", () => { + expect(planChunk(9_000n * EUR, MIN, CAP)).toBe(9_000n * EUR); + expect(planChunk(CAP, MIN, CAP)).toBe(CAP); }); - 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("caps a large deposit and leaves a swappable remainder", () => { + expect(planChunk(25_000n * EUR, MIN, CAP)).toBe(CAP); + expect(planChunk(10_100n * EUR, MIN, CAP)).toBe(CAP); // leftover 100 >= minimum }); - it("allocates only the converted portion of an oversized deposit", () => { - expect(selectDepositsForExecution([deposit("a", 100n * EUR)], 60n * EUR)).toEqual([deposit("a", 60n * EUR)]); + it("shortens the chunk so the leftover is never sub-minimum dust", () => { + expect(planChunk(10_010n * EUR, MIN, CAP)).toBe(10_010n * EUR - MIN); // leftover exactly the minimum }); - 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("returns null below the minimum: such a remainder waits for the refund path", () => { + expect(planChunk(24n * EUR, MIN, CAP)).toBeNull(); + expect(planChunk(0n, MIN, CAP)).toBeNull(); }); - 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([]); + it("falls back to the cap when it cannot avoid dust (minimum close to the cap)", () => { + expect(planChunk(30n * EUR, 25n * EUR, 25n * EUR)).toBe(25n * EUR); }); }); -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); +// The subsidy ladder (adr-0005 amendment 2026-09-18): how much of a shortfall Vortex pays +// after a chunk has waited, from a "seconds:bps" config string. +describe("subsidy ladder", () => { + const ladder = parseSubsidyLadder(undefined); + + it("parses the launch ladder and looks the tier up by waiting time", () => { + expect(ladder[0]).toEqual({ afterSeconds: 0, maxSubsidyBps: 0 }); + expect(ladder.at(-1)).toEqual({ afterSeconds: 960, maxSubsidyBps: 100 }); + expect(maxSubsidyBpsFor(ladder, 0)).toBe(0); + expect(maxSubsidyBpsFor(ladder, 359)).toBe(0); + expect(maxSubsidyBpsFor(ladder, 360)).toBe(10); + expect(maxSubsidyBpsFor(ladder, 700)).toBe(30); + expect(maxSubsidyBpsFor(ladder, 5_000)).toBe(100); // holds the last step until the refund deadline }); - 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("rejects a malformed or non-ascending ladder", () => { + expect(() => parseSubsidyLadder("60:10")).toThrow("start at 0"); + expect(() => parseSubsidyLadder("0:0,120:20,60:30")).toThrow("ascend"); + expect(() => parseSubsidyLadder("0:0,120:20,240:10")).toThrow("ascend"); + expect(() => parseSubsidyLadder("0:x")).toThrow(":"); + expect(parseSubsidyLadder("0:0,120:25")).toEqual([ + { afterSeconds: 0, maxSubsidyBps: 0 }, + { afterSeconds: 120, maxSubsidyBps: 25 } + ]); }); - 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("counts a chunk's wait from the mint or from the previous chunk's confirmation", () => { + const now = 1_800_000_000_000; + const deposit = { createdAt: new Date(now - 900_000), mintedAt: new Date(now - 600_000) }; + expect(chunkElapsedSeconds(deposit, null, now)).toBe(600); + expect(chunkElapsedSeconds(deposit, new Date(now - 120_000), now)).toBe(120); + expect(chunkElapsedSeconds({ createdAt: new Date(now - 300_000), mintedAt: null }, null, now)).toBe(300); + }); +}); + +function swapRow(eureInRaw: bigint, usdcNetRaw: bigint): MoneriumConversionExecution { + return { + eureInRaw: eureInRaw.toString(), + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Confirmed, + usdcNetRaw: usdcNetRaw.toString() + } as unknown as MoneriumConversionExecution; +} + +describe("settlementState", () => { + it("aggregates the confirmed chunks of a deposit", () => { + const state = settlementState({ amountRaw: (100n * EUR).toString() }, [ + swapRow(60n * EUR, 65n * USDC), + swapRow(30n * EUR, 32n * USDC) + ]); + expect(state).toMatchObject({ remainingEureRaw: 10n * EUR, usdcNetRaw: 97n * USDC }); + }); + + it("never reports a negative remainder", () => { + expect(settlementState({ amountRaw: (100n * EUR).toString() }, [swapRow(101n * EUR, 1n)]).remainingEureRaw).toBe(0n); + }); +}); + +describe("planAction", () => { + const base = { + batchOpenedAtSec: 1_000n, + convertible: true, + minSwapAmount: 25n * EUR, + nowMs: 1_000_000 + 3 * 60 * 60 * 1000, // three hours after the batch opened + perSwapCap: 10_000n * EUR, + recoveryDelaySeconds: 2 * 60 * 60, + recoveryInFlight: false + }; + const deposit = (id: string, status: MoneriumFiatDepositStatus, amount: bigint) => + ({ + amountRaw: amount.toString(), + createdAt: new Date(base.nowMs - 10 * 60_000), + id, + mintedAt: new Date(base.nowMs - 7 * 60_000), + status + }) as MoneriumFiatDeposit; + const withSwaps = (row: MoneriumFiatDeposit, swaps: MoneriumConversionExecution[]) => ({ + deposit: row, + state: settlementState(row, swaps) }); - 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("swaps the next chunk of the oldest convertible deposit", () => { + const plan = planAction([withSwaps(deposit("a", MoneriumFiatDepositStatus.Minted, 25_000n * EUR), [])], base); + // The first chunk's clock runs from the mint (seven minutes ago here). + expect(plan).toMatchObject({ amountIn: 10_000n * EUR, elapsedSeconds: 420, kind: "swap" }); }); - 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("forwards a deposit once every chunk is confirmed, with the sum of the nets", () => { + const row = deposit("a", MoneriumFiatDepositStatus.Converting, 100n * EUR); + const plan = planAction([withSwaps(row, [swapRow(60n * EUR, 65n * USDC), swapRow(40n * EUR, 43n * USDC)])], base); + expect(plan).toMatchObject({ kind: "forward", usdcRaw: 108n * 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("recovers a marked deposit first, once the batch is old enough, and never before", () => { + const stuck = withSwaps(deposit("old", MoneriumFiatDepositStatus.Recovering, 1_500n * EUR), [ + swapRow(1_000n * EUR, 1_138n * USDC) + ]); + const young = withSwaps(deposit("young", MoneriumFiatDepositStatus.Minted, 500n * EUR), []); + expect(planAction([stuck, young], base)).toMatchObject({ + eureRaw: 500n * EUR, + kind: "recover", + usdcRaw: 1_138n * USDC + }); + // Too early for the contract: the younger deposit keeps converting meanwhile. + expect(planAction([stuck, young], { ...base, nowMs: 1_000_000 + 60 * 60 * 1000 })).toMatchObject({ + amountIn: 500n * EUR, + kind: "swap" + }); + expect(planAction([stuck, young], { ...base, batchOpenedAtSec: 0n })).toMatchObject({ kind: "swap" }); }); - 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("never sends a second recover while a refund is still on the recovery wallet", () => { + const stuck = withSwaps(deposit("old", MoneriumFiatDepositStatus.Recovering, 500n * EUR), []); + const young = withSwaps(deposit("young", MoneriumFiatDepositStatus.Minted, 500n * EUR), []); + expect(planAction([stuck, young], { ...base, recoveryInFlight: true })).toMatchObject({ kind: "swap" }); }); - 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("still recovers on an account that may not convert", () => { + const stuck = withSwaps(deposit("old", MoneriumFiatDepositStatus.Recovering, 500n * EUR), []); + const young = withSwaps(deposit("young", MoneriumFiatDepositStatus.Minted, 500n * EUR), []); + expect(planAction([stuck, young], { ...base, convertible: false })).toMatchObject({ kind: "recover" }); + expect(planAction([young], { ...base, convertible: false })).toMatchObject({ kind: "none" }); }); - 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); + it("does nothing for a sub-minimum remainder or an empty queue", () => { + expect(planAction([withSwaps(deposit("a", MoneriumFiatDepositStatus.Minted, 10n * EUR), [])], base)).toMatchObject({ + kind: "none", + reason: expect.stringContaining("below the minimum swap") + }); + expect(planAction([], base)).toMatchObject({ kind: "none" }); }); }); 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("nets the fee out of this chunk's output", () => { + expect(conversionAmountsFromSwapEvent({ fee: 8n * USDC, subsidy: 0n, usdcOut: 108n * USDC })).toEqual({ + feeRaw: "8000000", + subsidyRaw: "0", + usdcGrossRaw: "108000000", + usdcNetRaw: "100000000" + }); + }); + + it("adds the vault subsidy to the client's net", () => { + expect(conversionAmountsFromSwapEvent({ fee: 0n, subsidy: 2n * USDC, usdcOut: 106n * USDC })).toEqual({ + feeRaw: "0", + subsidyRaw: "2000000", + usdcGrossRaw: "106000000", + usdcNetRaw: "108000000" + }); }); it("refuses an impossible event whose fee exceeds this swap's output", () => { - expect(() => conversionAmountsFromSwapEvent({ fee: 2n, forwarded: 0n, usdcOut: 1n })).toThrow("fee exceeds"); + expect(() => conversionAmountsFromSwapEvent({ fee: 2n, subsidy: 0n, usdcOut: 1n })).toThrow("fee exceeds"); }); }); -describe("classifyHashlessPending", () => { - it("fails a row whose send phase was never reached (no persisted nonce)", () => { +// Off-chain mirror of the contract's settlement: same numbers as the Foundry suite +// (1000 EURe at 1.14: reference 1140 USDC, target 1138.575, floor 1138.29, oracle floor 1133.16). +describe("projectSwap", () => { + const vault = { balance: 1_000n * USDC, dailyBudget: 200n * USDC, maxSubsidyPpm: 5_000, paused: false, spentToday: 0n }; + const base = { + amountIn: 1_000n * EUR, + floorPpm: 1_500, + maxFeePpm: 10_000, + maxSubsidyRaw: 1_140n * USDC, // an unbounded tier: the vault decides + oracleDecimals: 8, + oracleRaw: 114_000_000n, + referenceRaw: 114_000_000n, + slippageBps: 60, + targetPpm: 1_250, + vault + }; + + it("defers a shortfall above the keeper's current tier before asking the vault", () => { + // 2.29 USDC needed; a 10 bps tier of 1140 allows 1.14. + expect(projectSwap({ ...base, maxSubsidyRaw: 1_140_000n, quotedOut: 1_136n * USDC }).defer).toContain("current tier"); + expect(projectSwap({ ...base, maxSubsidyRaw: 0n, quotedOut: 1_136n * USDC }).defer).toContain("current tier"); + expect(projectSwap({ ...base, maxSubsidyRaw: 2_290_000n, quotedOut: 1_136n * USDC }).defer).toBeNull(); + }); + + it("takes the surplus above the target as fee, capped at MAX_FEE_PPM", () => { + expect(projectSwap({ ...base, quotedOut: 1_145n * USDC })).toEqual({ + defer: null, + fee: 1_145n * USDC - 1_138_575_000n, + net: 1_138_575_000n, + subsidy: 0n + }); + expect(projectSwap({ ...base, quotedOut: 1_200n * USDC }).fee).toBe(12n * USDC); + }); + + it("leaves a fill between the floor and the target untouched", () => { + expect(projectSwap({ ...base, quotedOut: 1_138_400_000n })).toEqual({ + defer: null, + fee: 0n, + net: 1_138_400_000n, + subsidy: 0n + }); + }); + + it("tops a fill below the floor up from the vault", () => { + expect(projectSwap({ ...base, quotedOut: 1_136n * USDC })).toEqual({ + defer: null, + fee: 0n, + net: 1_138_290_000n, + subsidy: 2_290_000n + }); + }); + + it("defers when the vault cannot cover the subsidy", () => { + expect(projectSwap({ ...base, quotedOut: 1_130n * USDC }).defer).toContain("per-swap cap"); + expect(projectSwap({ ...base, quotedOut: 1_136n * USDC, vault: null }).defer).toContain("no subsidy vault"); + expect(projectSwap({ ...base, quotedOut: 1_136n * USDC, vault: { ...vault, paused: true } }).defer).toContain("paused"); expect( - classifyHashlessPending({ latestNonceCount: 0, matchingSwapTxHashes: [], nonce: null, scanComplete: true }) - ).toEqual({ kind: "fail", reason: "crashed before the transaction was sent" }); + projectSwap({ ...base, quotedOut: 1_136n * USDC, vault: { ...vault, spentToday: 199n * USDC } }).defer + ).toContain("daily budget"); + expect(projectSwap({ ...base, quotedOut: 1_136n * USDC, vault: { ...vault, balance: 1n * USDC } }).defer).toContain( + "vault balance" + ); }); - it("adopts the unclaimed SwapExecuted hash when the nonce was consumed", () => { + // Mirrors test_swap_depeggedReference_isLiftedToTheOracleFloorWhenTheTierAndVaultAllow. + it("lifts a depegged reference's net to the oracle floor when the tier and the vault allow, else defers", () => { + const lowReference = (114_000_000n * 9_910n) / 10_000n; // 90 bps below Chainlink + const needed = 1_133_160_000n - 1_127n * USDC; // 6.16 USDC up to the Chainlink floor, not the reference floor + // The launch vault cap (50 bps of the reference value, ~5.65 USDC) cannot cover it. + expect(projectSwap({ ...base, quotedOut: 1_127n * USDC, referenceRaw: lowReference }).defer).toContain("per-swap cap"); + const roomy = { ...vault, maxSubsidyPpm: 10_000 }; + expect(projectSwap({ ...base, maxSubsidyRaw: needed - 1n, quotedOut: 1_127n * USDC, referenceRaw: lowReference, vault: roomy }).defer).toContain("current tier"); + expect(projectSwap({ ...base, quotedOut: 1_127n * USDC, referenceRaw: lowReference, vault: roomy })).toEqual({ + defer: null, + fee: 0n, + net: 1_133_160_000n, + subsidy: needed + }); + }); + + // Mirrors test_swap_depeggedReference_feeGivesWayBeforeTheOracleFloor. + it("lets the fee give way before the client drops under the oracle floor", () => { + const lowReference = (114_000_000n * 9_900n) / 10_000n; // 100 bps below Chainlink: the band's edge + expect(projectSwap({ ...base, quotedOut: 1_140n * USDC, referenceRaw: lowReference })).toEqual({ + defer: null, + fee: 1_140n * USDC - 1_133_160_000n, // only what sits above the Chainlink floor, not down to 1_127_189_250 + net: 1_133_160_000n, + subsidy: 0n + }); + }); + + it("pays the drift below ~45 bps under Chainlink from the tier instead of deferring", () => { + // A fill exactly at the client's reference floor: above the Chainlink floor it is untouched, + // below it the subsidy lifts the net to the Chainlink floor (amendment 2026-09-18). + const floorFill = (referenceRaw: bigint) => (((base.amountIn * referenceRaw) / 10n ** 20n) * 998_500n) / 1_000_000n; + const tooLow = (114_000_000n * 9_953n) / 10_000n; // 47 bps below + const fine = (114_000_000n * 9_957n) / 10_000n; // 43 bps below + const lifted = projectSwap({ ...base, quotedOut: floorFill(tooLow), referenceRaw: tooLow }); + expect(lifted.defer).toBeNull(); + expect(lifted.subsidy).toBeGreaterThan(0n); + expect(lifted.net).toBe(1_133_160_000n); + expect(projectSwap({ ...base, maxSubsidyRaw: 0n, quotedOut: floorFill(tooLow), referenceRaw: tooLow }).defer).toContain( + "current tier" + ); + expect(projectSwap({ ...base, quotedOut: floorFill(fine), referenceRaw: fine })).toMatchObject({ + defer: null, + fee: 0n, + subsidy: 0n + }); + }); +}); + +describe("expectedCalldata", () => { + const swap = { + eureInRaw: (1_000n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Swap, + maxSubsidyRaw: "2290000", + usdcNetRaw: null + }; + + it("rebuilds a swap's calldata from the persisted reference, route, chunk and tier cap, or nothing", () => { + expect(expectedCalldata({ ...swap, referenceRateRaw: null, routeIndex: 0 })).toBeNull(); + expect(expectedCalldata({ ...swap, referenceRateRaw: "114000000", routeIndex: null })).toBeNull(); + expect(expectedCalldata({ ...swap, maxSubsidyRaw: null, referenceRateRaw: "114000000", routeIndex: 1 })).toBeNull(); + expect(expectedCalldata({ ...swap, referenceRateRaw: "114000000", routeIndex: 1 })).toBe( + encodeFunctionData({ abi: chain.forwarderAbi, args: [114_000_000n, 1n, 1_000n * EUR, 2_290_000n], functionName: "swap" }) + ); + }); + + it("rebuilds a forward's and a recovery's calldata from the persisted amounts", () => { expect( - classifyHashlessPending({ latestNonceCount: 8, matchingSwapTxHashes: ["0xlost"], nonce: 7, scanComplete: true }) - ).toEqual({ kind: "adopt", txHash: "0xlost" }); + expectedCalldata({ + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Forward, + maxSubsidyRaw: null, + referenceRateRaw: null, + routeIndex: null, + usdcNetRaw: (108n * USDC).toString() + }) + ).toBe(encodeFunctionData({ abi: chain.forwarderAbi, args: [108n * USDC], functionName: "forward" })); + expect( + expectedCalldata({ + eureInRaw: (40n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + maxSubsidyRaw: null, + referenceRateRaw: null, + routeIndex: null, + usdcNetRaw: (65n * USDC).toString() + }) + ).toBe(encodeFunctionData({ abi: chain.forwarderAbi, args: [40n * EUR, 65n * USDC], functionName: "recover" })); }); +}); - it("fails a consumed nonce with no SwapExecuted (reverted or replaced)", () => { - const result = classifyHashlessPending({ - latestNonceCount: 8, - matchingSwapTxHashes: [], - nonce: 7, - scanComplete: true +describe("classifyHashlessPending", () => { + it("fails a row whose send phase was never reached (no persisted nonce)", () => { + expect(classifyHashlessPending({ latestNonceCount: 0, matchingTxHashes: [], nonce: null, scanComplete: true })).toEqual({ + kind: "fail", + reason: "crashed before the transaction was sent" }); - expect(result.kind).toBe("fail"); }); - it("waits while the broadcast may still be in the mempool", () => { + it("adopts the unclaimed matching hash when the nonce was consumed", () => { expect( - classifyHashlessPending({ latestNonceCount: 7, matchingSwapTxHashes: [], nonce: 7, scanComplete: true }) - ).toEqual({ kind: "in-flight", reason: "the persisted nonce has not been consumed" }); + classifyHashlessPending({ latestNonceCount: 8, matchingTxHashes: ["0xlost"], nonce: 7, scanComplete: true }) + ).toEqual({ kind: "adopt", txHash: "0xlost" }); }); - it("remains fail-closed when a persisted nonce is not visible in the mempool", () => { - const result = classifyHashlessPending({ - latestNonceCount: 7, - matchingSwapTxHashes: [], - nonce: 7, - scanComplete: true + it("fails a consumed nonce with no matching transaction (reverted or replaced)", () => { + expect(classifyHashlessPending({ latestNonceCount: 8, matchingTxHashes: [], nonce: 7, scanComplete: true }).kind).toBe( + "fail" + ); + }); + + it("waits while the broadcast may still be in the mempool", () => { + expect(classifyHashlessPending({ latestNonceCount: 7, matchingTxHashes: [], nonce: 7, scanComplete: true })).toEqual({ + kind: "in-flight", + reason: "the persisted nonce has not been consumed" }); - 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 + classifyHashlessPending({ latestNonceCount: 8, matchingTxHashes: [], nonce: 7, scanComplete: false }).kind ).toBe("in-flight"); expect( - classifyHashlessPending({ - latestNonceCount: 8, - matchingSwapTxHashes: ["0xone", "0xtwo"], - nonce: 7, - scanComplete: true - }).kind + classifyHashlessPending({ latestNonceCount: 8, matchingTxHashes: ["0xone", "0xtwo"], nonce: 7, scanComplete: true }) + .kind ).toBe("in-flight"); }); }); -describe("isExpectedSwapTransaction", () => { +describe("isExpectedTransaction", () => { 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); + const input = encodeFunctionData({ + abi: chain.forwarderAbi, + args: [114_000_000n, 0n, 1_000n * EUR, 0n], + functionName: "swap" + }); + const expected = { from: keeper, input, nonce: 7, to: forwarder }; + + it("requires the exact keeper, nonce, forwarder, and calldata", () => { + expect(isExpectedTransaction(expected, keeper, forwarder, 7, input)).toBe(true); + expect(isExpectedTransaction({ ...expected, from: forwarder }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedTransaction({ ...expected, nonce: 8 }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedTransaction({ ...expected, to: keeper }, keeper, forwarder, 7, input)).toBe(false); + expect(isExpectedTransaction({ ...expected, input: "0x" }, keeper, forwarder, 7, input)).toBe(false); + const otherChunk = encodeFunctionData({ + abi: chain.forwarderAbi, + args: [114_000_000n, 0n, 999n * EUR, 0n], + functionName: "swap" + }); + expect(isExpectedTransaction({ ...expected, input: otherChunk }, keeper, forwarder, 7, input)).toBe(false); }); }); @@ -220,52 +447,52 @@ describe("recoveryBlockRanges", () => { }); }); -describe("broadcastSwapSequence", () => { - it("never reserves or sends a swap when the preceding poke fails", async () => { +describe("broadcastExecutionSequence", () => { + it("never reserves or sends when the preceding poke fails", async () => { const actions: string[] = []; await expect( - broadcastSwapSequence({ + broadcastExecutionSequence({ broadcastBlockNumber: 100, pendingNonce: 7, pokeNeeded: true, - reserveSwap: async () => { + reserve: async () => { actions.push("reserve"); return true; }, + send: async nonce => { + actions.push(`send:${nonce}`); + return "0xsend"; + }, 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 () => { + it("durably reserves the exact nonce after poke and before broadcast", async () => { const actions: string[] = []; - const hash = await broadcastSwapSequence({ + const hash = await broadcastExecutionSequence({ broadcastBlockNumber: 100, pendingNonce: 7, pokeNeeded: true, - reserveSwap: async (nonce, blockNumber) => { + reserve: async (nonce, blockNumber) => { actions.push(`reserve:${nonce}:${blockNumber}`); return true; }, + send: async nonce => { + actions.push(`send:${nonce}`); + return "0xsend"; + }, 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"]); + expect(hash).toBe("0xsend"); + expect(actions).toEqual(["poke:7", "reserve:8:100", "send:8"]); }); }); @@ -287,6 +514,7 @@ describe("runConversionExecutor recovery ordering", () => { accountId: account.id, createdAt: new Date(), id: "execution-1", + kind: MoneriumConversionExecutionKind.Swap, nonce: null, status: MoneriumConversionExecutionStatus.Pending, txHash: null, @@ -327,3 +555,291 @@ describe("runConversionExecutor recovery ordering", () => { } }); }); + +describe("pricePlannedSwap", () => { + afterEach(() => mock.restore()); + + const FORWARDER = "0x1111111111111111111111111111111111111111" as Address; + const FACTORY = "0x2222222222222222222222222222222222222222" as Address; + const VAULT = "0x3333333333333333333333333333333333333333" as Address; + const immutables: chain.ForwarderImmutables = { + eure: "0x4444444444444444444444444444444444444444", + factory: FACTORY, + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, + oracle: "0x5555555555555555555555555555555555555555", + oracleDecimals: 8, + recoveryDelaySeconds: 7_200, + recoveryAddress: "0x7777777777777777777777777777777777777777", + router: "0x8888888888888888888888888888888888888888", + slippageBps: 60, + usdc: "0x6666666666666666666666666666666666666666" + }; + const reference: ReferenceQuote = { + price: "1.14000000", + rateRaw: 114_000_000n, + source: "test", + time: new Date(0) + }; + const vault: chain.SubsidyVaultState = { + balance: 1_000n * USDC, + dailyBudget: 200n * USDC, + maxSubsidyPpm: 5_000, + paused: false, + spentToday: 0n + }; + const routes = [ + { index: 0, path: "0xaa" as Hex }, + { index: 1, path: "0xbb" as Hex } + ]; + + function arrange( + overrides: { + chainId?: number; + oracleAnswer?: bigint; + quotes?: Record; + reference?: ReferenceQuote | Error; + routes?: typeof routes; + } = {} + ) { + const reads: Record = { + floorPpm: 1_500, + latestRoundData: [1n, overrides.oracleAnswer ?? 114_000_000n, 0n, 0n, 1n], + subsidyVault: VAULT, + targetPpm: 1_250 + }; + spyOn(chain, "getPublicClient").mockReturnValue({ + readContract: async ({ functionName }: { functionName: string }) => reads[functionName] + } as unknown as ReturnType); + spyOn(chain, "getForwarderImmutables").mockResolvedValue(immutables); + spyOn(chain, "getChainId").mockResolvedValue(overrides.chainId ?? 1); + spyOn(chain, "readEnabledRoutes").mockResolvedValue(overrides.routes ?? routes); + spyOn(chain, "readSubsidyVaultState").mockResolvedValue(vault); + const quotes = overrides.quotes ?? { "0xaa": 1_138_400_000n, "0xbb": 1_139_000_000n }; + spyOn(chain, "quoteRouteOutput").mockImplementation(async path => { + const quote = quotes[path]; + if (quote instanceof Error) throw quote; + return quote; + }); + const fetched = overrides.reference ?? reference; + const fetchSpy = spyOn(referenceRate, "fetchCoinbaseReference"); + if (fetched instanceof Error) { + fetchSpy.mockRejectedValue(fetched); + } else { + fetchSpy.mockResolvedValue(fetched); + } + } + + const price = (maxSubsidyBps = 50) => pricePlannedSwap(FORWARDER, FACTORY, 1_000n * EUR, maxSubsidyBps); + + it("defers on a non-positive Chainlink answer", async () => { + arrange({ oracleAnswer: 0n }); + expect(await price()).toEqual({ code: "oracle_unavailable", kind: "defer", reason: "Chainlink EUR/USD answered 0" }); + }); + + it("defers when the reference cannot be fetched", async () => { + arrange({ reference: new Error("coinbase down") }); + expect(await price()).toMatchObject({ + code: "reference_unavailable", + kind: "defer", + reason: expect.stringContaining("reference rate unavailable") + }); + }); + + it("defers on a reference outside the Chainlink band", async () => { + arrange({ reference: { ...reference, price: "1.12000000", rateRaw: 112_000_000n } }); // 175 bps below + expect(await price()).toMatchObject({ + code: "reference_out_of_band", + kind: "defer", + reason: expect.stringContaining("outside the 100 bps band") + }); + }); + + it("defers when the factory has no enabled route", async () => { + arrange({ routes: [] }); + expect(await price()).toEqual({ code: "no_route", kind: "defer", reason: "the factory has no enabled swap route" }); + }); + + it("uses the first enabled route unprojected off mainnet, still carrying the tier cap", async () => { + arrange({ chainId: 11_155_111 }); + expect(await price()).toEqual({ kind: "ready", maxSubsidyRaw: 5_700_000n, reference, routeIndex: 0 }); + }); + + it("defers when no route can be quoted", async () => { + arrange({ quotes: { "0xaa": new Error("no pool"), "0xbb": new Error("no pool") } }); + expect(await price()).toEqual({ code: "no_route", kind: "defer", reason: "no enabled swap route could be quoted" }); + }); + + it("picks the route with the highest quote", async () => { + arrange(); + expect(await price()).toEqual({ + kind: "ready", + maxSubsidyRaw: 5_700_000n, // 50 bps of the 1140 USDC reference value + reference, + routeIndex: 1 + }); + }); + + it("defers with the route, quote and shortfall when the projection defers", async () => { + arrange({ quotes: { "0xaa": 1_130n * USDC, "0xbb": new Error("no pool") } }); // needs 8.29 USDC, cap is 5.7 + expect(await price(100)).toMatchObject({ + kind: "defer", + reason: expect.stringMatching(/per-swap cap.*\(route 0 quoted 1130000000, shortfall 72 bps, tier 100 bps\)/) + }); + // A tier below the shortfall defers before the vault is even consulted. + expect(await price(0)).toMatchObject({ code: "below_floor", kind: "defer", reason: expect.stringContaining("current tier 0") }); + }); +}); + +describe("finalizeExecution", () => { + const FORWARDER = "0x1111111111111111111111111111111111111111" as Address; + const KEEPER = "0x7777777777777777777777777777777777777777" as Address; + const TX = `0x${"ab".repeat(32)}` as Hex; + + function swapLog( + address: Address, + args: Record<"eureIn" | "fee" | "referenceRate" | "routeIndex" | "subsidy" | "usdcOut", bigint> + ) { + const inputs = chain.swapExecutedEvent.inputs; + return { + address, + blockNumber: 100n, + data: encodeAbiParameters( + inputs.filter(input => !("indexed" in input)), + [args.routeIndex, args.eureIn, args.usdcOut, args.referenceRate, args.fee, args.subsidy] + ), + logIndex: 7, + topics: encodeEventTopics({ abi: [chain.swapExecutedEvent], args: { caller: KEEPER }, eventName: "SwapExecuted" }), + transactionHash: TX + }; + } + + function forwardedLog(amount: bigint) { + return { + address: FORWARDER, + blockNumber: 100n, + data: encodeAbiParameters([{ type: "uint256" }], [amount]), + logIndex: 3, + topics: encodeEventTopics({ abi: [chain.forwardedEvent], args: { caller: KEEPER }, eventName: "Forwarded" }), + transactionHash: TX + }; + } + + function recoveredLog(eureAmount: bigint, usdcAmount: bigint) { + return { + address: FORWARDER, + blockNumber: 100n, + data: encodeAbiParameters([{ type: "uint256" }, { type: "uint256" }], [eureAmount, usdcAmount]), + logIndex: 4, + topics: encodeEventTopics({ abi: [chain.recoveredEvent], args: { caller: KEEPER }, eventName: "Recovered" }), + transactionHash: TX + }; + } + + function receipt(status: "reverted" | "success", logs: Array> = []): TransactionReceipt { + return { blockNumber: 100n, logs, status, transactionHash: TX } as unknown as TransactionReceipt; + } + + function pendingExecution(fields: Partial = {}) { + const updates: Record[] = []; + const execution = { + depositId: null, + kind: MoneriumConversionExecutionKind.Swap, + ...fields, + async update(values: Record) { + updates.push(values); + } + } as unknown as MoneriumConversionExecution; + return { execution, updates }; + } + + it("fails the execution on a reverted receipt, naming its kind", async () => { + const { execution, updates } = pendingExecution({ kind: MoneriumConversionExecutionKind.Forward }); + await finalizeExecution(execution, receipt("reverted"), FORWARDER, {} as Transaction); + expect(updates).toEqual([{ blockNumber: 100, error: "forward reverted", status: MoneriumConversionExecutionStatus.Failed }]); + }); + + it("fails a successful receipt that carries no SwapExecuted from the forwarder itself", async () => { + const { execution, updates } = pendingExecution(); + const foreign = swapLog("0x9999999999999999999999999999999999999999", { + eureIn: 1_000n * EUR, + fee: 0n, + referenceRate: 114_000_000n, + routeIndex: 0n, + subsidy: 0n, + usdcOut: 1_138n * USDC + }); + await finalizeExecution(execution, receipt("success", [foreign]), FORWARDER, {} as Transaction); + expect(updates).toEqual([ + { + blockNumber: 100, + error: "receipt succeeded but no SwapExecuted event was emitted by the forwarder", + status: MoneriumConversionExecutionStatus.Failed + } + ]); + }); + + it("confirms a swap from the forwarder's SwapExecuted and records the event's pricing as authoritative", async () => { + const { execution, updates } = pendingExecution(); + const log = swapLog(FORWARDER, { + eureIn: 1_000n * EUR, + fee: 425_000n, + referenceRate: 114_000_000n, + routeIndex: 1n, + subsidy: 0n, + usdcOut: 1_139n * USDC + }); + await finalizeExecution(execution, receipt("success", [log]), FORWARDER, {} as Transaction); + expect(updates).toEqual([ + { + blockNumber: 100, + error: null, + eureInRaw: (1_000n * EUR).toString(), + feeRaw: "425000", + referenceRateRaw: "114000000", + routeIndex: 1, + status: MoneriumConversionExecutionStatus.Confirmed, + subsidyRaw: "0", + swapLogIndex: 7, + txHash: TX, + usdcGrossRaw: "1139000000", + usdcNetRaw: "1138575000" + } + ]); + }); + + it("confirms a forward only when the forwarded amount is the planned one", async () => { + const planned = pendingExecution({ kind: MoneriumConversionExecutionKind.Forward, usdcNetRaw: (108n * USDC).toString() }); + await finalizeExecution(planned.execution, receipt("success", [forwardedLog(108n * USDC)]), FORWARDER, {} as Transaction); + expect(planned.updates).toEqual([ + { blockNumber: 100, error: null, status: MoneriumConversionExecutionStatus.Confirmed, swapLogIndex: 3, txHash: TX } + ]); + + const mismatch = pendingExecution({ kind: MoneriumConversionExecutionKind.Forward, usdcNetRaw: (108n * USDC).toString() }); + await finalizeExecution(mismatch.execution, receipt("success", [forwardedLog(107n * USDC)]), FORWARDER, {} as Transaction); + expect(mismatch.updates[0]).toMatchObject({ + error: expect.stringContaining("forwarded 107000000 but the execution planned 108000000"), + status: MoneriumConversionExecutionStatus.Failed + }); + }); + + it("confirms a recovery only when both recovered amounts match the plan", async () => { + const planned = pendingExecution({ + eureInRaw: (40n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + usdcNetRaw: (65n * USDC).toString() + }); + await finalizeExecution(planned.execution, receipt("success", [recoveredLog(40n * EUR, 65n * USDC)]), FORWARDER, {} as Transaction); + expect(planned.updates).toEqual([ + { blockNumber: 100, error: null, status: MoneriumConversionExecutionStatus.Confirmed, swapLogIndex: 4, txHash: TX } + ]); + + const mismatch = pendingExecution({ + eureInRaw: (40n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + usdcNetRaw: (65n * USDC).toString() + }); + await finalizeExecution(mismatch.execution, receipt("success", [recoveredLog(40n * EUR, 60n * USDC)]), FORWARDER, {} as Transaction); + expect(mismatch.updates[0]).toMatchObject({ status: MoneriumConversionExecutionStatus.Failed }); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts index 94b30ef80..7cb702c8c 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -1,33 +1,48 @@ +import type { DepositRefundReason, DepositWaitingReason } from "@vortexfi/shared"; 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, { + MoneriumConversionExecutionKind, MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import { + chainlinkAbi, erc20Abi, factoryAbi, + forwardedEvent, forwarderAbi, getChainId, getForwarderImmutables, getKeeperWalletClient, getPublicClient, + quoteRouteOutput, + readEnabledRoutes, + readSubsidyVaultState, + recoveredEvent, + SubsidyVaultState, swapExecutedEvent } from "./chain"; -import { withForwarderLock } from "./deposit-processor"; +import { isForwardTransition, withForwarderLock } from "./deposit-processor"; +import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; +import { activeRecoveryExists } from "./recovery"; +import { fetchCoinbaseReference, isWithinReferenceBand, ReferenceQuote } from "./reference-rate"; /** - * 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. + * Per-account keeper (docs/architecture-monerium-b2b-onramp.md, "Keeper"). Every keeper + * transaction on a forwarder is an execution row bound to the deposit it serves and + * committed BEFORE broadcast: + * - `swap(reference, route, amountIn)`: one chunk of one deposit (1 deposit : N swaps); + * the USDC waits on the clone; + * - `forward(amount)`: once every chunk is confirmed, the whole converted deposit goes + * to the client's destination in one transfer; + * - `recover(eure, usdc)`: a deposit marked `recovering` is moved to the recovery + * wallet once the clone's batch has been open for RECOVERY_DELAY. + * One transaction per account per cycle; a pending row of any kind blocks the next. * * Serialization: every database mutation runs inside the per-forwarder advisory lock * (withForwarderLock). The chain send/wait itself deliberately happens OUTSIDE a lock — @@ -42,8 +57,8 @@ import { withForwarderLock } from "./deposit-processor"; 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; +/** How long one cycle waits for the receipt before deferring to the next cycle. */ +export const RECEIPT_TIMEOUT_MS = 3 * 60_000; /** * A nonce-less pending row is a live pre-send reservation until this deadline. The @@ -55,6 +70,16 @@ const PRE_SEND_RESERVATION_MS = 5 * 60_000; /** Keep recovery log requests below common RPC block-range limits. */ const RECOVERY_LOG_BLOCK_RANGE = 2000n; +/** Wall-clock margin over the on-chain delay so a `recover` is never simulated a few seconds early. */ +const RECOVERY_ELIGIBILITY_MARGIN_MS = 30_000; + +/** Deposit states the keeper still has work for. */ +export const SETTLING_STATUSES = [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Converting, + MoneriumFiatDepositStatus.Recovering +] as const; + /** * Serializes nonce derivation and the broadcasts that consume it across every process * sharing the database: two concurrent senders would otherwise derive the same pending @@ -71,31 +96,35 @@ async function withKeeperSendLock(fn: () => Promise): Promise { }); } -interface SwapBroadcastSequence { +interface ExecutionBroadcastSequence { broadcastBlockNumber: number; pendingNonce: number; pokeNeeded: boolean; - reserveSwap(nonce: number, broadcastBlockNumber: number): Promise; + reserve(nonce: number, broadcastBlockNumber: number): Promise; sendPoke(nonce: number): Promise; - sendSwap(nonce: number): Promise; + send(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; +/** Safety-critical ordering: harmless poke, durable transaction identity, value-moving send. */ +export async function broadcastExecutionSequence(input: ExecutionBroadcastSequence): Promise { + let nonce = input.pendingNonce; if (input.pokeNeeded) { - await input.sendPoke(swapNonce); - swapNonce += 1; + await input.sendPoke(nonce); + nonce += 1; } - if (!(await input.reserveSwap(swapNonce, input.broadcastBlockNumber))) { + if (!(await input.reserve(nonce, input.broadcastBlockNumber))) { throw new Error("execution lost its pre-send reservation"); } - return input.sendSwap(swapNonce); + return input.send(nonce); } -/** Maps SwapExecuted into accounting values; `forwarded` may include pre-existing USDC. */ -export function conversionAmountsFromSwapEvent(event: { fee: bigint; forwarded: bigint; usdcOut: bigint }): { +/** + * Maps SwapExecuted into accounting values. The client's net for this chunk is the fill + * minus the fee plus the vault subsidy, all of which stays on the clone until forward. + */ +export function conversionAmountsFromSwapEvent(event: { fee: bigint; subsidy: bigint; usdcOut: bigint }): { feeRaw: string; + subsidyRaw: string; usdcGrossRaw: string; usdcNetRaw: string; } { @@ -104,241 +133,332 @@ export function conversionAmountsFromSwapEvent(event: { fee: bigint; forwarded: } return { feeRaw: event.fee.toString(), + subsidyRaw: event.subsidy.toString(), usdcGrossRaw: event.usdcOut.toString(), - usdcNetRaw: (event.usdcOut - event.fee).toString() + usdcNetRaw: (event.usdcOut - event.fee + event.subsidy).toString() }; } -// ------------------------------------------------------------------ R04 allocation math +// ------------------------------------------------------------------ pricing projection + +const PPM = 1_000_000n; +const BPS = 10_000n; + +export interface SwapProjectionInput { + amountIn: bigint; + floorPpm: number; + maxFeePpm: number; + /** The keeper's subsidy tier for this chunk (6 decimals): the most Vortex pays right now. */ + maxSubsidyRaw: bigint; + oracleDecimals: number; + oracleRaw: bigint; + quotedOut: bigint; + referenceRaw: bigint; + slippageBps: number; + targetPpm: number; + /** null when the factory has no subsidy vault configured. */ + vault: SubsidyVaultState | null; +} -export interface AllocatableDeposit { - id: string; - amountRaw: bigint; +export interface SwapProjection { + /** Why the keeper must not send this swap now, or null when it may proceed. */ + defer: string | null; + fee: bigint; + net: bigint; + subsidy: 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. + * Off-chain mirror of VortexForwarder's settlement for a quoted fill: the fee band, the + * subsidy band and the oracle floor on the client's net, where the Chainlink floor bounds + * both the target and the floor from below (a reference far under a stale round costs + * Vortex fee and subsidy instead of stopping the swap). The keeper defers — funds wait, + * nothing is sent, no execution row is burnt — whenever the contract would revert or the + * tier or the vault could not cover the projected subsidy. */ -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; +export function projectSwap(input: SwapProjectionInput): SwapProjection { + const scale = 10n ** BigInt(12 + input.oracleDecimals); + const referenceOut = (input.amountIn * input.referenceRaw) / scale; + const oracleFloor = (((input.amountIn * input.oracleRaw) / scale) * (BPS - BigInt(input.slippageBps))) / BPS; + let targetOut = (referenceOut * (PPM - BigInt(input.targetPpm))) / PPM; + if (targetOut < oracleFloor) targetOut = oracleFloor; + let floorOut = (referenceOut * (PPM - BigInt(input.floorPpm))) / PPM; + if (floorOut < oracleFloor) floorOut = oracleFloor; + + let fee = 0n; + let subsidy = 0n; + if (input.quotedOut > targetOut) { + fee = input.quotedOut - targetOut; + const maxFee = (input.quotedOut * BigInt(input.maxFeePpm)) / PPM; + if (fee > maxFee) fee = maxFee; + } else if (input.quotedOut < floorOut) { + subsidy = floorOut - input.quotedOut; + } + const net = input.quotedOut - fee + subsidy; + + let defer: string | null = null; + if (subsidy > input.maxSubsidyRaw) { + defer = `projected subsidy ${subsidy} exceeds the current tier ${input.maxSubsidyRaw}`; + } else if (subsidy > 0n) { + const vault = input.vault; + if (!vault) { + defer = `a subsidy of ${subsidy} is needed but no subsidy vault is configured`; + } else if (vault.paused) { + defer = `a subsidy of ${subsidy} is needed but the subsidy vault is paused`; + } else if (subsidy > (referenceOut * BigInt(vault.maxSubsidyPpm)) / PPM) { + defer = `projected subsidy ${subsidy} exceeds the vault's per-swap cap`; + } else if (subsidy > vault.dailyBudget - vault.spentToday) { + defer = `projected subsidy ${subsidy} exceeds the vault's remaining daily budget`; + } else if (subsidy > vault.balance) { + defer = `projected subsidy ${subsidy} exceeds the vault balance ${vault.balance}`; + } + } + if (defer === null && net < oracleFloor) { + defer = `projected net ${net} is below the oracle floor ${oracleFloor}`; } - return selected; + return { defer, fee, net, subsidy }; } +// ------------------------------------------------------------------ subsidy ladder + /** - * 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. + * The most Vortex pays for a chunk that has waited `elapsedSeconds`, in bps of the + * reference value: the last ladder step whose time has come (adr-0005 amendment + * 2026-09-18). The ladder holds its last step from then on; the refund deadline, not the + * ladder, ends the wait. */ -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); +export function maxSubsidyBpsFor( + ladder: ReadonlyArray<{ afterSeconds: number; maxSubsidyBps: number }>, + elapsedSeconds: number +): number { + let bps = 0; + for (const step of ladder) { + if (elapsedSeconds >= step.afterSeconds) bps = step.maxSubsidyBps; } - return shares; + return bps; } -// ------------------------------------------------------------------ finalization + attribution +/** How long the next chunk of a deposit has been waiting: since the mint, or since the previous chunk confirmed. */ +export function chunkElapsedSeconds( + deposit: Pick, + lastSwapAt: Date | null, + nowMs: number +): number { + const since = Math.max((deposit.mintedAt ?? deposit.createdAt).getTime(), lastSwapAt?.getTime() ?? 0); + return Math.max(0, Math.floor((nowMs - since) / 1000)); +} -function errorText(error: unknown): string { - return (error instanceof Error ? error.message : String(error)).slice(0, 500); +// ------------------------------------------------------------------ chunk planning + +/** + * Next chunk of a deposit with `remaining` unconverted EURe, or null when nothing can be + * swapped: below `minSwapAmount` the contract refuses, and such a remainder waits for + * the refund path (registry D5). A chunk is capped at `perSwapCap`, but never leaves a + * sub-minimum dust remainder behind when it can avoid it: the last two chunks split so + * both stay swappable. + */ +export function planChunk(remaining: bigint, minSwapAmount: bigint, perSwapCap: bigint): bigint | null { + if (remaining < minSwapAmount) return null; + if (remaining <= perSwapCap) return remaining; + const leftover = remaining - perSwapCap; + if (leftover >= minSwapAmount) return perSwapCap; + const shortened = remaining - minSwapAmount; + return shortened >= minSwapAmount ? shortened : perSwapCap; } -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"] - ], +// ------------------------------------------------------------------ deposit bookkeeping + +export interface DepositSettlementState { + /** When the newest confirmed chunk settled: the next chunk's clock starts here. */ + lastSwapAt: Date | null; + remainingEureRaw: bigint; + /** Sum of the confirmed chunks' net USDC: what a forward or a recovery moves. */ + usdcNetRaw: bigint; +} + +/** Pure aggregation of a deposit's confirmed chunk swaps. */ +export function settlementState( + deposit: Pick, + swaps: MoneriumConversionExecution[] +): DepositSettlementState { + const convertedEureRaw = swaps.reduce((sum, swap) => sum + BigInt(swap.eureInRaw), 0n); + const usdcNetRaw = swaps.reduce((sum, swap) => sum + BigInt(swap.usdcNetRaw ?? "0"), 0n); + const remainingEureRaw = BigInt(deposit.amountRaw) - convertedEureRaw; + const lastSwapAt = swaps.reduce( + (latest, swap) => (swap.updatedAt && (!latest || swap.updatedAt > latest) ? swap.updatedAt : latest), + null + ); + return { lastSwapAt, remainingEureRaw: remainingEureRaw < 0n ? 0n : remainingEureRaw, usdcNetRaw }; +} + +async function loadSettlementState(deposit: MoneriumFiatDeposit, transaction?: Transaction): Promise { + const swaps = await MoneriumConversionExecution.findAll({ + order: [["created_at", "ASC"]], transaction, where: { - accountId: execution.accountId, - [Op.or]: [ - { blockNumber: { [Op.lt]: execution.blockNumber } }, - { blockNumber: execution.blockNumber, logIndex: { [Op.lt]: execution.swapLogIndex } } - ], - status: MoneriumFiatDepositStatus.Minted + depositId: deposit.id, + kind: MoneriumConversionExecutionKind.Swap, + status: MoneriumConversionExecutionStatus.Confirmed } }); - 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; + return settlementState(deposit, swaps); } /** - * 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. + * The deposits the keeper may act on for an account: chain-indexed (the mint watcher has + * proven the mint), provider-attributed (R09 rows are never converted), oldest mint first. */ -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({ +async function settlingDeposits(accountId: string, transaction?: Transaction): Promise { + return MoneriumFiatDeposit.findAll({ order: [ ["block_number", "ASC"], - ["swap_log_index", "ASC"] + ["log_index", "ASC"] ], + transaction, 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 } + accountId, + blockNumber: { [Op.ne]: null }, + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + status: { [Op.in]: [...SETTLING_STATUSES] } } }); - 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); - }); +} + +// ------------------------------------------------------------------ finalization + +export function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 500); +} + +function eventForKind(kind: MoneriumConversionExecutionKind) { + switch (kind) { + case MoneriumConversionExecutionKind.Swap: + return swapExecutedEvent; + case MoneriumConversionExecutionKind.Forward: + return forwardedEvent; + case MoneriumConversionExecutionKind.Recover: + return recoveredEvent; } - return allocated; +} + +async function failExecution( + execution: MoneriumConversionExecution, + receipt: TransactionReceipt, + error: string, + transaction: Transaction +): Promise { + await execution.update( + { blockNumber: Number(receipt.blockNumber), error, status: MoneriumConversionExecutionStatus.Failed }, + { transaction } + ); } /** Applies a mined receipt to a pending execution: confirmed + event amounts, or failed on revert. */ -async function finalizeExecution( +export async function finalizeExecution( execution: MoneriumConversionExecution, receipt: TransactionReceipt, forwarderAddress: string, transaction: Transaction ): Promise { + const kind = execution.kind; if (receipt.status !== "success") { + await failExecution(execution, receipt, `${kind} reverted`, transaction); + return; + } + const event = eventForKind(kind); + const events = parseEventLogs({ abi: [event], logs: receipt.logs }).filter( + log => log.address.toLowerCase() === forwarderAddress.toLowerCase() + ); + if (events.length === 0) { + // A successful keeper transaction always emits its event; treat absence as failure. + await failExecution( + execution, + receipt, + `receipt succeeded but no ${event.name} event was emitted by the forwarder`, + transaction + ); + return; + } + const log = events[0]; + const blockNumber = Number(receipt.blockNumber); + const txHash = receipt.transactionHash; + + if (kind === MoneriumConversionExecutionKind.Swap) { + const args = log.args as { + eureIn: bigint; + fee: bigint; + referenceRate: bigint; + routeIndex: bigint; + subsidy: bigint; + usdcOut: bigint; + }; await execution.update( { - blockNumber: Number(receipt.blockNumber), - error: "swapAndForward reverted", - status: MoneriumConversionExecutionStatus.Failed + blockNumber, + error: null, + // The event's amountIn, reference and route are authoritative: what the contract + // actually priced and executed, whoever triggered it. + eureInRaw: args.eureIn.toString(), + referenceRateRaw: args.referenceRate.toString(), + routeIndex: Number(args.routeIndex), + ...conversionAmountsFromSwapEvent(args), + status: MoneriumConversionExecutionStatus.Confirmed, + swapLogIndex: log.logIndex, + txHash }, { 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. + + if (kind === MoneriumConversionExecutionKind.Forward) { + const { amount } = log.args as { amount: bigint }; + if (amount.toString() !== execution.usdcNetRaw) { + await failExecution( + execution, + receipt, + `forwarded ${amount} but the execution planned ${execution.usdcNetRaw}`, + transaction + ); + return; + } await execution.update( - { - blockNumber: Number(receipt.blockNumber), - error: "receipt succeeded but no SwapExecuted event was emitted by the forwarder", - status: MoneriumConversionExecutionStatus.Failed - }, + { blockNumber, error: null, status: MoneriumConversionExecutionStatus.Confirmed, swapLogIndex: log.logIndex, txHash }, { transaction } ); + await settleDeposit(execution, MoneriumFiatDepositStatus.Forwarded, transaction); + return; + } + + const { eureAmount, usdcAmount } = log.args as { eureAmount: bigint; usdcAmount: bigint }; + if (eureAmount.toString() !== execution.eureInRaw || usdcAmount.toString() !== execution.usdcNetRaw) { + await failExecution( + execution, + receipt, + `recovered ${eureAmount} EURe / ${usdcAmount} USDC but the execution planned ${execution.eureInRaw} / ${execution.usdcNetRaw}`, + 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 - }, + { blockNumber, error: null, status: MoneriumConversionExecutionStatus.Confirmed, swapLogIndex: log.logIndex, txHash }, { transaction } ); } +/** Forward-only deposit transition driven by a confirmed execution; ignored when already past it. */ +async function settleDeposit( + execution: MoneriumConversionExecution, + status: MoneriumFiatDepositStatus, + transaction: Transaction +): Promise { + if (!execution.depositId) return; + const deposit = await MoneriumFiatDeposit.findByPk(execution.depositId, { transaction }); + if (deposit && isForwardTransition(deposit.status, status)) { + await deposit.update({ status }, { transaction }); + } +} + // ------------------------------------------------------------------ pending resolution + backoff type PreparationResult = { kind: "proceed"; attempt: number } | { kind: "skip"; reason: string }; @@ -355,20 +475,56 @@ export interface RecoveryTransactionIdentity { to: string | null; } -const SWAP_AND_FORWARD_CALLDATA = encodeFunctionData({ abi: forwarderAbi, functionName: "swapAndForward" }); +/** + * The exact calldata a row would have broadcast, rebuilt from what was persisted before + * the send: reference, route and chunk for a swap; the amount for a forward; both + * amounts for a recovery. Null for a swap that never got priced. + */ +export function expectedCalldata( + execution: Pick< + MoneriumConversionExecution, + "eureInRaw" | "kind" | "maxSubsidyRaw" | "referenceRateRaw" | "routeIndex" | "usdcNetRaw" + > +): Hex | null { + switch (execution.kind) { + case MoneriumConversionExecutionKind.Swap: + if (execution.referenceRateRaw === null || execution.routeIndex === null || execution.maxSubsidyRaw === null) return null; + return encodeFunctionData({ + abi: forwarderAbi, + args: [ + BigInt(execution.referenceRateRaw), + BigInt(execution.routeIndex), + BigInt(execution.eureInRaw), + BigInt(execution.maxSubsidyRaw) + ], + functionName: "swap" + }); + case MoneriumConversionExecutionKind.Forward: + if (execution.usdcNetRaw === null) return null; + return encodeFunctionData({ abi: forwarderAbi, args: [BigInt(execution.usdcNetRaw)], functionName: "forward" }); + case MoneriumConversionExecutionKind.Recover: + if (execution.usdcNetRaw === null) return null; + return encodeFunctionData({ + abi: forwarderAbi, + args: [BigInt(execution.eureInRaw), BigInt(execution.usdcNetRaw)], + functionName: "recover" + }); + } +} /** Exact transaction identity required before a lost hash may be adopted. */ -export function isExpectedSwapTransaction( +export function isExpectedTransaction( transaction: RecoveryTransactionIdentity, keeperAddress: string, forwarderAddress: string, - nonce: number + nonce: number, + expectedInput: Hex ): boolean { return ( transaction.from.toLowerCase() === keeperAddress.toLowerCase() && transaction.nonce === nonce && transaction.to?.toLowerCase() === forwarderAddress.toLowerCase() && - transaction.input.toLowerCase() === SWAP_AND_FORWARD_CALLDATA.toLowerCase() + transaction.input.toLowerCase() === expectedInput.toLowerCase() ); } @@ -381,7 +537,7 @@ export function isExpectedSwapTransaction( export function classifyHashlessPending(input: { nonce: number | null; latestNonceCount: number; - matchingSwapTxHashes: string[]; + matchingTxHashes: string[]; scanComplete: boolean; }): HashlessPendingClassification { if (input.nonce === null) { @@ -395,13 +551,13 @@ export function classifyHashlessPending(input: { 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.matchingTxHashes.length === 1) { + return { kind: "adopt", txHash: input.matchingTxHashes[0] }; } - if (input.matchingSwapTxHashes.length > 1) { + if (input.matchingTxHashes.length > 1) { return { kind: "in-flight", reason: "multiple exact recovery candidates were found" }; } - return { kind: "fail", reason: "nonce consumed without the expected swap transaction" }; + return { kind: "fail", reason: "nonce consumed without the expected transaction" }; } /** Inclusive, non-overlapping block ranges for a complete bounded recovery scan. */ @@ -416,15 +572,16 @@ export function recoveryBlockRanges(fromBlock: bigint, toBlock: bigint): Array<{ /** * Scans every block since the pre-broadcast head and returns only unclaimed - * SwapExecuted transactions with the exact keeper identity persisted on the row. + * transactions of the row's kind with the exact keeper identity persisted on the row. */ -async function findMatchingSwapTxHashes( +async function findMatchingTxHashes( pending: MoneriumConversionExecution, account: MoneriumAccount, transaction: Transaction -): Promise<{ matchingSwapTxHashes: string[]; scanComplete: boolean }> { - if (pending.nonce === null || pending.broadcastBlockNumber === null) { - return { matchingSwapTxHashes: [], scanComplete: false }; +): Promise<{ matchingTxHashes: string[]; scanComplete: boolean }> { + const expectedInput = expectedCalldata(pending); + if (pending.nonce === null || pending.broadcastBlockNumber === null || expectedInput === null) { + return { matchingTxHashes: [], scanComplete: false }; } const client = getPublicClient(); const latestBlock = await client.getBlockNumber(); @@ -432,7 +589,7 @@ async function findMatchingSwapTxHashes( for (const range of recoveryBlockRanges(BigInt(pending.broadcastBlockNumber), latestBlock)) { const logs = await client.getLogs({ address: account.forwarderAddress as Address, - event: swapExecutedEvent, + event: eventForKind(pending.kind), ...range }); for (const log of logs) { @@ -440,7 +597,7 @@ async function findMatchingSwapTxHashes( } } if (loggedHashes.size === 0) { - return { matchingSwapTxHashes: [], scanComplete: true }; + return { matchingTxHashes: [], scanComplete: true }; } const known = await MoneriumConversionExecution.findAll({ attributes: ["txHash"], @@ -448,22 +605,21 @@ async function findMatchingSwapTxHashes( 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[] = []; + const matchingTxHashes: string[] = []; let claimedExactMatch = false; - for (const hash of hashes) { + for (const hash of loggedHashes) { const candidate = await client.getTransaction({ hash }); - if (!isExpectedSwapTransaction(candidate, keeperAddress, account.forwarderAddress, pending.nonce)) { + if (!isExpectedTransaction(candidate, keeperAddress, account.forwarderAddress, pending.nonce, expectedInput)) { continue; } if (claimed.has(hash.toLowerCase())) { claimedExactMatch = true; } else { - matchingSwapTxHashes.push(hash); + matchingTxHashes.push(hash); } } - return { matchingSwapTxHashes, scanComplete: !claimedExactMatch }; + return { matchingTxHashes, scanComplete: !claimedExactMatch }; } /** @@ -515,8 +671,8 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans const latestNonceCount = await client.getTransactionCount({ address: keeperAddress, blockTag: "latest" }); const recovery = latestNonceCount > pending.nonce - ? await findMatchingSwapTxHashes(pending, account, transaction) - : { matchingSwapTxHashes: [], scanComplete: true }; + ? await findMatchingTxHashes(pending, account, transaction) + : { matchingTxHashes: [], 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}` }; @@ -564,11 +720,201 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans return { attempt: failedSince.length + 1, kind: "proceed" }; } +// ------------------------------------------------------------------ pricing + +export type PlannedSwap = + | { kind: "defer"; code: DepositWaitingReason; reason: string } + | { + kind: "ready"; + /** The tier cap in USDC (6 decimals): the `maxSubsidy` argument of the swap. */ + maxSubsidyRaw: bigint; + reference: ReferenceQuote; + routeIndex: number; + }; + +function deferSwap(code: DepositWaitingReason, reason: string): PlannedSwap { + return { code, kind: "defer", reason }; +} + +/** Quotes every enabled route on the mainnet QuoterV2; a route that cannot be quoted is skipped with a warning. */ +async function quoteRoutes( + routes: Array<{ index: number; path: Hex }>, + amountIn: bigint +): Promise> { + const quotes: Array<{ index: number; quotedOut: bigint }> = []; + for (const route of routes) { + try { + quotes.push({ index: route.index, quotedOut: await quoteRouteOutput(route.path, amountIn) }); + } catch (error) { + logger.warn(`monerium-b2b: route ${route.index} could not be quoted: ${errorText(error)}`); + } + } + return quotes; +} + +/** + * Reference, route, tier cap and projection for a swap of `amountIn` + * (docs/architecture-monerium-b2b-onramp.md, fees section). `maxSubsidyBps` is the + * keeper's tier for the chunk's waiting time; the cap it yields is passed into the swap + * and binds on chain. Outside Ethereum mainnet there is no quoter pin: the first enabled + * route is used unprojected and the contract's own checks remain the only gate. + */ +export async function pricePlannedSwap( + forwarder: Address, + factory: Address, + amountIn: bigint, + maxSubsidyBps: number +): Promise { + const client = getPublicClient(); + const immutables = await getForwarderImmutables(forwarder); + const [targetPpm, floorPpm, roundData, vaultAddress] = await Promise.all([ + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "targetPpm" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "floorPpm" }), + client.readContract({ abi: chainlinkAbi, address: immutables.oracle, functionName: "latestRoundData" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "subsidyVault" }) + ]); + const oracleRaw = roundData[1]; + if (oracleRaw <= 0n) { + return deferSwap("oracle_unavailable", `Chainlink EUR/USD answered ${oracleRaw}`); + } + + let reference: ReferenceQuote; + try { + reference = await fetchCoinbaseReference(immutables.oracleDecimals); + } catch (error) { + return deferSwap("reference_unavailable", `reference rate unavailable: ${errorText(error)}`); + } + if (!isWithinReferenceBand(reference.rateRaw, oracleRaw, immutables.maxReferenceDeviationBps)) { + return deferSwap( + "reference_out_of_band", + `reference ${reference.price} is outside the ${immutables.maxReferenceDeviationBps} bps band around Chainlink ${oracleRaw}` + ); + } + + const referenceOut = (amountIn * reference.rateRaw) / 10n ** BigInt(12 + immutables.oracleDecimals); + const maxSubsidyRaw = (referenceOut * BigInt(maxSubsidyBps)) / BPS; + + const routes = await readEnabledRoutes(factory); + if (routes.length === 0) { + return deferSwap("no_route", "the factory has no enabled swap route"); + } + if ((await getChainId()) !== 1) { + return { kind: "ready", maxSubsidyRaw, reference, routeIndex: routes[0].index }; + } + const quotes = await quoteRoutes(routes, amountIn); + if (quotes.length === 0) { + return deferSwap("no_route", "no enabled swap route could be quoted"); + } + const best = quotes.reduce((leader, quote) => (quote.quotedOut > leader.quotedOut ? quote : leader)); + const vault = await readSubsidyVaultState(vaultAddress, immutables.usdc); + const projection = projectSwap({ + amountIn, + floorPpm: Number(floorPpm), + maxFeePpm: immutables.maxFeePpm, + maxSubsidyRaw, + oracleDecimals: immutables.oracleDecimals, + oracleRaw, + quotedOut: best.quotedOut, + referenceRaw: reference.rateRaw, + slippageBps: immutables.slippageBps, + targetPpm: Number(targetPpm), + vault + }); + if (projection.defer) { + // Calibration data for the ladder: the shortfall this attempt would have needed. + const shortfallBps = referenceOut > 0n ? Number((projection.subsidy * BPS) / referenceOut) : 0; + return deferSwap( + "below_floor", + `${projection.defer} (route ${best.index} quoted ${best.quotedOut}, shortfall ${shortfallBps} bps, tier ${maxSubsidyBps} bps)` + ); + } + logger.info( + `monerium-b2b: priced swap of ${amountIn} on route ${best.index}: quoted ${best.quotedOut}, ` + + `reference ${reference.price}, fee ${projection.fee}, subsidy ${projection.subsidy}, tier ${maxSubsidyBps} bps` + ); + return { kind: "ready", maxSubsidyRaw, reference, routeIndex: best.index }; +} + +// ------------------------------------------------------------------ action planning + +export type PlannedAction = + | { kind: "none"; reason: string } + | { kind: "recover"; deposit: MoneriumFiatDeposit; eureRaw: bigint; usdcRaw: bigint } + | { kind: "forward"; deposit: MoneriumFiatDeposit; usdcRaw: bigint } + | { kind: "swap"; deposit: MoneriumFiatDeposit; amountIn: bigint; elapsedSeconds: number }; + +export interface ActionPlanningInput { + batchOpenedAtSec: bigint; + convertible: boolean; + minSwapAmount: bigint; + nowMs: number; + perSwapCap: bigint; + recoveryDelaySeconds: number; + /** A recovered payment is still on the recovery wallet: no second `recover` may land there. */ + recoveryInFlight: boolean; +} + +/** + * What the keeper should do next for an account, given its settling deposits (oldest + * mint first) and their confirmed chunks. A deposit marked `recovering` goes first, once + * the clone's batch has been open for RECOVERY_DELAY and no other refund is in flight + * (the recovery wallet takes one payment at a time); else it waits without blocking + * younger deposits. Then the oldest convertible deposit is forwarded when all of its + * EURe is converted, or swapped in its next chunk. + */ +export function planAction( + deposits: Array<{ deposit: MoneriumFiatDeposit; state: DepositSettlementState }>, + input: ActionPlanningInput +): PlannedAction { + const recoveryEligibleAtMs = + (Number(input.batchOpenedAtSec) + input.recoveryDelaySeconds) * 1000 + RECOVERY_ELIGIBILITY_MARGIN_MS; + for (const { deposit, state } of deposits) { + if (deposit.status !== MoneriumFiatDepositStatus.Recovering) continue; + if (state.remainingEureRaw === 0n && state.usdcNetRaw === 0n) { + // Nothing on chain belongs to it (e.g. forwarded permissionlessly): operator matter. + continue; + } + if (input.batchOpenedAtSec === 0n || input.nowMs < recoveryEligibleAtMs) { + continue; // the contract would revert DelayNotElapsed; younger deposits keep converting + } + if (input.recoveryInFlight) { + continue; // the previous refund must leave the recovery wallet first + } + return { deposit, eureRaw: state.remainingEureRaw, kind: "recover", usdcRaw: state.usdcNetRaw }; + } + if (!input.convertible) { + return { kind: "none", reason: "account is not convertible" }; + } + const next = deposits.find(({ deposit }) => deposit.status !== MoneriumFiatDepositStatus.Recovering); + if (!next) { + return { kind: "none", reason: "no settling deposit" }; + } + if (next.state.remainingEureRaw === 0n) { + if (next.state.usdcNetRaw === 0n) { + return { kind: "none", reason: `deposit ${next.deposit.id} has nothing to forward` }; + } + return { deposit: next.deposit, kind: "forward", usdcRaw: next.state.usdcNetRaw }; + } + const amountIn = planChunk(next.state.remainingEureRaw, input.minSwapAmount, input.perSwapCap); + if (amountIn === null) { + return { + kind: "none", + reason: `deposit ${next.deposit.id} has ${next.state.remainingEureRaw} raw EURe left, below the minimum swap` + }; + } + return { + amountIn, + deposit: next.deposit, + elapsedSeconds: chunkElapsedSeconds(next.deposit, next.state.lastSwapAt, input.nowMs), + kind: "swap" + }; +} + // ------------------------------------------------------------------ executor /** - * Runs one conversion cycle for an account. Safe to call for accounts with nothing to - * do (cheap chain reads, then returns). + * Runs one keeper cycle for an account: at most one transaction. 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); @@ -576,8 +922,7 @@ export async function runConversionExecutor(accountId: string): Promise { 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. + // Recover an earlier broadcast before current account state can make this cycle return. const existingPending = await MoneriumConversionExecution.findOne({ attributes: ["id"], where: { accountId: account.id, status: MoneriumConversionExecutionStatus.Pending } @@ -591,45 +936,105 @@ export async function runConversionExecutor(accountId: string): Promise { return; } } + if (account.status === MoneriumAccountStatus.Closed) { + 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; + // Suspended/dormant accounts never swap or forward (dormancy is guardian-paused — the + // clone would revert Paused()), but a recovery still runs for them: the refund path is + // exactly for payments nobody is converting any more, and `recover` ignores the pause. + const convertible = account.status !== MoneriumAccountStatus.Suspended && !account.dormantSince; const client = getPublicClient(); const forwarder = account.forwarderAddress as Address; - const { eure, factory } = await getForwarderImmutables(forwarder); + const immutables = await getForwarderImmutables(forwarder); + const { eure, factory, usdc } = immutables; 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([ + const [eureBalance, usdcBalance, batchOpenedAt, 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: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }), 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; + // Arm the batch marker whenever funds are present, even below the (guardian-tunable) + // minSwapAmount: the recovery and trigger clocks must run regardless of whether a swap + // is currently possible. + const pokeNeeded = batchOpenedAt === 0n && (eureBalance >= minSwapFloor || usdcBalance > 0n); - if (!convertible || balance < minSwapAmount) { + const recoveryInFlight = await activeRecoveryExists(); + const planned = await withForwarderLock(account.forwarderAddress, async transaction => { + const deposits = await settlingDeposits(account.id, transaction); + const withState = []; + for (const deposit of deposits) { + // A deposit whose `recover` already confirmed is the orchestrator's; it never + // recovers twice. + const state = await loadSettlementState(deposit, transaction); + const recovered = await MoneriumConversionExecution.count({ + transaction, + where: { + depositId: deposit.id, + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed + } + }); + if (recovered > 0) continue; + withState.push({ deposit, state }); + } + return planAction(withState, { + batchOpenedAtSec: batchOpenedAt, + convertible, + minSwapAmount, + nowMs: Date.now(), + perSwapCap, + recoveryDelaySeconds: immutables.recoveryDelaySeconds, + recoveryInFlight + }); + }); + if (planned.kind === "none") { if (pokeNeeded) { await sendPoke(forwarder); } return; } + // Price a chunk before anything is reserved: reference, route and the projected + // fee/subsidy. A deferral leaves the funds waiting (marker still armed) and never + // creates an execution row. + let plan: PlannedSwap | null = null; + if (planned.kind === "swap") { + const maxSubsidyBps = maxSubsidyBpsFor(config.moneriumB2b.subsidyLadder, planned.elapsedSeconds); + plan = await pricePlannedSwap(forwarder, factory, planned.amountIn, maxSubsidyBps); + if (plan.kind === "defer") { + logger.warn( + `monerium-b2b: deferring conversion for account ${account.id} (chunk waited ${planned.elapsedSeconds}s): ${plan.reason}` + ); + // Partner-visible hold reason (DEPOSIT_UPDATED); written only when it changes. + if (planned.deposit.waitingReason !== plan.code) { + await planned.deposit.update({ + waitingReason: plan.code, + waitingSince: planned.deposit.waitingSince ?? new Date() + }); + } + if (pokeNeeded) { + await sendPoke(forwarder); + } + return; + } + if (planned.deposit.waitingReason) { + await planned.deposit.update({ waitingReason: null, waitingSince: null }); + } + } + const readyPlan = plan; + const call = executionCall(planned, readyPlan); + // 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 => { @@ -637,16 +1042,31 @@ export async function runConversionExecutor(accountId: string): Promise { 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. + // Execution-before-send record: committed before any broadcast so a crash leaves an + // auditable pending row, never an untracked on-chain transaction. const execution = await MoneriumConversionExecution.create( { accountId: account.id, + depositId: planned.deposit.id, destination: account.destination, - eureInRaw: (balance > perSwapCap ? perSwapCap : balance).toString() + eureInRaw: call.eureInRaw, + kind: call.kind, + usdcNetRaw: call.usdcNetRaw, + ...(readyPlan?.kind === "ready" + ? { + maxSubsidyRaw: readyPlan.maxSubsidyRaw.toString(), + referenceAt: readyPlan.reference.time, + referenceRateRaw: readyPlan.reference.rateRaw.toString(), + referenceSource: readyPlan.reference.source, + routeIndex: readyPlan.routeIndex + } + : {}) }, { transaction } ); + if (planned.kind === "swap" && planned.deposit.status === MoneriumFiatDepositStatus.Minted) { + await planned.deposit.update({ status: MoneriumFiatDepositStatus.Converting }, { transaction }); + } return { attempt: preparation.attempt, execution, kind: "proceed" as const }; }); if (slot.kind === "skip") { @@ -663,29 +1083,24 @@ export async function runConversionExecutor(accountId: string): Promise { 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" - }); + await simulateCall(client, keeper, forwarder, call.request); - // Send phase, serialized across processes: explicit nonces because poke + swap go + // Send phase, serialized across processes: explicit nonces because poke + send 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. + // send is attempted; persist the nonce only after poke succeeds, immediately before + // the value-moving transaction 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({ + return broadcastExecutionSequence({ broadcastBlockNumber, pendingNonce, pokeNeeded, - reserveSwap: async nonce => { + reserve: async nonce => { const [reserved] = await MoneriumConversionExecution.update( { broadcastBlockNumber, nonce }, { where: { id: execution.id, nonce: null, status: MoneriumConversionExecutionStatus.Pending } } @@ -695,6 +1110,7 @@ export async function runConversionExecutor(accountId: string): Promise { } return reserved === 1; }, + send: nonce => writeCall(keeper, forwarder, call.request, nonce), sendPoke: async nonce => { await keeper.writeContract({ abi: forwarderAbi, @@ -704,16 +1120,7 @@ export async function runConversionExecutor(accountId: string): Promise { functionName: "poke", nonce }); - }, - sendSwap: nonce => - keeper.writeContract({ - abi: forwarderAbi, - account: keeper.account, - address: forwarder, - chain: null, - functionName: "swapAndForward", - nonce - }) + } }); }); await execution.update({ txHash }); @@ -742,17 +1149,95 @@ export async function runConversionExecutor(accountId: string): Promise { error: `attempt ${attempt}: ${errorText(error)}`, status: MoneriumConversionExecutionStatus.Failed }); - logger.error(`monerium-b2b: conversion for account ${account.id} failed (attempt ${attempt}):`, error); + logger.error(`monerium-b2b: ${call.kind} for account ${account.id} failed (attempt ${attempt}):`, error); } } -/** Standalone stranding-marker poke for balances between the floor and minSwapAmount. */ +type ExecutionRequest = + | { args: readonly [bigint, bigint, bigint, bigint]; functionName: "swap" } + | { args: readonly [bigint]; functionName: "forward" } + | { args: readonly [bigint, bigint]; functionName: "recover" }; + +/** viem needs a literal function name per overload, so the three calls are spelled out. */ +async function simulateCall( + client: ReturnType, + keeper: ReturnType, + address: Address, + request: ExecutionRequest +): Promise { + const base = { abi: forwarderAbi, account: keeper.account, address } as const; + switch (request.functionName) { + case "swap": + await client.simulateContract({ ...base, args: request.args, functionName: "swap" }); + return; + case "forward": + await client.simulateContract({ ...base, args: request.args, functionName: "forward" }); + return; + case "recover": + await client.simulateContract({ ...base, args: request.args, functionName: "recover" }); + return; + } +} + +function writeCall( + keeper: ReturnType, + address: Address, + request: ExecutionRequest, + nonce: number +): Promise { + const base = { abi: forwarderAbi, account: keeper.account, address, chain: null, nonce } as const; + switch (request.functionName) { + case "swap": + return keeper.writeContract({ ...base, args: request.args, functionName: "swap" }); + case "forward": + return keeper.writeContract({ ...base, args: request.args, functionName: "forward" }); + case "recover": + return keeper.writeContract({ ...base, args: request.args, functionName: "recover" }); + } +} + +/** The contract call and the row amounts for a planned action. */ +function executionCall( + planned: Exclude, + plan: PlannedSwap | null +): { eureInRaw: string; kind: MoneriumConversionExecutionKind; request: ExecutionRequest; usdcNetRaw: string | null } { + switch (planned.kind) { + case "swap": { + if (!plan || plan.kind !== "ready") throw new Error("a swap needs a priced plan"); + return { + eureInRaw: planned.amountIn.toString(), + kind: MoneriumConversionExecutionKind.Swap, + request: { + args: [plan.reference.rateRaw, BigInt(plan.routeIndex), planned.amountIn, plan.maxSubsidyRaw], + functionName: "swap" + }, + usdcNetRaw: null + }; + } + case "forward": + return { + eureInRaw: planned.deposit.amountRaw, + kind: MoneriumConversionExecutionKind.Forward, + request: { args: [planned.usdcRaw], functionName: "forward" }, + usdcNetRaw: planned.usdcRaw.toString() + }; + case "recover": + return { + eureInRaw: planned.eureRaw.toString(), + kind: MoneriumConversionExecutionKind.Recover, + request: { args: [planned.eureRaw, planned.usdcRaw], functionName: "recover" }, + usdcNetRaw: planned.usdcRaw.toString() + }; + } +} + +/** Standalone batch-marker poke for funds the keeper cannot act on yet. */ 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. + // Implicit nonce, so the send still serializes with the value-moving path's derivation. const hash = await withKeeperSendLock(() => keeper.writeContract({ abi: forwarderAbi, @@ -765,7 +1250,41 @@ async function sendPoke(forwarder: Address): Promise { 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. + // the batch clocks until the next cycle. logger.warn(`monerium-b2b: poke for forwarder ${forwarder} failed: ${errorText(error)}`); } } + +/** + * Marks a settling deposit for the refund path. Under the forwarder lock so it cannot + * race a chunk swap being reserved; the keeper then sends `recover` once the clone's + * batch has been open for RECOVERY_DELAY. Returns the reason it could not, or null. + */ +export async function markDepositForRecovery(depositId: string, reason: DepositRefundReason): Promise { + const deposit = await MoneriumFiatDeposit.findByPk(depositId); + if (!deposit) return "deposit not found"; + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) return "deposit has no account"; + return withForwarderLock(account.forwarderAddress, async transaction => { + const current = await MoneriumFiatDeposit.findByPk(depositId, { transaction }); + if (!current) return "deposit not found"; + if (!isForwardTransition(current.status, MoneriumFiatDepositStatus.Recovering)) { + return `deposit is ${current.status} and cannot be recovered`; + } + if (current.blockNumber === null) { + return "deposit has no chain-indexed mint yet"; + } + const pending = await MoneriumConversionExecution.count({ + transaction, + where: { depositId, status: MoneriumConversionExecutionStatus.Pending } + }); + if (pending > 0) { + return "deposit has a pending execution; retry once it settled"; + } + await current.update( + { refundReason: reason, refundStartedAt: new Date(), status: MoneriumFiatDepositStatus.Recovering }, + { transaction } + ); + return null; + }); +} 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 index c8bde0fae..9dac2b561 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.test.ts @@ -3,7 +3,6 @@ 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"; @@ -15,7 +14,8 @@ import { processMoneriumWebhookInbox } from "./deposit-processor"; -const { Held, Minted, Pending, Returned } = MoneriumFiatDepositStatus; +const { Converting, Forwarded, Held, Minted, Pending, Recovering, RecoveryFailed, Refunded, Returned } = + MoneriumFiatDepositStatus; const PROFILE_ID = "11111111-1111-4111-8111-111111111111"; const ORDER_ID = "22222222-2222-4222-8222-222222222222"; const PROCESSOR_DEPS = { getChainId: async () => 11155111 }; @@ -33,17 +33,34 @@ describe("forward-only deposit status transitions", () => { expect(isForwardTransition(Held, Pending)).toBe(false); }); - it("treats minted and returned as terminal", () => { - for (const to of [Pending, Held, Returned]) { + it("lets a minted deposit convert or enter the refund path, never regress", () => { + expect(isForwardTransition(Minted, Converting)).toBe(true); + expect(isForwardTransition(Minted, Recovering)).toBe(true); + for (const to of [Pending, Held, Returned, Forwarded, Refunded]) { expect(isForwardTransition(Minted, to)).toBe(false); } - for (const to of [Pending, Held, Minted]) { - expect(isForwardTransition(Returned, to)).toBe(false); + }); + + it("settles a converting deposit by forward or by recovery", () => { + expect(isForwardTransition(Converting, Forwarded)).toBe(true); + expect(isForwardTransition(Converting, Recovering)).toBe(true); + expect(isForwardTransition(Converting, Minted)).toBe(false); + expect(isForwardTransition(Recovering, Refunded)).toBe(true); + expect(isForwardTransition(Recovering, RecoveryFailed)).toBe(true); + expect(isForwardTransition(RecoveryFailed, Recovering)).toBe(true); // operator retry + expect(isForwardTransition(Recovering, Forwarded)).toBe(false); + }); + + it("treats forwarded, returned and refunded as terminal", () => { + for (const terminal of [Forwarded, Returned, Refunded]) { + for (const to of Object.values(MoneriumFiatDepositStatus)) { + expect(isForwardTransition(terminal, to)).toBe(false); + } } }); it("never allows a self-transition write", () => { - for (const status of [Pending, Held, Minted, Returned]) { + for (const status of Object.values(MoneriumFiatDepositStatus)) { expect(isForwardTransition(status, status)).toBe(false); } }); @@ -99,10 +116,27 @@ describe("parseOrderEvent", () => { orderId: ORDER_ID, profileId: PROFILE_ID, state: "processed", + payerIban: null, + payerName: null, + rejectedReason: null, txHash: "0xabc" }); }); + it("captures the payer's IBAN and name from an IBAN counterpart as the refund target", () => { + const payload = { + ...validPayload, + data: { + ...validPayload.data, + counterpart: { + details: { name: " Payer GmbH " }, + identifier: { iban: "de89 3704 0044 0532 0130 00", standard: "iban" } + } + } + }; + expect(parseOrderEvent(payload)).toMatchObject({ payerIban: "DE89370400440532013000", payerName: "Payer GmbH" }); + }); + 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(); @@ -180,8 +214,6 @@ describe("order-event inbox processing (end to end)", () => { async function createAccount(): Promise { return MoneriumAccount.create({ destination: "0x2222222222222222222222222222222222222222", - fallbackAddress: "0x3333333333333333333333333333333333333333", - feeBps: 0, forwarderAddress: FORWARDER, profileId: PROFILE_ID }); @@ -264,12 +296,7 @@ describe("order-event inbox processing (end to end)", () => { 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 execution.update({ depositId: unattributed.id }); await MoneriumWebhookEvent.create({ eventId: "evt-late-order", payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) @@ -278,7 +305,7 @@ describe("order-event inbox processing (end to end)", () => { expect(await processMoneriumWebhookInbox(PROCESSOR_DEPS)).toBe(1); expect(await MoneriumFiatDeposit.count()).toBe(1); await unattributed.reload(); - await allocation.reload(); + await execution.reload(); expect(unattributed).toMatchObject({ blockHash: "0xblock", blockNumber: 100, @@ -287,7 +314,7 @@ describe("order-event inbox processing (end to end)", () => { moneriumOrderId: ORDER_ID, txHash: "0xmint" }); - expect(allocation.depositId).toBe(unattributed.id); + expect(execution.depositId).toBe(unattributed.id); }); it("merges an unattributed mint when a tx hash resolves equal-amount order ambiguity", async () => { @@ -326,12 +353,7 @@ describe("order-event inbox processing (end to end)", () => { 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 execution.update({ depositId: unattributed.id }); await MoneriumWebhookEvent.create({ eventId: "evt-ambiguous-order-resolved", payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) @@ -340,7 +362,7 @@ describe("order-event inbox processing (end to end)", () => { await processMoneriumWebhookInbox(PROCESSOR_DEPS); await providerDeposit.reload(); await otherDeposit.reload(); - await allocation.reload(); + await execution.reload(); expect(await MoneriumFiatDeposit.count()).toBe(2); expect(providerDeposit).toMatchObject({ blockHash: "0xblock", @@ -351,7 +373,7 @@ describe("order-event inbox processing (end to end)", () => { txHash: "0xmint" }); expect(otherDeposit).toMatchObject({ blockNumber: null, status: MoneriumFiatDepositStatus.Pending, txHash: null }); - expect(allocation.depositId).toBe(providerDeposit.id); + expect(execution.depositId).toBe(providerDeposit.id); }); it("never merges a quarantined mint into a terminal returned order", async () => { @@ -386,12 +408,7 @@ describe("order-event inbox processing (end to end)", () => { txHash: "0xswap", usdcNetRaw: "108000000" }); - const allocation = await MoneriumDepositAllocation.create({ - depositId: unattributed.id, - eureInRaw: amountRaw, - executionId: execution.id, - usdcNetRaw: execution.usdcNetRaw as string - }); + await execution.update({ depositId: unattributed.id }); await MoneriumWebhookEvent.create({ eventId: "evt-returned-order-mint", payload: orderEvent("processed", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) @@ -400,7 +417,7 @@ describe("order-event inbox processing (end to end)", () => { await processMoneriumWebhookInbox(PROCESSOR_DEPS); await providerDeposit.reload(); await unattributed.reload(); - await allocation.reload(); + await execution.reload(); expect(providerDeposit).toMatchObject({ blockHash: null, blockNumber: null, @@ -410,7 +427,7 @@ describe("order-event inbox processing (end to end)", () => { txHash: null }); expect(unattributed.txHash).toBe("0xmint"); - expect(allocation.depositId).toBe(unattributed.id); + expect(execution.depositId).toBe(unattributed.id); }); it("does not adopt an unattributed mint for a first-seen returned order", async () => { @@ -438,29 +455,27 @@ describe("order-event inbox processing (end to end)", () => { txHash: "0xswap", usdcNetRaw: "108000000" }); - const allocation = await MoneriumDepositAllocation.create({ - depositId: unattributed.id, - eureInRaw: amountRaw, - executionId: execution.id, - usdcNetRaw: execution.usdcNetRaw as string - }); + await execution.update({ depositId: unattributed.id }); await MoneriumWebhookEvent.create({ eventId: "evt-first-seen-returned", - payload: orderEvent("rejected", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) + payload: orderEvent("rejected", { + meta: { placedAt: "2026-08-26T00:00:00Z", rejectedReason: "Payer verification failed", txHashes: ["0xmint"] } + }) }); await processMoneriumWebhookInbox(PROCESSOR_DEPS); const providerDeposit = await MoneriumFiatDeposit.findOne({ where: { moneriumOrderId: ORDER_ID } }); await unattributed.reload(); - await allocation.reload(); + await execution.reload(); expect(await MoneriumFiatDeposit.count()).toBe(2); expect(providerDeposit).toMatchObject({ blockNumber: null, + rejectedReason: "Payer verification failed", status: MoneriumFiatDepositStatus.Returned, txHash: "0xmint" }); expect(unattributed.moneriumOrderId).toBe("unattr:first-seen-returned"); - expect(allocation.depositId).toBe(unattributed.id); + expect(execution.depositId).toBe(unattributed.id); }); it("discards wrong-currency, wrong-chain, and foreign-profile orders", async () => { diff --git a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts index 87b2c53f5..3b6f03012 100644 --- a/apps/api/src/api/services/monerium-b2b/deposit-processor.ts +++ b/apps/api/src/api/services/monerium-b2b/deposit-processor.ts @@ -8,7 +8,7 @@ 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 MoneriumConversionExecution from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; import { getChainId, moneriumChainForChainId } from "./chain"; @@ -39,8 +39,10 @@ export async function withForwarderLock(forwarderAddress: string, fn: (transa }); } -// Forward-only lattice (plan §3): pending → minted/held/returned; a compliance hold can -// still resolve to minted or returned; minted/returned are terminal. +// Forward-only lattice (plan §3): the provider states first — pending → minted/held/ +// returned, a compliance hold resolves to minted or returned — then the keeper's +// settlement branch (minted → converting → forwarded) or the refund branch (minted or +// converting → recovering → refunded, or recovery_failed for the operator, who may retry). const FORWARD_TRANSITIONS: Record = { [MoneriumFiatDepositStatus.Pending]: [ MoneriumFiatDepositStatus.Minted, @@ -48,14 +50,28 @@ const FORWARD_TRANSITIONS: Record ${targetStatus} for order ${event.orderId}` 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 index 6c20fa588..f2707981e 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.test.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.test.ts @@ -2,20 +2,24 @@ 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 MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.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"; +import { markDepositForRecovery } from "./conversion-executor"; +import { emitMoneriumDepositEvents, maskIban } 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", () => { @@ -47,7 +51,6 @@ describe("monerium b2b manager events", () => { contactEmail: "ops@client.example.com", destination: DESTINATION, externalSubjectId: "client-1", - fallbackAddress: FALLBACK, forwarderAddress: FORWARDER, managerProfileId: manager.id, moneriumProfileId: MONERIUM_PROFILE @@ -155,63 +158,91 @@ describe("monerium b2b manager events", () => { expect(await WebhookDelivery.count()).toBe(0); }); - it("emits one aggregate DEPOSIT_CONVERTED only after every allocation reaches confirmation depth", async () => { + it("emits DEPOSIT_RECEIVED for a deposit the keeper already started converting", async () => { + const { mapped } = 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.Converting, + txHash: "0xmint" + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + const deliveries = await WebhookDelivery.findAll(); + expect(deliveries).toHaveLength(1); + expect(deliveries[0].payload).toMatchObject({ payload: { depositId: deposit.id, status: "converting" } }); + }); + + it("emits one DEPOSIT_CONVERTED with every chunk once the forward reaches confirmation depth", async () => { const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_CONVERTED]); + 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.Converting, + txHash: "0xmint" + }); const firstExecution = await MoneriumConversionExecution.create({ accountId: mapped.accountId, blockNumber: 1000, + depositId: deposit.id, destination: DESTINATION, eureInRaw: "60000000000000000000", + feeRaw: "81000", + referenceRateRaw: "108140000", status: MoneriumConversionExecutionStatus.Confirmed, + subsidyRaw: "0", txHash: "0xswap1", usdcNetRaw: "64800000" }); const secondExecution = await MoneriumConversionExecution.create({ accountId: mapped.accountId, blockNumber: 1001, + depositId: deposit.id, destination: DESTINATION, eureInRaw: "40000000000000000000", + feeRaw: "0", + referenceRateRaw: "108120000", status: MoneriumConversionExecutionStatus.Confirmed, + subsidyRaw: "120000", 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))); + // Converted but not forwarded: the partner must not see a final event yet. + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH))); expect(await WebhookDelivery.count()).toBe(0); - await MoneriumDepositAllocation.create({ + await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1002, depositId: deposit.id, - eureInRaw: "40000000000000000000", - executionId: secondExecution.id, - usdcNetRaw: "43200000" + destination: DESTINATION, + eureInRaw: "100000000000000000000", + kind: MoneriumConversionExecutionKind.Forward, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xforward", + usdcNetRaw: "108000000" }); + await deposit.update({ status: MoneriumFiatDepositStatus.Forwarded }); // One block short of the depth: nothing emitted, marker untouched. - await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH - 1))); + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1002 + 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))); + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1002 + NOTIFY_CONFIRMATION_DEPTH))); const deliveries = await WebhookDelivery.findAll(); expect(deliveries).toHaveLength(1); expect(deliveries[0]).toMatchObject({ @@ -222,10 +253,24 @@ describe("monerium b2b manager events", () => { 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" } + { + eureInRaw: "60000000000000000000", + execution: { feeRaw: "81000", referenceRateRaw: "108140000", subsidyRaw: "0" }, + executionId: firstExecution.id, + txHash: "0xswap1", + usdcNetRaw: "64800000" + }, + { + eureInRaw: "40000000000000000000", + execution: { feeRaw: "0", referenceRateRaw: "108120000", subsidyRaw: "120000" }, + executionId: secondExecution.id, + txHash: "0xswap2", + usdcNetRaw: "43200000" + } ], depositId: deposit.id, + forwardTxHash: "0xforward", + status: "forwarded", usdcNetRaw: "108000000" } }); @@ -233,10 +278,63 @@ describe("monerium b2b manager events", () => { expect(deposit.convertedEventAt).not.toBeNull(); // Replay is a no-op. - await emitMoneriumDepositEvents(depsAtBlock(BigInt(1001 + NOTIFY_CONFIRMATION_DEPTH))); + await emitMoneriumDepositEvents(depsAtBlock(BigInt(1002 + NOTIFY_CONFIRMATION_DEPTH))); expect(await WebhookDelivery.count()).toBe(1); }); + it("emits DEPOSIT_RETURNED once a deposit was refunded, with the refund facts and a masked IBAN", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RETURNED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 999, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + receivedEventAt: new Date(), + status: MoneriumFiatDepositStatus.Refunded, + txHash: "0xmint" + }); + await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: "100000000000000000000", + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xrecover", + usdcNetRaw: "0" + }); + await MoneriumRecovery.create({ + depositId: deposit.id, + eureRecoveredRaw: "100000000000000000000", + phase: MoneriumRecoveryPhase.Redeemed, + redeemOrderId: "order-redeem-1", + refundAmount: "100.00", + usdcRecoveredRaw: "0" + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await emitMoneriumDepositEvents(depsAtBlock(null)); + const deliveries = await WebhookDelivery.findAll(); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]).toMatchObject({ eventId: `deposit-returned:${deposit.id}`, webhookId: webhook?.id }); + expect(deliveries[0].payload).toMatchObject({ + eventType: WebhookEventType.DEPOSIT_RETURNED, + payload: { + depositId: deposit.id, + refund: { amount: "100.00", payerIbanMasked: "DE89…3000", recoverTxHash: "0xrecover", redeemOrderId: "order-redeem-1" }, + status: "refunded" + } + }); + await deposit.reload(); + expect(deposit.returnedEventAt).not.toBeNull(); + expect(maskIban("EE08 7224 5745 6244 9516")).toBe("EE08…9516"); + }); + it("only enqueues to the controlling manager's webhooks", async () => { const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_RECEIVED]); const otherManager = await createTestUser(); @@ -267,4 +365,141 @@ describe("monerium b2b manager events", () => { expect(deliveries).toHaveLength(1); expect((deliveries[0] as WebhookDelivery & { webhook: Webhook }).webhook.url).toBe("https://manager.example.com/hook"); }); + + it("sends DEPOSIT_UPDATED with the full snapshot on every change, and only then", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_UPDATED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + currency: "eur", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Pending + }); + const snapshots = async () => + (await WebhookDelivery.findAll({ order: [["created_at", "ASC"]] })).map(delivery => { + expect(delivery.webhookId).toBe(webhook?.id as string); + return (delivery.payload as unknown as { payload: Record }).payload; + }); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await emitMoneriumDepositEvents(depsAtBlock(null)); + expect(await snapshots()).toHaveLength(1); + expect((await snapshots())[0]).toMatchObject({ + amount: "100.00", + depositId: deposit.id, + externalSubjectId: "client-1", + moneriumOrderId: "order-1", + moneriumProfileId: MONERIUM_PROFILE, + profileId: mapped.profileId, + refund: null, + status: "pending", + waiting: { reason: "monerium_pending" } + }); + + // Minted, then held by the keeper on the market: two more snapshots. + await deposit.update({ + blockNumber: 100, + chainId: 11155111, + logIndex: 1, + mintedAt: new Date(), + status: MoneriumFiatDepositStatus.Minted, + txHash: "0xmint" + }); + await emitMoneriumDepositEvents(depsAtBlock(null)); + await deposit.update({ waitingReason: "below_floor", waitingSince: new Date() }); + await emitMoneriumDepositEvents(depsAtBlock(null)); + let sent = await snapshots(); + expect(sent).toHaveLength(3); + expect(sent[1]).toMatchObject({ status: "minted", txHash: "0xmint", waiting: null }); + expect(sent[2]).toMatchObject({ status: "minted", waiting: { reason: "below_floor" } }); + + // The refund path carries its reason and start time; the market hold no longer applies. + expect(await markDepositForRecovery(deposit.id, "window_missed")).toBeNull(); + await emitMoneriumDepositEvents(depsAtBlock(null)); + sent = await snapshots(); + expect(sent).toHaveLength(4); + expect(sent[3]).toMatchObject({ + refund: { amount: "100.00", reason: "window_missed", startedAt: expect.any(String) }, + status: "recovering", + waiting: null + }); + const eventIds = (await WebhookDelivery.findAll()).map(delivery => delivery.eventId); + expect(new Set(eventIds).size).toBe(4); + }); + + it("reports a forwarded deposit through DEPOSIT_UPDATED only at confirmation depth", async () => { + const { mapped } = await setupAccountWithWebhook([WebhookEventType.DEPOSIT_UPDATED]); + const deposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + amountRaw: "100000000000000000000", + blockNumber: 999, + chainId: 11155111, + currency: "eur", + logIndex: 1, + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Converting, + txHash: "0xmint" + }); + await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1000, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: "100000000000000000000", + feeRaw: "0", + referenceRateRaw: "108140000", + status: MoneriumConversionExecutionStatus.Confirmed, + subsidyRaw: "0", + txHash: "0xswap", + usdcNetRaw: "108000000" + }); + await emitMoneriumDepositEvents(depsAtBlock(null)); + await MoneriumConversionExecution.create({ + accountId: mapped.accountId, + blockNumber: 1001, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: "100000000000000000000", + kind: MoneriumConversionExecutionKind.Forward, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xforward", + usdcNetRaw: "108000000" + }); + await deposit.update({ status: MoneriumFiatDepositStatus.Forwarded }); + + await emitMoneriumDepositEvents(depsAtBlock(1001n)); + expect(await WebhookDelivery.count()).toBe(1); + await emitMoneriumDepositEvents(depsAtBlock(1001n + BigInt(NOTIFY_CONFIRMATION_DEPTH))); + await emitMoneriumDepositEvents(depsAtBlock(1001n + BigInt(NOTIFY_CONFIRMATION_DEPTH))); + const deliveries = await WebhookDelivery.findAll({ order: [["created_at", "ASC"]] }); + expect(deliveries).toHaveLength(2); + expect((deliveries[1].payload as { payload: unknown }).payload).toMatchObject({ + conversions: [{ confirmedAt: expect.any(String), status: "confirmed", txHash: "0xswap", usdcNetRaw: "108000000" }], + deliveredAt: expect.any(String), + forwardTxHash: "0xforward", + status: "forwarded", + usdcNetRaw: "108000000" + }); + }); + + it("sends ACCOUNT_UPDATED when the account snapshot changes", async () => { + const { mapped, webhook } = await setupAccountWithWebhook([WebhookEventType.ACCOUNT_UPDATED]); + + await emitMoneriumDepositEvents(depsAtBlock(null)); + await emitMoneriumDepositEvents(depsAtBlock(null)); + await MoneriumAccount.update({ iban: "EE123456789012345678" }, { where: { id: mapped.accountId } }); + await emitMoneriumDepositEvents(depsAtBlock(null)); + + const deliveries = await WebhookDelivery.findAll({ order: [["created_at", "ASC"]] }); + expect(deliveries).toHaveLength(2); + expect(deliveries.every(delivery => delivery.webhookId === webhook?.id)).toBe(true); + expect((deliveries[0].payload as { payload: unknown }).payload).toMatchObject({ + accountId: mapped.accountId, + externalSubjectId: "client-1", + iban: null, + moneriumProfileId: MONERIUM_PROFILE, + profileId: mapped.profileId + }); + expect((deliveries[1].payload as { payload: unknown }).payload).toMatchObject({ iban: "EE123456789012345678" }); + }); }); diff --git a/apps/api/src/api/services/monerium-b2b/manager-events.ts b/apps/api/src/api/services/monerium-b2b/manager-events.ts index d50782c8c..c3b46bb5f 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -1,4 +1,15 @@ -import { DepositStatus, type DepositWebhookPayloadBase, WebhookEventType, type WebhookPayload } from "@vortexfi/shared"; +import { createHash } from "node:crypto"; +import { + type AccountSnapshot, + type ConversionExecutionPricing, + type DepositRefundReason, + type DepositSnapshot, + DepositStatus, + type DepositWaitingReason, + type DepositWebhookPayloadBase, + WebhookEventType, + type WebhookPayload +} from "@vortexfi/shared"; import { Op } from "sequelize"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; @@ -6,10 +17,11 @@ import { config } from "../../../config/vars"; import ManagedProfile from "../../../models/managedProfile.model"; import MoneriumAccount from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, MoneriumConversionExecutionStatus } from "../../../models/moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "../../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumRecovery from "../../../models/moneriumRecovery.model"; import webhookService from "../webhook/webhook.service"; import { enqueueWebhookDeliveries } from "../webhook/webhook-outbox.service"; import { getPublicClient, NOTIFY_CONFIRMATION_DEPTH } from "./chain"; @@ -29,6 +41,21 @@ const defaultDeps: ManagerEventDeps = { } }; +/** First and last four characters of an IBAN, for partner-facing payloads. */ +export function maskIban(iban: string): string { + const compact = iban.replace(/\s+/g, ""); + return compact.length <= 8 ? compact : `${compact.slice(0, 4)}…${compact.slice(-4)}`; +} + +/** Execution-level pricing facts, identical on every deposit portion the execution consumed. */ +export function executionPricing(execution: MoneriumConversionExecution): ConversionExecutionPricing { + return { + feeRaw: execution.feeRaw, + referenceRateRaw: execution.referenceRateRaw, + subsidyRaw: execution.subsidyRaw + }; +} + function depositPayloadBase(deposit: MoneriumFiatDeposit, account: MoneriumAccount): DepositWebhookPayloadBase { return { accountId: account.id, @@ -42,18 +69,133 @@ function depositPayloadBase(deposit: MoneriumFiatDeposit, account: MoneriumAccou } /** - * 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. + * The active managed relationship behind an account: its controlling manager and the + * partner's client reference. Null when the account is unmapped or the relationship is + * gone; events are then marked emitted with no deliveries, so history is never replayed + * to late subscribers. */ -async function resolveManagerProfileId(account: MoneriumAccount): Promise { +export async function findRelationship(account: MoneriumAccount): Promise { if (!account.vortexProfileId) return null; - const relationship = await ManagedProfile.findOne({ - where: { profileId: account.vortexProfileId, status: "active" } + return ManagedProfile.findOne({ where: { profileId: account.vortexProfileId, status: "active" } }); +} + +async function resolveManagerProfileId(account: MoneriumAccount): Promise { + return (await findRelationship(account))?.managerProfileId ?? null; +} + +const iso = (date: Date | null | undefined): string | null => (date ? date.toISOString() : null); + +export function accountSnapshot(account: MoneriumAccount, relationship: ManagedProfile | null | undefined): AccountSnapshot { + return { + accountId: account.id, + createdAt: account.createdAt.toISOString(), + destination: account.destination, + dormantSince: iso(account.dormantSince), + externalSubjectId: relationship?.externalSubjectId ?? null, + floorPpm: account.floorPpm, + forwarderAddress: account.forwarderAddress, + iban: account.iban, + moneriumProfileId: account.profileId, + profileId: account.vortexProfileId, + status: account.status, + targetPpm: account.targetPpm + }; +} + +function waitingOf(deposit: MoneriumFiatDeposit): DepositSnapshot["waiting"] { + if (deposit.status === MoneriumFiatDepositStatus.Pending || deposit.status === MoneriumFiatDepositStatus.Held) { + return { reason: "monerium_pending", since: deposit.createdAt.toISOString() }; + } + const converting = + deposit.status === MoneriumFiatDepositStatus.Minted || deposit.status === MoneriumFiatDepositStatus.Converting; + if (!converting || !deposit.waitingReason) return null; + return { + reason: deposit.waitingReason as DepositWaitingReason, + since: iso(deposit.waitingSince) ?? deposit.createdAt.toISOString() + }; +} + +/** A confirmed execution row is terminal, so its last update is its confirmation. */ +const confirmedAt = (execution: MoneriumConversionExecution): string | null => + execution.status === MoneriumConversionExecutionStatus.Confirmed ? execution.updatedAt.toISOString() : null; + +/** + * Snapshots of one account's deposits, in the given order. The deposits endpoint and the + * DEPOSIT_UPDATED event share this shape. + */ +export async function depositSnapshots( + account: MoneriumAccount, + relationship: ManagedProfile | null | undefined, + deposits: MoneriumFiatDeposit[] +): Promise { + if (deposits.length === 0) return []; + const depositIds = deposits.map(deposit => deposit.id); + const [executions, recoveries] = await Promise.all([ + MoneriumConversionExecution.findAll({ + order: [["created_at", "ASC"]], + where: { depositId: depositIds, status: { [Op.ne]: MoneriumConversionExecutionStatus.Failed } } + }), + MoneriumRecovery.findAll({ where: { depositId: depositIds } }) + ]); + return deposits.map(deposit => { + const own = executions.filter(execution => execution.depositId === deposit.id); + const confirmed = (kind: MoneriumConversionExecutionKind) => + own.find(execution => execution.kind === kind && execution.status === MoneriumConversionExecutionStatus.Confirmed); + const swaps = own.filter(execution => execution.kind === MoneriumConversionExecutionKind.Swap); + const forward = confirmed(MoneriumConversionExecutionKind.Forward); + const recover = confirmed(MoneriumConversionExecutionKind.Recover); + const recovery = recoveries.find(row => row.depositId === deposit.id); + return { + accountId: account.id, + amount: eurAmountFromRaw(deposit.amountRaw), + amountRaw: deposit.amountRaw, + conversions: swaps.map(execution => ({ + confirmedAt: confirmedAt(execution), + eureInRaw: execution.eureInRaw, + execution: executionPricing(execution), + executionId: execution.id, + sentAt: execution.createdAt.toISOString(), + status: execution.status as "pending" | "confirmed", + txHash: execution.txHash, + usdcNetRaw: execution.usdcNetRaw ?? "0" + })), + currency: deposit.currency, + deliveredAt: forward ? confirmedAt(forward) : null, + depositId: deposit.id, + externalSubjectId: relationship?.externalSubjectId ?? null, + forwardTxHash: forward?.txHash ?? null, + mintedAt: iso(deposit.mintedAt), + moneriumOrderId: deposit.moneriumOrderId, + moneriumProfileId: account.profileId, + profileId: account.vortexProfileId as string, + receivedAt: deposit.createdAt.toISOString(), + refund: + deposit.refundStartedAt || recovery || recover + ? { + amount: recovery?.refundAmount ?? eurAmountFromRaw(deposit.amountRaw), + payerIbanMasked: deposit.payerIban ? maskIban(deposit.payerIban) : null, + reason: deposit.refundReason as DepositRefundReason | null, + recoverTxHash: recover?.txHash ?? null, + redeemOrderId: recovery?.redeemOrderId ?? null, + refundedAt: deposit.status === MoneriumFiatDepositStatus.Refunded ? iso(recovery?.updatedAt) : null, + startedAt: iso(deposit.refundStartedAt) + } + : null, + rejectedReason: deposit.rejectedReason, + status: deposit.status as unknown as DepositStatus, + txHash: deposit.txHash, + usdcNetRaw: swaps + .filter(execution => execution.status === MoneriumConversionExecutionStatus.Confirmed) + .reduce((sum, execution) => sum + BigInt(execution.usdcNetRaw ?? "0"), 0n) + .toString(), + waiting: waitingOf(deposit) + }; }); - return relationship?.managerProfileId ?? null; } +const snapshotHash = (snapshot: AccountSnapshot | DepositSnapshot): string => + createHash("sha256").update(JSON.stringify(snapshot)).digest("hex"); + async function enqueueForManager( eventType: WebhookEventType, managerProfileId: string | null, @@ -75,7 +217,10 @@ async function emitReceivedEvents(): Promise { logIndex: { [Op.ne]: null }, moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, receivedEventAt: null, - status: MoneriumFiatDepositStatus.Minted, + // Any state past the mint: the keeper may have started converting within the cycle. + status: { + [Op.notIn]: [MoneriumFiatDepositStatus.Pending, MoneriumFiatDepositStatus.Held, MoneriumFiatDepositStatus.Returned] + }, txHash: { [Op.ne]: null } } }); @@ -109,9 +254,8 @@ async function emitConvertedEvents(deps: ManagerEventDeps): Promise { 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 + status: MoneriumFiatDepositStatus.Forwarded } }); if (deposits.length === 0) return; @@ -129,28 +273,17 @@ async function emitConvertedEvents(deps: ManagerEventDeps): Promise { } 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) } + order: [["created_at", "ASC"]], + where: { depositId: deposit.id, status: MoneriumConversionExecutionStatus.Confirmed } }); - 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) - ) - ) { + const forward = executions.find(execution => execution.kind === MoneriumConversionExecutionKind.Forward); + const swaps = executions.filter(execution => execution.kind === MoneriumConversionExecutionKind.Swap); + if (!forward || swaps.length === 0) return; + // Confirmation-depth gate (plan §3, registry P9): only notify once the forward is + // NOTIFY_CONFIRMATION_DEPTH blocks below the head, so a shallow reorg cannot produce a + // delivered-then-vanished conversion event. The chunks precede the forward by construction. + if (forward.blockNumber === null || head < BigInt(forward.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH)) { return; } @@ -162,16 +295,15 @@ async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: 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() + conversions: swaps.map(execution => ({ + eureInRaw: execution.eureInRaw, + execution: executionPricing(execution), + executionId: execution.id, + txHash: execution.txHash, + usdcNetRaw: execution.usdcNetRaw ?? "0" + })), + forwardTxHash: forward.txHash, + usdcNetRaw: forward.usdcNetRaw ?? "0" }, timestamp: new Date().toISOString() }; @@ -179,16 +311,167 @@ async function emitConvertedEventForDeposit(deposit: MoneriumFiatDeposit, head: await deposit.update({ convertedEventAt: new Date() }); } +async function emitReturnedEvents(): Promise { + const deposits = await MoneriumFiatDeposit.findAll({ + limit: BATCH_LIMIT, + order: [["created_at", "ASC"]], + where: { + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + returnedEventAt: null, + status: MoneriumFiatDepositStatus.Refunded + } + }); + for (const deposit of deposits) { + try { + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) continue; + const [recovery, recoverExecution] = await Promise.all([ + MoneriumRecovery.findOne({ where: { depositId: deposit.id } }), + MoneriumConversionExecution.findOne({ + where: { + depositId: deposit.id, + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed + } + }) + ]); + const managerProfileId = await resolveManagerProfileId(account); + const payload: WebhookPayload = { + eventId: `deposit-returned:${deposit.id}`, + eventType: WebhookEventType.DEPOSIT_RETURNED, + payload: { + ...depositPayloadBase(deposit, account), + refund: { + amount: recovery?.refundAmount ?? eurAmountFromRaw(deposit.amountRaw), + payerIbanMasked: deposit.payerIban ? maskIban(deposit.payerIban) : "", + recoverTxHash: recoverExecution?.txHash ?? null, + redeemOrderId: recovery?.redeemOrderId ?? null + } + }, + timestamp: new Date().toISOString() + }; + await enqueueForManager(WebhookEventType.DEPOSIT_RETURNED, managerProfileId, payload); + await deposit.update({ returnedEventAt: new Date() }); + } catch (error) { + logger.error(`monerium-b2b: DEPOSIT_RETURNED emission failed for deposit ${deposit.id}:`, error); + } + } +} + +/** An 18-decimal EUR amount to the cent ("1234.56"). */ +function eurAmountFromRaw(amountRaw: string): string { + const cents = BigInt(amountRaw) / 10n ** 16n; + return `${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`; +} + +const SETTLED_STATUSES = [ + MoneriumFiatDepositStatus.Forwarded, + MoneriumFiatDepositStatus.Returned, + MoneriumFiatDepositStatus.Refunded +]; + +/** Same depth gate as DEPOSIT_CONVERTED: "forwarded" is reported once the forward cannot reorg away. */ +async function forwardIsDeep(depositId: string, head: bigint | null): Promise { + if (head === null) return false; + const forward = await MoneriumConversionExecution.findOne({ + where: { + depositId, + kind: MoneriumConversionExecutionKind.Forward, + status: MoneriumConversionExecutionStatus.Confirmed + } + }); + return ( + forward?.blockNumber !== null && + forward?.blockNumber !== undefined && + head >= BigInt(forward.blockNumber) + BigInt(NOTIFY_CONFIRMATION_DEPTH) + ); +} + +/** + * DEPOSIT_UPDATED: the full snapshot whenever it changed since the last one sent. Unsettled + * deposits are re-evaluated every pass; a settled one once more after its last change. + */ +async function emitDepositUpdatedEvents(deps: ManagerEventDeps): Promise { + // ponytail: oldest BATCH_LIMIT unsettled deposits per pass; page through them if a + // partner ever has more than that in flight at once. + const deposits = await MoneriumFiatDeposit.findAll({ + limit: BATCH_LIMIT, + order: [["created_at", "ASC"]], + where: { + [Op.or]: [ + { status: { [Op.notIn]: SETTLED_STATUSES } }, + { lifecycleEventAt: null }, + sequelize.where(sequelize.col("updated_at"), Op.gt, sequelize.col("lifecycle_event_at")) + ], + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` } + } + }); + let head: bigint | null | undefined; + for (const deposit of deposits) { + try { + if (deposit.status === MoneriumFiatDepositStatus.Forwarded) { + if (head === undefined) head = await deps.getBlockNumber(); + if (!(await forwardIsDeep(deposit.id, head))) continue; + } + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account?.vortexProfileId) continue; + const relationship = await findRelationship(account); + const [snapshot] = await depositSnapshots(account, relationship, [deposit]); + const hash = snapshotHash(snapshot); + if (hash !== deposit.lifecycleEventHash) { + await enqueueForManager(WebhookEventType.DEPOSIT_UPDATED, relationship?.managerProfileId ?? null, { + eventId: `deposit-updated:${deposit.id}:${hash.slice(0, 16)}`, + eventType: WebhookEventType.DEPOSIT_UPDATED, + payload: snapshot, + timestamp: new Date().toISOString() + }); + } + // Silent: the marker must not bump updated_at, which is what re-queues a settled deposit. + await deposit.update({ lifecycleEventAt: new Date(), lifecycleEventHash: hash }, { silent: true }); + } catch (error) { + logger.error(`monerium-b2b: DEPOSIT_UPDATED emission failed for deposit ${deposit.id}:`, error); + } + } +} + +/** ACCOUNT_UPDATED: the account snapshot whenever it changed (IBAN issued, status, dormancy). */ +async function emitAccountUpdatedEvents(): Promise { + // ponytail: scans every mapped account each pass; fine at per-partner pilot scale. + const accounts = await MoneriumAccount.findAll({ where: { vortexProfileId: { [Op.ne]: null } } }); + for (const account of accounts) { + try { + const relationship = await findRelationship(account); + const snapshot = accountSnapshot(account, relationship); + const hash = snapshotHash(snapshot); + if (hash === account.lifecycleEventHash) continue; + await enqueueForManager(WebhookEventType.ACCOUNT_UPDATED, relationship?.managerProfileId ?? null, { + eventId: `account-updated:${account.id}:${hash.slice(0, 16)}`, + eventType: WebhookEventType.ACCOUNT_UPDATED, + payload: snapshot, + timestamp: new Date().toISOString() + }); + await account.update({ lifecycleEventHash: hash }, { silent: true }); + } catch (error) { + logger.error(`monerium-b2b: ACCOUNT_UPDATED emission failed for account ${account.id}:`, error); + } + } +} + /** * 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. + * DEPOSIT_RECEIVED once a deposit is minted, DEPOSIT_CONVERTED once the whole converted + * deposit was forwarded to the destination and that forward sits at notification depth, + * DEPOSIT_RETURNED once a deposit that missed the promised window was refunded, and + * DEPOSIT_UPDATED / ACCOUNT_UPDATED whenever a deposit's or account's snapshot changed. + * 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); + await emitReturnedEvents(); + await emitDepositUpdatedEvents(deps); + await emitAccountUpdatedEvents(); } catch (error) { logger.error("monerium-b2b: manager event emission failed:", error); } diff --git a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts index c5c793f3a..ed5e2c87b 100644 --- a/apps/api/src/api/services/monerium-b2b/mint-watcher.ts +++ b/apps/api/src/api/services/monerium-b2b/mint-watcher.ts @@ -70,6 +70,8 @@ export function matchMintLogToDeposit(log: MintLogFields, candidates: MatchableD interface ObservedMint { blockHash: string; blockNumber: number; + /** Block timestamp: the promised conversion window counts from here. */ + mintedAt: Date; logIndex: number; to: Address; txHash: string; @@ -120,6 +122,7 @@ async function recordMint( blockNumber: mint.blockNumber, chainId, logIndex: mint.logIndex, + mintedAt: mint.mintedAt, // Forward-only: pending -> minted; a webhook-minted row just gains chain fields. ...(deposit.status === MoneriumFiatDepositStatus.Pending ? { status: MoneriumFiatDepositStatus.Minted } : {}), txHash: mint.txHash @@ -148,6 +151,7 @@ async function recordMint( chainId, currency: "eur", logIndex: mint.logIndex, + mintedAt: mint.mintedAt, moneriumOrderId: syntheticUnattributedOrderId(chainId, mint.txHash, mint.logIndex), status: MoneriumFiatDepositStatus.Minted, txHash: mint.txHash @@ -205,15 +209,23 @@ export async function runMintWatcher(): Promise { }); const touchedAccounts = new Set(); + const blockTimestamps = new Map(); 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) } + let mintedAt = blockTimestamps.get(log.blockNumber); + if (!mintedAt) { + const block = await client.getBlock({ blockNumber: log.blockNumber }); + mintedAt = new Date(Number(block.timestamp) * 1000); + blockTimestamps.set(log.blockNumber, mintedAt); + } const accountId = await recordMint( { blockHash: log.blockHash, blockNumber: Number(log.blockNumber), logIndex: log.logIndex, + mintedAt, to: log.args.to as Address, txHash: log.transactionHash, valueRaw: log.args.value as bigint diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts index c8eaec395..a3a5b9f44 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.test.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "bun:test"; import { + classifyExecutableDepth, + classifyRefundQueue, classifyStranding, + classifyVaultRunway, computeQuoteImpactBps, detectConfigDrift, diffAssociation, eip1167RuntimeCode, - normalizeIban, - STRANDED_WARN_MS + normalizeIban } from "./monitoring"; // Pure monitoring logic (implementation plan D3): quote-impact math against the T6 @@ -50,26 +52,82 @@ describe("computeQuoteImpactBps", () => { }); }); +describe("classifyExecutableDepth", () => { + const SLIPPAGE_BPS = 60; + + it("is ok when the best route clears SLIPPAGE_BPS at both sizes", () => { + expect(classifyExecutableDepth(11, 30, SLIPPAGE_BPS).severity).toBe("ok"); + }); + + it("warns when only cap-sized fills would need a subsidy", () => { + const verdict = classifyExecutableDepth(11, 75, SLIPPAGE_BPS); + expect(verdict.severity).toBe("warn"); + expect(verdict.reason).toContain("perSwapCap"); + }); + + it("errors on a subsidizable min-size impact but names the subsidy, not a pause", () => { + // 70 bps raw impact: the vault (50 bps cap) still covers the shortfall below the + // policy floor and the keeper executes. + const verdict = classifyExecutableDepth(70, 90, SLIPPAGE_BPS); + expect(verdict.severity).toBe("error"); + expect(verdict.reason).toContain("subsidy"); + expect(verdict.reason).toContain("permissionless path would revert"); + expect(verdict.reason).not.toMatch(/pause/i); + }); +}); + describe("classifyStranding", () => { - const TRIGGER_DELAY = 86_400n; // 24h, registry P4 placeholder + const RECOVERY_DELAY = 7_200n; // 2h, registry P3 + const TRIGGER_DELAY = 86_400n; // 24h, registry P4 const now = 1_800_000_000_000; // fixed epoch ms - const armedAt = (msAgo: number): bigint => BigInt(Math.floor((now - msAgo) / 1000)); + const openedAt = (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 when no batch is open", () => { + expect(classifyStranding(0n, RECOVERY_DELAY, TRIGGER_DELAY, now)).toBe("ok"); }); - it("is ok within the warn window", () => { - expect(classifyStranding(armedAt(60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("ok"); + it("is ok inside the promised window", () => { + expect(classifyStranding(openedAt(60 * 60 * 1000), RECOVERY_DELAY, TRIGGER_DELAY, now)).toBe("ok"); }); - it("warns after 12h", () => { - expect(classifyStranding(armedAt(STRANDED_WARN_MS + 60_000), TRIGGER_DELAY, now)).toBe("warn"); + it("warns once the promised window (RECOVERY_DELAY) is missed", () => { + expect(classifyStranding(openedAt(2 * 60 * 60 * 1000 + 60_000), RECOVERY_DELAY, TRIGGER_DELAY, now)).toBe("warn"); }); it("errors past TRIGGER_DELAY", () => { - expect(classifyStranding(armedAt(25 * 60 * 60 * 1000), TRIGGER_DELAY, now)).toBe("error"); + expect(classifyStranding(openedAt(25 * 60 * 60 * 1000), RECOVERY_DELAY, TRIGGER_DELAY, now)).toBe("error"); + }); +}); + +describe("classifyRefundQueue", () => { + const now = 1_800_000_000_000; + it("is ok without an active refund or with a young one, warns after an hour, errors after four", () => { + expect(classifyRefundQueue(null, false, now)).toBe("ok"); + expect(classifyRefundQueue(new Date(now - 10 * 60_000), false, now)).toBe("ok"); + expect(classifyRefundQueue(new Date(now - 61 * 60_000), false, now)).toBe("warn"); + expect(classifyRefundQueue(new Date(now - 5 * 60 * 60_000), false, now)).toBe("error"); + }); + it("always errors on a failed refund", () => { + expect(classifyRefundQueue(new Date(now - 60_000), true, now)).toBe("error"); + }); +}); + +describe("classifyVaultRunway", () => { + const healthy = { balance: 1_000n * USDC, dailyBudget: 200n * USDC, paused: false, spentToday: 0n }; + + it("is ok with a funded, unpaused vault and budget left today", () => { + expect(classifyVaultRunway(healthy).severity).toBe("ok"); + }); + + it("errors when paused or empty, since every below-floor swap then defers", () => { + expect(classifyVaultRunway({ ...healthy, paused: true })).toMatchObject({ severity: "error" }); + expect(classifyVaultRunway({ ...healthy, balance: 0n })).toMatchObject({ severity: "error" }); + }); + + it("warns below one day of budget or once today's budget is spent", () => { + expect(classifyVaultRunway({ ...healthy, balance: 150n * USDC })).toMatchObject({ severity: "warn" }); + expect(classifyVaultRunway({ ...healthy, spentToday: 200n * USDC })).toMatchObject({ severity: "warn" }); }); }); @@ -133,8 +191,8 @@ describe("normalizeIban", () => { describe("detectConfigDrift", () => { const base = { destination: "0x1111111111111111111111111111111111111111", - fallbackAddress: "0x0d6455B4E46A4C9847f121Bd134B91B9666d6Df1", - feeBps: 0 + floorPpm: 1500, + targetPpm: 1250 }; it("reports nothing when the chain matches the db (case-insensitively)", () => { @@ -142,24 +200,18 @@ describe("detectConfigDrift", () => { 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("alarms on a destination change: the clone has no setter for it", () => { + const drift = detectConfigDrift(base, { ...base, destination: "0x4444444444444444444444444444444444444444" }); + expect(drift.ownerAuthorizedUpdates).toEqual({}); + expect(drift.errors).toEqual([ + "destination changed on chain to 0x4444444444444444444444444444444444444444 (recorded 0x1111111111111111111111111111111111111111)" + ]); }); - it("classifies a feeBps change as a guardian-authorized reconciliation (P11)", () => { - const drift = detectConfigDrift(base, { ...base, feeBps: 50 }); + it("classifies a fee-policy change as a guardian-authorized reconciliation (P11)", () => { + const drift = detectConfigDrift(base, { ...base, floorPpm: 3000, targetPpm: 2500 }); expect(drift.errors).toEqual([]); - expect(drift.ownerAuthorizedUpdates.feeBps).toBe(50); + expect(drift.ownerAuthorizedUpdates).toEqual({ floorPpm: 3000, targetPpm: 2500 }); }); }); diff --git a/apps/api/src/api/services/monerium-b2b/monitoring.ts b/apps/api/src/api/services/monerium-b2b/monitoring.ts index f0df89938..4b1bb2cf9 100644 --- a/apps/api/src/api/services/monerium-b2b/monitoring.ts +++ b/apps/api/src/api/services/monerium-b2b/monitoring.ts @@ -1,75 +1,72 @@ import { Op } from "sequelize"; -import { Address, encodePacked, Hex, parseAbi } from "viem"; +import { Address, formatUnits, Hex, parseAbi } from "viem"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; +import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; import { + chainlinkAbi, erc20Abi, factoryAbi, forwarderAbi, getChainId, + getFloatWalletClient, getForwarderImmutables, getPublicClient, - moneriumChainForChainId + moneriumChainForChainId, + quoteRouteOutput, + readEnabledRoutes, + readSubsidyVaultState, + SubsidyVaultState } from "./chain"; import { getProfileAddresses, isWhitelabelConfigured, listIbans } from "./monerium-api"; +import { COINBASE_REFERENCE_PRODUCT, classifyReferenceVenue, fetchCoinbaseProductStatus } from "./reference-rate"; /** * 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: + * from the keeper worker. Five 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. + * 1. Executable-depth check (main PRD §7.4, T6 follow-up): QuoterV2 static quotes on + * every enabled factory route at perSwapCap and minSwapAmount sizes vs the Chainlink + * EUR/USD rate. Raw impact of the best route above SLIPPAGE_BPS at minSwapAmount size + * means every keeper swap draws a subsidy and the permissionless path would revert + * (error-level DEPTH BELOW FLOOR line, triage per the runbook); at perSwapCap size it + * is an early warning. Mainnet-only (QuoterV2 pin). + * 2. Stranded-balance monitor: forwarders whose on-chain batch marker has been open + * longer than RECOVERY_DELAY (the promised window, registry P3) warn — the deposit + * should be forwarded or recovering by then; past TRIGGER_DELAY (the + * permissionless-trigger delay, registry P4) they error — a keeper-outage signal. + * 5. Subsidy-vault monitor: balance, daily budget and pause state of the shared vault + * (docs/architecture-monerium-b2b-onramp.md, fees section); a vault that cannot cover a + * below-floor swap makes the keeper defer, so runway problems surface here first. * 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. + * and clone bytecode. Guardian fee-policy changes (P11) are reconciled into the DB + * and logged, not alarmed; the destination has no setter, so a change there, like + * bytecode or registration drift, is an incident. + * 6. Reference-venue monitor: the Coinbase product the reference midpoint reads. A + * delisted or halted product keeps answering its endpoints with stale data, so every + * keeper swap would defer silently; its status is probed instead of assumed. + * 7. Refund monitor (automated refunds only): the active recovery must not linger, and + * the EURe float that tops refunds up must not run dry. * * 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 targetPpm() view returns (uint32)", + "function floorPpm() view returns (uint32)", "function TRIGGER_DELAY() view returns (uint256)", - "function POOL_FEE_EURE_EURC() view returns (uint24)", - "function POOL_FEE_EURC_USDC() view returns (uint24)" + "function RECOVERY_DELAY() view returns (uint256)" ]); const factoryMonitoringAbi = parseAbi([ @@ -81,7 +78,7 @@ const factoryMonitoringAbi = parseAbi([ /** * 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 + * negative when the quote beats the oracle). Same scaling as VortexForwarder._floorOut * without the slippage haircut: EURe 18 dp in, USDC 6 dp out. */ export function computeQuoteImpactBps( @@ -97,26 +94,90 @@ export function computeQuoteImpactBps( return Number(((expectedOut - quotedOutRaw) * 10_000n) / expectedOut); } +export type DepthSeverity = "error" | "ok" | "warn"; + +/** + * Verdict of the executable-depth check from the raw quote impact vs Chainlink at the + * two swap sizes. Settlement enforces SLIPPAGE_BPS on the client's NET, so a raw impact + * above it does not by itself revert a keeper swap: the vault covers the shortfall + * below the floor band up to its per-swap cap and the keeper defers beyond that + * (`projectSwap`). It does mean every keeper swap of that size draws a subsidy and the + * unsubsidized permissionless path would revert — a market condition to investigate, + * not a pause trigger on its own. + */ +export function classifyExecutableDepth( + minImpactBps: number, + capImpactBps: number, + slippageBps: number +): { reason: string; severity: DepthSeverity } { + if (minImpactBps > slippageBps) { + return { + reason: + "raw quote impact at minSwapAmount exceeds SLIPPAGE_BPS on every route: every keeper swap needs a vault " + + "subsidy (deferring once the shortfall exceeds the per-swap cap) and the permissionless path would revert", + severity: "error" + }; + } + if (capImpactBps > slippageBps) { + return { + reason: "raw quote impact at perSwapCap exceeds SLIPPAGE_BPS on the best route: cap-sized swaps need a vault subsidy", + severity: "warn" + }; + } + return { reason: "ok", severity: "ok" }; +} + 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. + * Severity of an open batch marker: older than TRIGGER_DELAY (the permissionless-trigger + * delay) is an error; older than RECOVERY_DELAY (the promised window) a warning. */ -export function classifyStranding(strandedSinceSec: bigint, triggerDelaySec: bigint, nowMs: number): StrandingSeverity { - if (strandedSinceSec === 0n) { +export function classifyStranding( + batchOpenedAtSec: bigint, + recoveryDelaySec: bigint, + triggerDelaySec: bigint, + nowMs: number +): StrandingSeverity { + if (batchOpenedAtSec === 0n) { return "ok"; } - const armedMs = nowMs - Number(strandedSinceSec) * 1000; - if (armedMs >= Number(triggerDelaySec) * 1000) { + const openMs = nowMs - Number(batchOpenedAtSec) * 1000; + if (openMs >= Number(triggerDelaySec) * 1000) { return "error"; } - if (armedMs >= STRANDED_WARN_MS) { + if (openMs >= Number(recoveryDelaySec) * 1000) { return "warn"; } return "ok"; } +export type VaultRunwaySeverity = "error" | "ok" | "warn"; + +/** + * Runway of the shared subsidy vault. Paused or empty is an error (every below-floor + * swap defers); less than one day of budget on hand, or today's budget already spent, + * is a warning worth a refill before clients notice. + */ +export function classifyVaultRunway(state: Pick): { + reason: string; + severity: VaultRunwaySeverity; +} { + if (state.paused) { + return { reason: "vault is paused", severity: "error" }; + } + if (state.balance === 0n) { + return { reason: "vault is empty", severity: "error" }; + } + if (state.balance < state.dailyBudget) { + return { reason: "balance is below one day of budget", severity: "warn" }; + } + if (state.spentToday >= state.dailyBudget) { + return { reason: "today's budget is exhausted", severity: "warn" }; + } + return { reason: "ok", severity: "ok" }; +} + export interface AssociationDbRecord { forwarderAddress: string; iban: string | null; @@ -170,36 +231,34 @@ export function diffAssociation(db: AssociationDbRecord, live: LiveAssociationSt export interface ForwarderConfigRecord { destination: string; - fallbackAddress: string; - feeBps: number; + floorPpm: number; + targetPpm: 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>; + /** Authorized on-chain transitions — reconcile the DB: the fee policy changes only via + * the guardian's timelocked setter (P11), which leaves 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. + * Classifies drift between the DB config record and on-chain clone state. The fee + * policy is mutable ONLY by the guardian's timelocked setter (P11), so a change there is + * an expected authorized transition to reconcile; the destination has no setter at all, + * so a change there (like bytecode or registration drift) 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.targetPpm !== onchain.targetPpm) { + result.ownerAuthorizedUpdates.targetPpm = onchain.targetPpm; } - if (db.destination.toLowerCase() !== onchain.destination.toLowerCase()) { - result.ownerAuthorizedUpdates.destination = onchain.destination; + if (db.floorPpm !== onchain.floorPpm) { + result.ownerAuthorizedUpdates.floorPpm = onchain.floorPpm; } - if (db.fallbackAddress.toLowerCase() !== onchain.fallbackAddress.toLowerCase()) { - result.ownerAuthorizedUpdates.fallbackAddress = onchain.fallbackAddress; + if (db.destination.toLowerCase() !== onchain.destination.toLowerCase()) { + result.errors.push(`destination changed on chain to ${onchain.destination} (recorded ${db.destination})`); } return result; } @@ -217,8 +276,8 @@ async function monitoredAccounts(statuses: MoneriumAccountStatus[]): Promise { if ((await getChainId()) !== 1) { @@ -229,20 +288,18 @@ export async function runExecutableDepthCheck(): Promise { 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 { factory, oracle, oracleDecimals, slippageBps } = await getForwarderImmutables( + accounts[0].forwarderAddress as Address + ); + const [minSwapAmount, perSwapCap, routes] = await Promise.all([ + client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }), + client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }), + readEnabledRoutes(factory) + ]); + if (routes.length === 0) { + logger.error("monerium-b2b: depth check aborted — the factory has no enabled swap route"); + return; + } const [, answer, , updatedAt] = await client.readContract({ abi: chainlinkAbi, @@ -254,41 +311,44 @@ export async function runExecutableDepthCheck(): Promise { 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 quoted: Array<{ capImpactBps: number; index: number; minImpactBps: number }> = []; + for (const route of routes) { + try { + const [minOut, capOut] = await Promise.all([ + quoteRouteOutput(route.path, minSwapAmount), + quoteRouteOutput(route.path, perSwapCap) + ]); + quoted.push({ + capImpactBps: computeQuoteImpactBps(perSwapCap, capOut, answer, oracleDecimals), + index: route.index, + minImpactBps: computeQuoteImpactBps(minSwapAmount, minOut, answer, oracleDecimals) + }); + } catch (error) { + logger.warn(`monerium-b2b: depth check could not quote route ${route.index}:`, error); + } + } + if (quoted.length === 0) { + logger.error("monerium-b2b: depth check aborted — no enabled swap route could be quoted"); + return; + } + const best = quoted.reduce((leader, route) => (route.minImpactBps < leader.minImpactBps ? route : leader)); const detail = - `oracle=${answer} (updatedAt=${updatedAt}), minSwapAmount=${minSwapAmount} -> ${minOut} (${minImpactBps} bps), ` + - `perSwapCap=${perSwapCap} -> ${capOut} (${capImpactBps} bps), SLIPPAGE_BPS=${slippageBps}`; + `oracle=${answer} (updatedAt=${updatedAt}), SLIPPAGE_BPS=${slippageBps}, best route ${best.index}; per route: ` + + quoted.map(route => `#${route.index} min=${route.minImpactBps}bps cap=${route.capImpactBps}bps`).join(", "); - if (minImpactBps > slippageBps) { - // PAUSE THRESHOLD (PRD §7.4): even minimum-size swaps would revert on minOut. + const verdict = classifyExecutableDepth(best.minImpactBps, best.capImpactBps, slippageBps); + if (verdict.severity === "error") { logger.error( - "monerium-b2b: PAUSE THRESHOLD — quote impact at minSwapAmount exceeds SLIPPAGE_BPS; engage guardian pause per " + - `docs/operations-monerium-b2b-runbook.md. ${detail}` + `monerium-b2b: DEPTH BELOW FLOOR — ${verdict.reason}; triage per docs/operations-monerium-b2b-runbook.md §3. ${detail}` ); - } else if (capImpactBps > slippageBps) { - logger.warn(`monerium-b2b: executable depth below perSwapCap — cap-sized swaps would revert on minOut. ${detail}`); + } else if (verdict.severity === "warn") { + logger.warn(`monerium-b2b: ${verdict.reason}. ${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. */ +/** Stranded-balance monitor: batches open longer than RECOVERY_DELAY warn, longer than TRIGGER_DELAY error. */ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promise { const accounts = await monitoredAccounts([ MoneriumAccountStatus.Onboarding, @@ -299,7 +359,7 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi return; } const client = getPublicClient(); - const { factory } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const { factory, recoveryDelaySeconds } = 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({ @@ -312,22 +372,28 @@ export async function runStrandedBalanceMonitor(now: number = Date.now()): Promi for (const account of accounts) { try { const forwarder = account.forwarderAddress as Address; - const { eure } = await getForwarderImmutables(forwarder); - const [balance, strandedSince] = await Promise.all([ + const { eure, usdc } = await getForwarderImmutables(forwarder); + const [eureBalance, usdcBalance, batchOpenedAt] = await Promise.all([ client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }), - client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "strandedSince" }) + client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }), + client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }) ]); - if (balance < minSwapFloor) { + if (eureBalance < minSwapFloor && usdcBalance === 0n) { continue; } - const severity = classifyStranding(strandedSince, triggerDelay, now); + const severity = classifyStranding(batchOpenedAt, BigInt(recoveryDelaySeconds), triggerDelay, now); if (severity === "ok") { continue; } - const hours = Math.floor((now - Number(strandedSince) * 1000) / 3_600_000); + const openMs = now - Number(batchOpenedAt) * 1000; + const hours = Math.floor(openMs / 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" : ""}`; + `monerium-b2b: stranded funds on forwarder ${forwarder} (account ${account.id}): eure=${eureBalance}, usdc=${usdcBalance}, ` + + `batch open for ${hours}h${ + severity === "error" + ? " — past TRIGGER_DELAY, permissionless trigger is live" + : " — past RECOVERY_DELAY, the promised window was missed: forward or recover (runbook §2.7)" + }`; if (severity === "error") { logger.error(message); } else { @@ -382,8 +448,8 @@ export async function runAssociationMonitor(): Promise { /** * 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. + * state against the DB. Guardian fee-policy changes are reconciled (DB update + + * configVersion bump), immutable violations are alarmed. */ export async function runConfigReconciliation(): Promise { const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); @@ -420,10 +486,10 @@ export async function runConfigReconciliation(): Promise { implementationByFactory.set(trustedFactory.toLowerCase(), implementation); } - const [destination, fallbackAddress, feeBps, isForwarder, code] = await Promise.all([ + const [destination, targetPpm, floorPpm, 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: forwarderMonitoringAbi, address: forwarder, functionName: "targetPpm" }), + client.readContract({ abi: forwarderMonitoringAbi, address: forwarder, functionName: "floorPpm" }), client.readContract({ abi: factoryMonitoringAbi, address: trustedFactoryAddress, @@ -445,16 +511,15 @@ export async function runConfigReconciliation(): Promise { } const drift = detectConfigDrift( - { destination: account.destination, fallbackAddress: account.fallbackAddress, feeBps: account.feeBps }, - { destination, fallbackAddress, feeBps: Number(feeBps) } + { destination: account.destination, floorPpm: account.floorPpm, targetPpm: account.targetPpm }, + { destination, floorPpm: Number(floorPpm), targetPpm: Number(targetPpm) } ); 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. + // Authorized transition: the fee policy changes 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}): ` + @@ -467,6 +532,98 @@ export async function runConfigReconciliation(): Promise { } } +/** Subsidy-vault monitor: runway of the shared vault every below-floor swap depends on. */ +export async function runSubsidyVaultMonitor(): Promise { + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (accounts.length === 0) { + return; + } + const { factory, usdc } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const vault = await getPublicClient().readContract({ abi: factoryAbi, address: factory, functionName: "subsidyVault" }); + const state = await readSubsidyVaultState(vault, usdc); + if (!state) { + logger.warn("monerium-b2b: no subsidy vault is configured on the factory — every below-floor swap will defer"); + return; + } + const { reason, severity } = classifyVaultRunway(state); + const detail = + `vault=${vault}: balance=${state.balance}, dailyBudget=${state.dailyBudget}, spentToday=${state.spentToday}, ` + + `maxSubsidyPpm=${state.maxSubsidyPpm}, paused=${state.paused}`; + if (severity === "error") { + logger.error(`monerium-b2b: SUBSIDY VAULT — ${reason}; below-floor swaps are deferring. ${detail}`); + } else if (severity === "warn") { + logger.warn(`monerium-b2b: subsidy vault ${reason}; refill before below-floor swaps start deferring. ${detail}`); + } else { + logger.info(`monerium-b2b: subsidy vault ok. ${detail}`); + } +} + +/** An active refund older than this warns; older than four times it errors. */ +export const RECOVERY_LINGER_MS = 60 * 60 * 1000; +/** The EURe float warns below this balance (18 decimals). */ +export const FLOAT_WARN_EURE = 1_000n * 10n ** 18n; + +export type RefundQueueSeverity = "error" | "ok" | "warn"; + +/** Severity of the oldest active refund by its age; a failed one is always an error. */ +export function classifyRefundQueue(activeCreatedAt: Date | null, failed: boolean, nowMs: number): RefundQueueSeverity { + if (failed) return "error"; + if (!activeCreatedAt) return "ok"; + const age = nowMs - activeCreatedAt.getTime(); + if (age >= 4 * RECOVERY_LINGER_MS) return "error"; + if (age >= RECOVERY_LINGER_MS) return "warn"; + return "ok"; +} + +/** + * Refund monitor: the one active recovery and the float. Runs only with automated + * refunds configured; the manual procedure has the runbook. + */ +export async function runRefundMonitor(now: number = Date.now()): Promise { + const active = await MoneriumRecovery.findOne({ + order: [["created_at", "ASC"]], + where: { phase: { [Op.ne]: MoneriumRecoveryPhase.Redeemed } } + }); + const severity = classifyRefundQueue(active?.createdAt ?? null, Boolean(active?.error), now); + if (active) { + const message = + `monerium-b2b: refund of deposit ${active.depositId} in phase ${active.phase} since ${active.createdAt.toISOString()}` + + `${active.error ? ` — FAILED: ${active.error}` : ""}`; + if (severity === "error") logger.error(`${message} (runbook §2.7)`); + else if (severity === "warn") logger.warn(message); + } + + const float = getFloatWalletClient(); + const accounts = await monitoredAccounts([MoneriumAccountStatus.Onboarding, MoneriumAccountStatus.Active]); + if (!float || accounts.length === 0) return; + const { eure } = await getForwarderImmutables(accounts[0].forwarderAddress as Address); + const balance = await getPublicClient().readContract({ + abi: erc20Abi, + address: eure, + args: [float.account.address], + functionName: "balanceOf" + }); + const detail = `float ${float.account.address} holds ${formatUnits(balance, 18)} EURe`; + if (balance === 0n) { + logger.error(`monerium-b2b: FLOAT EMPTY — every refund top-up waits; ${detail} (runbook §2.7)`); + } else if (balance < FLOAT_WARN_EURE) { + logger.warn(`monerium-b2b: float running low; ${detail}`); + } else { + logger.info(`monerium-b2b: ${detail}`); + } +} + +/** Reference-venue monitor: a product that is not online makes every keeper swap defer. */ +export async function runReferenceVenueMonitor(): Promise { + const product = await fetchCoinbaseProductStatus(); + const reason = classifyReferenceVenue(product); + if (reason) { + logger.error(`monerium-b2b: REFERENCE VENUE — ${reason}; every keeper swap defers until the reference source is changed`); + } else { + logger.info(`monerium-b2b: reference venue ok (${COINBASE_REFERENCE_PRODUCT} ${product.status})`); + } +} + // ------------------------------------------------------------------ pass orchestration let lastPassAt = 0; @@ -493,10 +650,15 @@ export async function runMonitoringPass(now: number = Date.now()): Promise return; } lastPassAt = now; + await guarded("reference-venue monitor", runReferenceVenueMonitor); if (config.moneriumB2b.rpcUrl) { await guarded("executable-depth check", runExecutableDepthCheck); await guarded("stranded-balance monitor", () => runStrandedBalanceMonitor(now)); + await guarded("subsidy-vault monitor", runSubsidyVaultMonitor); await guarded("config reconciliation", runConfigReconciliation); + if (config.moneriumB2b.autoRecovery === "auto") { + await guarded("refund monitor", () => runRefundMonitor(now)); + } } 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 index c3dbcfc67..274c5cfcc 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -12,22 +12,38 @@ 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 REFUND_WALLET = "0x9999999999999999999999999999999999999999"; 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[][] }; + calls: { + getChainId: number; + getIbanForAddress: number; + getProfileAddresses: number; + linkAddress: unknown[][]; + requestIban: unknown[][]; + signLinkAttestation: unknown[][]; + signRefundLink: unknown[][]; + }; ibanByAddress: Map; linkedAddresses: Set; } function fakeDeps(): FakeDeps { const deps: FakeDeps = { - calls: { getChainId: 0, getIbanForAddress: 0, getProfileAddresses: 0, linkAddress: [], requestIban: [], signLinkAttestation: [] }, + calls: { + getChainId: 0, + getIbanForAddress: 0, + getProfileAddresses: 0, + linkAddress: [], + requestIban: [], + signLinkAttestation: [], + signRefundLink: [] + }, async getChainId() { deps.calls.getChainId += 1; return 1; @@ -46,7 +62,8 @@ function fakeDeps(): FakeDeps { deps.calls.linkAddress.push(args); return {}; }, - linkedAddresses: new Set(), + // The refund wallet starts linked so most cases exercise only the forwarder link. + linkedAddresses: new Set([REFUND_WALLET]), async requestIban(...args: unknown[]) { deps.calls.requestIban.push(args); return {}; @@ -54,6 +71,10 @@ function fakeDeps(): FakeDeps { async signLinkAttestation(...args: unknown[]) { deps.calls.signLinkAttestation.push(args); return { signature: "0xattestor-signature" }; + }, + async signRefundLink(...args: unknown[]) { + deps.calls.signRefundLink.push(args); + return { address: REFUND_WALLET, signature: "0xrefund-signature" }; } }; return deps; @@ -76,8 +97,6 @@ async function createMappedAccount(overrides: Partial { await resetTestDatabase(); config.moneriumB2b.attestorPrivateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; config.moneriumB2b.rpcUrl = "http://rpc.invalid"; + config.moneriumB2b.refundSeed = `0x${"11".repeat(32)}`; // MONERIUM_WHITELABEL_CLIENT_ID/SECRET come from test-utils/preload.ts. }); @@ -119,6 +139,22 @@ describe("monerium b2b onboarding automation", () => { ]); }); + it("links the client's refund wallet to the profile next to the forwarder", async () => { + await createMappedAccount(); + const deps = fakeDeps(); + deps.linkedAddresses.clear(); + + await advanceOnboardingAccounts(deps); + expect(deps.calls.signRefundLink).toEqual([[MONERIUM_PROFILE]]); + expect(deps.calls.linkAddress).toEqual([ + [MONERIUM_PROFILE, FORWARDER, "ethereum", "0xattestor-signature"], + [MONERIUM_PROFILE, REFUND_WALLET, "ethereum", "0xrefund-signature"] + ]); + expect(deps.calls.requestIban).toEqual([[FORWARDER, "ethereum"]]); + const links = await FinancialOperation.findAll({ where: { phase: ["linkAddress", "linkRefundAddress"] } }); + expect(links.map(link => link.status)).toEqual(["confirmed", "confirmed"]); + }); + it("never repeats a claimed provider write on replay", async () => { await createMappedAccount(); const deps = fakeDeps(); @@ -159,8 +195,6 @@ describe("monerium b2b onboarding automation", () => { 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 diff --git a/apps/api/src/api/services/monerium-b2b/onboarding.ts b/apps/api/src/api/services/monerium-b2b/onboarding.ts index 8e3738e3e..f68c37092 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.ts @@ -1,4 +1,4 @@ -import type { MoneriumChain } from "@vortexfi/shared"; +import { MONERIUM_ADDRESS_OWNERSHIP_MESSAGE, type MoneriumChain } from "@vortexfi/shared"; import { Op } from "sequelize"; import type { Address } from "viem"; import logger from "../../../config/logger"; @@ -8,6 +8,7 @@ import { FinancialOperationRejectedError, runFinancialOperation } from "../phase import { signLinkAttestation } from "./attestor"; import { getChainId, moneriumChainForChainId } from "./chain"; import { getIbanForAddress, getProfileAddresses, isWhitelabelConfigured, linkAddress, requestIban } from "./monerium-api"; +import { refundAccountFor } from "./refund-wallet"; const ONBOARDING_FLOW = { id: "monerium-b2b-onboarding", version: 1 } as const; @@ -18,6 +19,8 @@ export interface OnboardingDeps { linkAddress(profileId: string, address: string, chain: MoneriumChain, signature: string): Promise; requestIban(address: string, chain: MoneriumChain): Promise; signLinkAttestation(chainId: bigint, forwarderAddress: Address): Promise<{ signature: string }>; + /** The client's refund wallet and its signature over Monerium's ownership message. */ + signRefundLink(moneriumProfileId: string): Promise<{ address: string; signature: string }>; } const defaultDeps: OnboardingDeps = { @@ -26,46 +29,57 @@ const defaultDeps: OnboardingDeps = { getProfileAddresses, linkAddress, requestIban, - signLinkAttestation + signLinkAttestation, + async signRefundLink(moneriumProfileId) { + const wallet = refundAccountFor(moneriumProfileId); + return { address: wallet.address, signature: await wallet.signMessage({ message: MONERIUM_ADDRESS_OWNERSHIP_MESSAGE }) }; + } }; export function isOnboardingConfigured(): boolean { - const { attestorPrivateKey, rpcUrl } = config.moneriumB2b; - return Boolean(attestorPrivateKey && rpcUrl && isWhitelabelConfigured()); + const { attestorPrivateKey, refundSeed, rpcUrl } = config.moneriumB2b; + return Boolean(attestorPrivateKey && refundSeed && rpcUrl && isWhitelabelConfigured()); } let configWarned = false; -async function isForwarderLinked( +async function isLinked( deps: OnboardingDeps, moneriumProfileId: string, - forwarderAddress: string, + address: string, chainName: MoneriumChain ): Promise { - const forwarderKey = forwarderAddress.toLowerCase(); + const key = address.toLowerCase(); const addresses = await deps.getProfileAddresses(moneriumProfileId, chainName); - return addresses.some(address => address.toLowerCase() === forwarderKey); + return addresses.some(linked => linked.toLowerCase() === key); } +/** + * Links `address` to the client's Monerium profile with `sign`'s ownership signature, + * exactly once. `phase` names the ledger operation: the forwarder and the refund wallet + * are separate provider writes. + */ async function ensureLinked( deps: OnboardingDeps, account: MoneriumAccount, - chainId: number, + phase: "linkAddress" | "linkRefundAddress", + address: string, + sign: () => Promise, chainName: MoneriumChain ): Promise { - if (await isForwarderLinked(deps, account.profileId, account.forwarderAddress, chainName)) return; + if (await isLinked(deps, account.profileId, address, chainName)) return; await runFinancialOperation({ attemptClass: "provider-address-link", flow: ONBOARDING_FLOW, perform: async () => { - const attestation = await deps.signLinkAttestation(BigInt(chainId), account.forwarderAddress as Address); + const signature = await sign(); try { - await deps.linkAddress(account.profileId, account.forwarderAddress, chainName, attestation.signature); + await deps.linkAddress(account.profileId, address, chainName, 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)) { + if (await isLinked(deps, account.profileId, address, chainName)) { return { linked: true }; } throw new FinancialOperationRejectedError( @@ -74,13 +88,12 @@ async function ensureLinked( } return { linked: true }; }, - phase: "linkAddress", + phase, 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 }, + reconcile: async () => ((await isLinked(deps, account.profileId, address, chainName)) ? { linked: true } : null), + request: { address: address.toLowerCase(), chain: chainName, moneriumProfileId: account.profileId }, retryFailed: true, // vortexProfileId is non-null for every account this loop selects. scopeId: account.vortexProfileId as string, @@ -126,17 +139,17 @@ async function ensureIban(deps: OnboardingDeps, account: MoneriumAccount, chainN /** * 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. + * Monerium profile with the attestor signature and the client's refund wallet with its + * own signature, then requests IBAN issuance for the forwarder. Every provider write + * runs through the profile-scoped financial-operation ledger, so a crash or retry never + * repeats a claimed call. Activation 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" + "monerium-b2b: onboarding automation disabled — requires MONERIUM_WHITELABEL_CLIENT_ID/SECRET, MONERIUM_B2B_ATTESTOR_PRIVATE_KEY, MONERIUM_B2B_REFUND_SEED, and MONERIUM_B2B_RPC_URL" ); } return 0; @@ -158,7 +171,17 @@ export async function advanceOnboardingAccounts(deps: OnboardingDeps = defaultDe let advanced = 0; for (const account of accounts) { try { - await ensureLinked(deps, account, chainId, chainName); + const forwarder = account.forwarderAddress; + await ensureLinked( + deps, + account, + "linkAddress", + forwarder, + async () => (await deps.signLinkAttestation(BigInt(chainId), forwarder as Address)).signature, + chainName + ); + const refund = await deps.signRefundLink(account.profileId); + await ensureLinked(deps, account, "linkRefundAddress", refund.address, async () => refund.signature, chainName); await ensureIban(deps, account, chainName); advanced += 1; } catch (error) { diff --git a/apps/api/src/api/services/monerium-b2b/recovery.test.ts b/apps/api/src/api/services/monerium-b2b/recovery.test.ts new file mode 100644 index 000000000..2995c0b1e --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/recovery.test.ts @@ -0,0 +1,490 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type { MoneriumRedeemOrderRequest } from "@vortexfi/shared"; +import { Address, Hex } from "viem"; +import { config } from "../../../config/vars"; +import ManagedProfileManager from "../../../models/managedProfileManager.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; +import { resetTestDatabase, setupTestDatabase } from "../../../test-utils/db"; +import { createTestUser } from "../../../test-utils/factories"; +import { provisionMoneriumB2bAccount } from "./account-provisioning"; +import { + activeRecoveryExists, + driveRecovery, + isPastDeadline, + RecoveryDeps, + refundEurAmount, + refundMemo, + refundNeed, + reversePath, + reverseSwapMinOut, + runRecoveryDeadlines, + runRecoveryOrchestrator +} from "./recovery"; + +const EUR = 10n ** 18n; +const USDC = 10n ** 6n; +const RECOVERY = "0x7777777777777777777777777777777777777777" as Address; +const FLOAT = "0x8888888888888888888888888888888888888888" as Address; +const EURE = "0x1111111111111111111111111111111111111111"; +const EURC = "0x2222222222222222222222222222222222222222"; +const USDC_TOKEN = "0x3333333333333333333333333333333333333333"; + +describe("reversePath", () => { + it("reverses a two-hop packed path so the same pools run the other way", () => { + const forward = `0x${EURE.slice(2)}0001f4${EURC.slice(2)}0001f4${USDC_TOKEN.slice(2)}` as Hex; + expect(reversePath(forward)).toBe(`0x${USDC_TOKEN.slice(2)}0001f4${EURC.slice(2)}0001f4${EURE.slice(2)}`); + }); + + it("reverses a one-hop path and rejects malformed lengths", () => { + const forward = `0x${EURE.slice(2)}000bb8${USDC_TOKEN.slice(2)}` as Hex; + expect(reversePath(forward)).toBe(`0x${USDC_TOKEN.slice(2)}000bb8${EURE.slice(2)}`); + expect(() => reversePath("0xabcd")).toThrow("packed path length"); + }); +}); + +describe("refund arithmetic", () => { + it("renders the issue amount to the cent and refuses sub-cent deposits", () => { + expect(refundEurAmount((100n * EUR).toString())).toBe("100.00"); + expect(refundEurAmount("50000000000000000")).toBe("0.05"); + expect(refundEurAmount("1234570000000000000000")).toBe("1234.57"); + expect(() => refundEurAmount("1234567000000000000000")).toThrow("whole number of cents"); + }); + + it("splits the difference between wallet balance and refund into top-up or surplus", () => { + expect(refundNeed(100n * EUR, 98n * EUR)).toEqual({ surplus: 0n, topUp: 2n * EUR }); + expect(refundNeed(100n * EUR, 103n * EUR)).toEqual({ surplus: 3n * EUR, topUp: 0n }); + expect(refundNeed(100n * EUR, 100n * EUR)).toEqual({ surplus: 0n, topUp: 0n }); + }); + + it("floors the reverse swap at the Chainlink rate less the slippage tolerance", () => { + // 1140 USDC at 1.14 is 1000 EURe fair; 60 bps below is 994 EURe. + expect(reverseSwapMinOut(1_140n * USDC, 114_000_000n, 8, 60)).toBe(994n * EUR); + }); + + it("counts the promised window from the mint, falling back to the row's creation", () => { + const now = 1_800_000_000_000; + const twoHours = 2 * 60 * 60 * 1000; + const old = new Date(now - twoHours - 1); + const fresh = new Date(now - 60_000); + expect(isPastDeadline({ createdAt: fresh, mintedAt: old }, twoHours, now)).toBe(true); + expect(isPastDeadline({ createdAt: old, mintedAt: fresh }, twoHours, now)).toBe(false); + expect(isPastDeadline({ createdAt: old, mintedAt: null }, twoHours, now)).toBe(true); + }); +}); + +// ------------------------------------------------------------------ state machine with fakes + +interface Ledger { + eure: Map; + usdc: Map; +} + +function fakeDeps( + ledger: Ledger, + overrides: Partial & { + orders?: Array<{ id: string; memo: string; rejectedReason?: string; state: string }>; + receipts?: Record; + swapOut?: bigint; + } = {} +): RecoveryDeps & { calls: string[]; orders: Array<{ id: string; memo: string; rejectedReason?: string; state: string }> } { + const calls: string[] = []; + const orders = overrides.orders ?? []; + const receipts = overrides.receipts ?? {}; + const get = (map: Map, address: string) => map.get(address.toLowerCase()) ?? 0n; + const add = (map: Map, address: string, delta: bigint) => + map.set(address.toLowerCase(), get(map, address) + delta); + const deps: RecoveryDeps = { + createRedeemOrder: async request => { + calls.push(`redeem:${request.amount}:${request.counterpart.identifier.iban}:${request.memo}`); + orders.push({ id: "order-1", memo: request.memo as string, state: "placed" }); + return { id: "order-1" }; + }, + eureBalance: async address => get(ledger.eure, address), + floatWallet: FLOAT, + getOrder: async id => { + const order = orders.find(entry => entry.id === id); + if (!order) throw new Error("unknown order"); + return { rejectedReason: order.rejectedReason, state: order.state }; + }, + listOrdersByMemo: async (_address, memo) => orders.filter(order => order.memo === memo), + moneriumChain: async () => "ethereum", + now: () => new Date("2026-09-17T12:00:00Z"), + oracle: async () => ({ decimals: 8, raw: 114_000_000n, slippageBps: 60 }), + recoveryWallet: RECOVERY, + reverseRoute: async () => "0xpath" as Hex, + sendEure: async (from, to, amount) => { + calls.push(`eure:${from}->${to.toLowerCase()}:${amount}`); + const source = from === "float" ? FLOAT : RECOVERY; + add(ledger.eure, source, -amount); + add(ledger.eure, to, amount); + return `0x${from}tx` as Hex; + }, + sendReverseSwap: async (amountIn, minOut) => { + calls.push(`swap:${amountIn}:${minOut}`); + add(ledger.usdc, RECOVERY, -amountIn); + add(ledger.eure, RECOVERY, overrides.swapOut ?? (amountIn * EUR) / (114n * USDC / 100n)); + return "0xswaptx" as Hex; + }, + setDepositStatus: async (deposit, status) => { + calls.push(`deposit:${status}`); + (deposit as { status: MoneriumFiatDepositStatus }).status = status; + }, + signMessage: async message => { + calls.push(`sign:${message}`); + return "0xsig"; + }, + usdcBalance: async address => get(ledger.usdc, address), + waitReceipt: async hash => receipts[hash] ?? "success", + ...overrides + }; + return { ...deps, calls, orders }; +} + +function recoveryRow(fields: Partial = {}): MoneriumRecovery { + const row = { + attempts: 0, + error: null, + eureFromSwapRaw: null, + eureRecoveredRaw: (40n * EUR).toString(), + floatTopupRaw: null, + floatTopupTxHash: null, + phase: MoneriumRecoveryPhase.Moved, + redeemOrderId: null, + refundAmount: null, + reverseSwapTxHash: null, + surplusRaw: null, + surplusTxHash: null, + usdcRecoveredRaw: (68n * USDC).toString(), + ...fields, + async update(values: Record) { + Object.assign(row, values); + } + }; + return row as unknown as MoneriumRecovery; +} + +function depositRow(fields: Partial = {}): MoneriumFiatDeposit { + return { + amountRaw: (100n * EUR).toString(), + id: "deposit-1", + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status: MoneriumFiatDepositStatus.Recovering, + ...fields + } as unknown as MoneriumFiatDeposit; +} + +describe("driveRecovery", () => { + it("walks a chunked payment from the recovery wallet to a processed redeem order", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 40n * EUR], [FLOAT, 1_000n * EUR]]), usdc: new Map([[RECOVERY, 68n * USDC]]) }; + const deps = fakeDeps(ledger, { swapOut: 59n * EUR }); // 68 USDC back to 59 EURe: 1 EURe of slippage + const recovery = recoveryRow(); + const deposit = depositRow(); + + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Swapping); + expect(deps.calls[0]).toBe(`swap:${68n * USDC}:${reverseSwapMinOut(68n * USDC, 114_000_000n, 8, 60)}`); + + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ eureFromSwapRaw: (59n * EUR).toString(), phase: MoneriumRecoveryPhase.Swapped }); + + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ floatTopupRaw: (1n * EUR).toString(), phase: MoneriumRecoveryPhase.ToppingUp }); + expect(deps.calls.at(-1)).toBe(`eure:float->${RECOVERY.toLowerCase()}:${1n * EUR}`); + + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppedUp); + + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: "order-1", refundAmount: "100.00" }); + expect(deps.calls).toContain("sign:Send EUR 100.00 to DE89370400440532013000 at 2026-09-17T12:00Z"); + expect(deps.calls).toContain(`redeem:100.00:DE89370400440532013000:${refundMemo("deposit-1")}`); + + await driveRecovery(recovery, deposit, deps); // still placed + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeeming); + deps.orders[0].state = "processed"; + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeemed); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.Refunded); + }); + + it("sweeps a surplus to the float and skips the swap when nothing was converted", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 103n * EUR]]), usdc: new Map() }; + const deps = fakeDeps(ledger); + const recovery = recoveryRow({ eureRecoveredRaw: (103n * EUR).toString(), usdcRecoveredRaw: "0" }); + const deposit = depositRow(); + + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ eureFromSwapRaw: "0", phase: MoneriumRecoveryPhase.Swapped }); + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ phase: MoneriumRecoveryPhase.ToppingUp, surplusRaw: (3n * EUR).toString() }); + expect(deps.calls.at(-1)).toBe(`eure:recovery->${FLOAT.toLowerCase()}:${3n * EUR}`); + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppedUp); + }); + + it("waits for a surplus sweep sent after a confirmed top-up, not for the top-up again", async () => { + // More EURe reached the wallet after an earlier top-up confirmed; the sweep of the + // excess lands only once its own receipt arrives, as on chain. + const ledger: Ledger = { eure: new Map([[RECOVERY, 103n * EUR]]), usdc: new Map() }; + const sweeps: bigint[] = []; + const deps = fakeDeps(ledger, { + sendEure: async (_from, _to, amount) => { + sweeps.push(amount); + return "0xsweeptx" as Hex; + }, + waitReceipt: async hash => { + if (hash === "0xsweeptx") ledger.eure.set(RECOVERY, 100n * EUR); + return "success"; + } + }); + const recovery = recoveryRow({ + floatTopupRaw: (1n * EUR).toString(), + floatTopupTxHash: "0xfloattx", + phase: MoneriumRecoveryPhase.Swapped + }); + const deposit = depositRow(); + + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppingUp); + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppedUp); + expect(sweeps).toEqual([3n * EUR]); + }); + + it("re-derives a lost swap from balances instead of swapping twice", async () => { + // The swap landed (USDC gone, EURe up) but the hash never persisted. + const ledger: Ledger = { eure: new Map([[RECOVERY, 99n * EUR]]), usdc: new Map([[RECOVERY, 0n]]) }; + const deps = fakeDeps(ledger); + const recovery = recoveryRow({ phase: MoneriumRecoveryPhase.Swapping, reverseSwapTxHash: null }); + await driveRecovery(recovery, depositRow(), deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Moved); + await driveRecovery(recovery, depositRow(), deps); + expect(recovery).toMatchObject({ eureFromSwapRaw: (59n * EUR).toString(), phase: MoneriumRecoveryPhase.Swapped }); + expect(deps.calls.filter(call => call.startsWith("swap:"))).toHaveLength(0); + }); + + it("waits, without failing, while the float cannot cover the top-up", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 99n * EUR], [FLOAT, 0n]]), usdc: new Map() }; + const deps = fakeDeps(ledger); + const recovery = recoveryRow({ phase: MoneriumRecoveryPhase.Swapped }); + const deposit = depositRow(); + await driveRecovery(recovery, deposit, deps); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Swapped); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.Recovering); + expect(deps.calls).toEqual([]); + }); + + it("adopts an already placed order by memo instead of placing a second one", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 100n * EUR]]), usdc: new Map() }; + const deps = fakeDeps(ledger, { orders: [{ id: "order-9", memo: refundMemo("deposit-1"), state: "pending" }] }); + const recovery = recoveryRow({ phase: MoneriumRecoveryPhase.ToppedUp }); + await driveRecovery(recovery, depositRow(), deps); + expect(recovery).toMatchObject({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: "order-9" }); + expect(deps.calls.some(call => call.startsWith("redeem:"))).toBe(false); + }); + + it("parks the deposit as recovery_failed when the refund cannot be automated", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 100n * EUR]]), usdc: new Map() }; + const noPayer = depositRow({ payerIban: null }); + const recovery = recoveryRow({ phase: MoneriumRecoveryPhase.ToppedUp }); + await driveRecovery(recovery, noPayer, fakeDeps(ledger)); + expect(noPayer.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + expect(recovery).toMatchObject({ error: expect.stringContaining("no payer IBAN"), phase: MoneriumRecoveryPhase.ToppedUp }); + + const large = depositRow({ amountRaw: (20_000n * EUR).toString() }); + const bigRecovery = recoveryRow({ phase: MoneriumRecoveryPhase.ToppedUp }); + await driveRecovery(bigRecovery, large, fakeDeps({ eure: new Map([[RECOVERY, 20_000n * EUR]]), usdc: new Map() })); + expect(large.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + expect(bigRecovery.error).toContain("supporting document"); + + const rejected = fakeDeps(ledger, { orders: [{ id: "o", memo: refundMemo("deposit-1"), rejectedReason: "compliance", state: "rejected" }] }); + const redeeming = recoveryRow({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: "o" }); + const deposit = depositRow(); + await driveRecovery(redeeming, deposit, rejected); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + expect(redeeming.error).toContain("compliance"); + }); + + it("retries a reverted reverse swap and fails after the fifth attempt", async () => { + const ledger: Ledger = { eure: new Map([[RECOVERY, 40n * EUR]]), usdc: new Map([[RECOVERY, 68n * USDC]]) }; + const deps = fakeDeps(ledger, { + sendReverseSwap: async () => { + throw new Error("STF"); + } + }); + const recovery = recoveryRow(); + const deposit = depositRow(); + for (let attempt = 1; attempt <= 4; attempt++) { + await driveRecovery(recovery, deposit, deps); + expect(recovery).toMatchObject({ attempts: attempt, phase: MoneriumRecoveryPhase.Moved }); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.Recovering); + } + await driveRecovery(recovery, deposit, deps); + expect(recovery.attempts).toBe(5); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + }); +}); + +// ------------------------------------------------------------------ deadlines + orchestrator (database) + +describe("refund deadlines and orchestration", () => { + const FORWARDER = "0x4444444444444444444444444444444444444444"; + const DESTINATION = "0x5555555555555555555555555555555555555555"; + let originalRpcUrl: string | undefined; + let originalMode: typeof config.moneriumB2b.autoRecovery; + + beforeAll(async () => { + originalRpcUrl = config.moneriumB2b.rpcUrl; + originalMode = config.moneriumB2b.autoRecovery; + config.moneriumB2b.rpcUrl = undefined; + await setupTestDatabase(); + }); + + afterAll(() => { + config.moneriumB2b.rpcUrl = originalRpcUrl; + config.moneriumB2b.autoRecovery = originalMode; + }); + + beforeEach(async () => { + await resetTestDatabase(); + config.moneriumB2b.autoRecovery = "auto"; + }); + + async function mappedAccount() { + const manager = await createTestUser(); + await ManagedProfileManager.create({ + allowedCorridors: ["EU"], + allowedCustomerTypes: ["business"], + isActive: true, + profileId: manager.id + }); + return provisionMoneriumB2bAccount({ + contactEmail: "ops@client.example.com", + destination: DESTINATION, + externalSubjectId: "client-1", + forwarderAddress: FORWARDER, + managerProfileId: manager.id, + moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e" + }); + } + + function minted(accountId: string, orderId: string, mintedAt: Date, status = MoneriumFiatDepositStatus.Minted) { + return MoneriumFiatDeposit.create({ + accountId, + amountRaw: (100n * EUR).toString(), + blockNumber: 100, + chainId: 11155111, + currency: "eur", + logIndex: 1, + mintedAt, + moneriumOrderId: orderId, + payerIban: "DE89370400440532013000", + payerName: "Payer GmbH", + status, + txHash: `0x${orderId}` + }); + } + + it("marks deposits past the promised window in auto mode and only reports them in alert mode", async () => { + const { accountId } = await mappedAccount(); + const now = Date.now(); + const late = await minted(accountId, "late", new Date(now - 121 * 60_000)); + const fresh = await minted(accountId, "fresh", new Date(now - 10 * 60_000)); + + config.moneriumB2b.autoRecovery = "alert"; + await runRecoveryDeadlines(now); + await late.reload(); + expect(late.status).toBe(MoneriumFiatDepositStatus.Minted); + + config.moneriumB2b.autoRecovery = "auto"; + await runRecoveryDeadlines(now); + await late.reload(); + await fresh.reload(); + expect(late.status).toBe(MoneriumFiatDepositStatus.Recovering); + expect(fresh.status).toBe(MoneriumFiatDepositStatus.Minted); + }); + + it("opens one recovery per confirmed recover, drives it to the refund, and blocks a second recover meanwhile", async () => { + const { accountId } = await mappedAccount(); + const deposit = await minted(accountId, "stuck", new Date(), MoneriumFiatDepositStatus.Recovering); + await MoneriumConversionExecution.create({ + accountId, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xrecover", + usdcNetRaw: "0" + }); + expect(await activeRecoveryExists()).toBe(true); + + const ledger: Ledger = { eure: new Map([[RECOVERY, 100n * EUR], [FLOAT, 10n * EUR]]), usdc: new Map() }; + const deps = fakeDeps(ledger, { + setDepositStatus: async (row, status) => { + await row.update({ status }); + } + }); + const depsFor = async () => deps; + + await runRecoveryOrchestrator(depsFor); // opens the row: moved -> swapped (nothing to swap) + const recovery = (await MoneriumRecovery.findOne({ where: { depositId: deposit.id } })) as MoneriumRecovery; + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Swapped); + await runRecoveryOrchestrator(depsFor); // exact balance: topped up + await runRecoveryOrchestrator(depsFor); // order placed + await recovery.reload(); + expect(recovery).toMatchObject({ phase: MoneriumRecoveryPhase.Redeeming, refundAmount: "100.00" }); + expect(await activeRecoveryExists()).toBe(true); + + deps.orders[0].state = "processed"; + await runRecoveryOrchestrator(depsFor); + await recovery.reload(); + await deposit.reload(); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeemed); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.Refunded); + expect(await activeRecoveryExists()).toBe(false); + }); + + it("holds the queue on a failed refund until the operator retries it", async () => { + const { accountId } = await mappedAccount(); + const deposit = await minted(accountId, "stuck", new Date(), MoneriumFiatDepositStatus.Recovering); + await deposit.update({ payerIban: null }); + await MoneriumConversionExecution.create({ + accountId, + depositId: deposit.id, + destination: DESTINATION, + eureInRaw: (100n * EUR).toString(), + kind: MoneriumConversionExecutionKind.Recover, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xrecover", + usdcNetRaw: "0" + }); + const ledger: Ledger = { eure: new Map([[RECOVERY, 100n * EUR]]), usdc: new Map() }; + const deps = fakeDeps(ledger, { + setDepositStatus: async (row, status) => { + await row.update({ status }); + } + }); + const depsFor = async () => deps; + for (let i = 0; i < 3; i++) await runRecoveryOrchestrator(depsFor); + await deposit.reload(); + expect(deposit.status).toBe(MoneriumFiatDepositStatus.RecoveryFailed); + const recovery = (await MoneriumRecovery.findOne({ where: { depositId: deposit.id } })) as MoneriumRecovery; + expect(recovery.error).toContain("payer IBAN"); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.ToppedUp); + expect(await activeRecoveryExists()).toBe(true); + + // Operator fixes the payer and retries: the run resumes from the preserved phase. + await deposit.update({ payerIban: "DE89370400440532013000", status: MoneriumFiatDepositStatus.Recovering }); + await runRecoveryOrchestrator(depsFor); + await recovery.reload(); + expect(recovery.error).toBeNull(); + expect(recovery.phase).toBe(MoneriumRecoveryPhase.Redeeming); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/recovery.ts b/apps/api/src/api/services/monerium-b2b/recovery.ts new file mode 100644 index 000000000..6e3b074d1 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -0,0 +1,635 @@ +import { buildMoneriumSepaRedemptionMessage, MoneriumApiService, type MoneriumRedeemOrderRequest } from "@vortexfi/shared"; +import { Op, QueryTypes } from "sequelize"; +import { Address, formatUnits, Hex } from "viem"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import MoneriumAccount from "../../../models/moneriumAccount.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../../../models/moneriumConversionExecution.model"; +import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../../../models/moneriumFiatDeposit.model"; +import MoneriumRecovery, { MoneriumRecoveryPhase } from "../../../models/moneriumRecovery.model"; +import { + chainlinkAbi, + erc20Abi, + getChainId, + getFloatWalletClient, + getForwarderImmutables, + getPublicClient, + getRefundWalletClient, + KeeperWalletClient, + moneriumChainForChainId, + readEnabledRoutes, + swapRouter02Abi +} from "./chain"; +import { errorText, markDepositForRecovery, RECEIPT_TIMEOUT_MS } from "./conversion-executor"; +import { isForwardTransition, withForwarderLock } from "./deposit-processor"; +import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; +import { refundAccountFor } from "./refund-wallet"; + +/** + * The refund path (docs/architecture-monerium-b2b-onramp.md, "the refund path"): + * + * 1. `runRecoveryDeadlines` marks a settling deposit `recovering` once its mint is older + * than the promised window (MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES), or only alerts, + * depending on MONERIUM_B2B_AUTO_RECOVERY. The keeper then sends `recover` once the + * clone's batch is RECOVERY_DELAY old (conversion-executor.ts). + * 2. `runRecoveryOrchestrator` drives ONE recovery at a time from the confirmed `recover` + * to the bank refund, on the client's own refund wallet (the clone's `recoveryAddress`, + * derived in refund-wallet.ts and linked to the client's Monerium profile): reverse-swap + * the USDC, top the wallet up from the EURe float to exactly the refund amount (or + * sweep a surplus back to the float), place the Monerium redeem order to the payer's + * IBAN, which pays out of the client's own IBAN, and mark the deposit `refunded` when + * Monerium processed it. + * + * Crash safety rests on the refund wallet being dedicated and empty between refunds: + * every step re-derives what is still to do from the wallet's balances, so a lost + * transaction hash never repeats a value-moving send (a top-up already on chain makes the + * remaining need zero). One recovery at a time is what keeps those balances meaningful; + * the executor refuses a second `recover` while one is in flight (`activeRecoveryExists`). + * A step that fails beyond its retries parks the deposit in `recovery_failed` with the + * phase preserved; an operator retry (deposit back to `recovering`) resumes there. + */ + +export const REFUND_MEMO_PREFIX = "vortex-refund:"; +/** Monerium requires a supporting document above this amount; such refunds stay manual (rollout G1). */ +export const SUPPORTING_DOCUMENT_THRESHOLD_EUR = 15_000; +/** Gas the refund wallet's own transactions use (approve, reverse swap, surplus transfer), with margin. */ +const REFUND_WALLET_GAS_UNITS = 400_000n; +const MAX_ATTEMPTS = 5; +const EURE_DECIMALS = 18; +const USDC_DECIMALS = 6; +const BPS = 10_000n; + +// ------------------------------------------------------------------ pure helpers + +/** + * Reverses a packed Uniswap V3 path so the same pools run the other way. The factory only + * admits token(20) fee(3) token(20) and token fee token fee token, so the two hex layouts + * are sliced directly. + */ +export function reversePath(path: Hex): Hex { + const hex = path.slice(2); + if (hex.length === 86) return `0x${hex.slice(46)}${hex.slice(40, 46)}${hex.slice(0, 40)}`; + if (hex.length === 132) + return `0x${hex.slice(92)}${hex.slice(86, 92)}${hex.slice(46, 86)}${hex.slice(40, 46)}${hex.slice(0, 40)}`; + throw new Error(`unexpected packed path length ${hex.length / 2}`); +} + +/** + * The EUR amount Monerium expects for the refund: the issue amount to the cent. Monerium + * issues whole cents, so anything finer is a mis-recorded deposit, not a rounding case. + */ +export function refundEurAmount(amountRaw: string): string { + const raw = BigInt(amountRaw); + const cent = 10n ** BigInt(EURE_DECIMALS - 2); + if (raw % cent !== 0n) { + throw new Error(`deposit amount ${amountRaw} is not a whole number of cents`); + } + const cents = raw / cent; + return `${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`; +} + +/** What the float must add (or what the reverse swap left over) for the wallet to hold exactly the refund. */ +export function refundNeed(refundRaw: bigint, walletEureRaw: bigint): { surplus: bigint; topUp: bigint } { + const diff = refundRaw - walletEureRaw; + return diff >= 0n ? { surplus: 0n, topUp: diff } : { surplus: -diff, topUp: 0n }; +} + +/** Least EURe the reverse swap may return for `usdcIn` at the Chainlink EUR/USD rate, less `slippageBps`. */ +export function reverseSwapMinOut(usdcIn: bigint, oracleRaw: bigint, oracleDecimals: number, slippageBps: number): bigint { + const fair = (usdcIn * 10n ** BigInt(EURE_DECIMALS - USDC_DECIMALS + oracleDecimals)) / oracleRaw; + return (fair * (BPS - BigInt(slippageBps))) / BPS; +} + +/** Whether a settling deposit has outlived the promised window, counted from its mint. */ +export function isPastDeadline( + deposit: Pick, + deadlineMs: number, + nowMs: number +): boolean { + const startedAt = deposit.mintedAt ?? deposit.createdAt; + return nowMs - startedAt.getTime() >= deadlineMs; +} + +export function refundMemo(depositId: string): string { + return `${REFUND_MEMO_PREFIX}${depositId}`; +} + +// ------------------------------------------------------------------ dependencies + +export interface RecoveryDeps { + floatWallet: Address; + recoveryWallet: Address; + eureBalance(address: Address): Promise; + usdcBalance(address: Address): Promise; + oracle(): Promise<{ decimals: number; raw: bigint; slippageBps: number }>; + /** Packed USDC -> ... -> EURe path (the first enabled route, reversed). */ + reverseRoute(): Promise; + /** Sends the reverse swap from the client's refund wallet; returns the swap tx hash. */ + sendReverseSwap(amountIn: bigint, minOut: bigint, path: Hex): Promise; + sendEure(from: "float" | "recovery", to: Address, amount: bigint): Promise; + waitReceipt(hash: Hex): Promise<"reverted" | "success">; + moneriumChain(): Promise; + listOrdersByMemo(address: Address, memo: string): Promise>; + createRedeemOrder(request: MoneriumRedeemOrderRequest): Promise<{ id: string | null }>; + getOrder(orderId: string): Promise<{ rejectedReason?: string; state: string }>; + signMessage(message: string): Promise; + /** Forward-only deposit transition under the forwarder lock (a no-op for an illegal edge; the refund path ignores the refusal). */ + setDepositStatus(deposit: MoneriumFiatDeposit, status: MoneriumFiatDepositStatus): Promise; + now(): Date; +} + +function requireClient(client: KeeperWalletClient | null, name: string): KeeperWalletClient { + if (!client) throw new Error(`${name} is not configured`); + return client; +} + +/** Live dependencies: chain clients from ./chain, the shared Monerium client, the client's refund wallet and the float. */ +export async function liveRecoveryDeps(account: MoneriumAccount): Promise { + const client = getPublicClient(); + const immutables = await getForwarderImmutables(account.forwarderAddress as Address); + const refundAccount = refundAccountFor(account.profileId); + if (refundAccount.address.toLowerCase() !== immutables.recoveryAddress.toLowerCase()) { + throw new Error(`MONERIUM_B2B_REFUND_SEED does not derive the recovery address of forwarder ${account.forwarderAddress}`); + } + const recovery = getRefundWalletClient(refundAccount); + const float = requireClient(getFloatWalletClient(), "MONERIUM_B2B_FLOAT_PRIVATE_KEY"); + const balance = (token: Address, address: Address) => + client.readContract({ abi: erc20Abi, address: token, args: [address], functionName: "balanceOf" }); + const wallets = { float, recovery }; + // The refund wallet pays for its own approve, swap and surplus transfer: before it sends, + // the float tops its ETH up to twice that cost at the current gas price. Balance-derived, + // so a repeat after a crash sends nothing once the first top-up landed. + const fundRefundGas = async () => { + const need = (await client.getGasPrice()) * REFUND_WALLET_GAS_UNITS; + const held = await client.getBalance({ address: recovery.account.address }); + if (held >= need) return; + const hash = await float.sendTransaction({ chain: null, to: recovery.account.address, value: 2n * need - held }); + await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }); + }; + return { + async createRedeemOrder(request) { + const result = await MoneriumApiService.getInstance().createRedemptionOrder(request); + return { id: result.httpStatus === 200 ? result.order.id : null }; + }, + eureBalance: address => balance(immutables.eure, address), + floatWallet: float.account.address, + async getOrder(orderId) { + const order = await MoneriumApiService.getInstance().getOrder(orderId); + return { rejectedReason: order.meta.rejectedReason, state: order.state }; + }, + async listOrdersByMemo(address, memo) { + const { orders } = await MoneriumApiService.getInstance().listOrders({ address, memo }); + return orders + .filter(order => order.kind === "redeem" && order.memo === memo) + .map(order => ({ id: order.id, rejectedReason: order.meta.rejectedReason, state: order.state })); + }, + async moneriumChain() { + const chain = moneriumChainForChainId(await getChainId()); + if (!chain) throw new Error("no Monerium chain name for the configured chain id"); + return chain; + }, + now: () => new Date(), + async oracle() { + const [, answer] = await client.readContract({ + abi: chainlinkAbi, + address: immutables.oracle, + functionName: "latestRoundData" + }); + if (answer <= 0n) throw new Error(`Chainlink EUR/USD answered ${answer}`); + return { decimals: immutables.oracleDecimals, raw: answer, slippageBps: immutables.slippageBps }; + }, + recoveryWallet: recovery.account.address, + async reverseRoute() { + const routes = await readEnabledRoutes(immutables.factory); + if (routes.length === 0) throw new Error("the factory has no enabled swap route to reverse"); + return reversePath(routes[0].path); + }, + async sendEure(from, to, amount) { + const wallet = wallets[from]; + if (from === "recovery") await fundRefundGas(); + const { request } = await client.simulateContract({ + abi: erc20Abi, + account: wallet.account, + address: immutables.eure, + args: [to, amount], + functionName: "transfer" + }); + return wallet.writeContract({ ...request, chain: null }); + }, + async sendReverseSwap(amountIn, minOut, path) { + await fundRefundGas(); + const allowance = await client.readContract({ + abi: erc20Abi, + address: immutables.usdc, + args: [recovery.account.address, immutables.router], + functionName: "allowance" + }); + if (allowance < amountIn) { + const approve = await client.simulateContract({ + abi: erc20Abi, + account: recovery.account, + address: immutables.usdc, + args: [immutables.router, amountIn], + functionName: "approve" + }); + const approveHash = await recovery.writeContract({ ...approve.request, chain: null }); + await client.waitForTransactionReceipt({ hash: approveHash, timeout: RECEIPT_TIMEOUT_MS }); + } + const { request } = await client.simulateContract({ + abi: swapRouter02Abi, + account: recovery.account, + address: immutables.router, + args: [{ amountIn, amountOutMinimum: minOut, path, recipient: recovery.account.address }], + functionName: "exactInput" + }); + return recovery.writeContract({ ...request, chain: null }); + }, + setDepositStatus, + signMessage: message => recovery.signMessage({ message }), + usdcBalance: address => balance(immutables.usdc, address), + async waitReceipt(hash) { + const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }); + return receipt.status; + } + }; +} + +// ------------------------------------------------------------------ deadline trigger + +/** + * Marks settling deposits whose mint is older than the promised window for the refund + * path (`auto`), or reports them (`alert`). A deposit with a pending keeper transaction + * is marked on a later pass, once it settled. + */ +export async function runRecoveryDeadlines(now: number = Date.now()): Promise { + const mode = config.moneriumB2b.autoRecovery; + if (mode === "off") return; + const deadlineMs = config.moneriumB2b.recoveryDeadlineMinutes * 60_000; + const deposits = await MoneriumFiatDeposit.findAll({ + where: { + blockNumber: { [Op.ne]: null }, + moneriumOrderId: { [Op.notLike]: `${UNATTRIBUTED_ORDER_PREFIX}%` }, + status: { [Op.in]: [MoneriumFiatDepositStatus.Minted, MoneriumFiatDepositStatus.Converting] } + } + }); + for (const deposit of deposits) { + if (!isPastDeadline(deposit, deadlineMs, now)) continue; + const ageMinutes = Math.floor((now - (deposit.mintedAt ?? deposit.createdAt).getTime()) / 60_000); + if (mode === "alert") { + logger.error( + `monerium-b2b: REFUND DUE — deposit ${deposit.id} was minted ${ageMinutes} min ago and is still ${deposit.status}; ` + + "MONERIUM_B2B_AUTO_RECOVERY=alert: mark it via POST /v1/admin/monerium-b2b/deposits/:id/recover (runbook §2.7)" + ); + continue; + } + const refusal = await markDepositForRecovery(deposit.id, "window_missed"); + if (refusal) { + logger.warn(`monerium-b2b: deposit ${deposit.id} is past its window but cannot be marked yet: ${refusal}`); + } else { + logger.warn(`monerium-b2b: deposit ${deposit.id} missed the ${ageMinutes} min window; marked for recovery`); + } + } +} + +// ------------------------------------------------------------------ orchestrator + +/** + * True while a recovered payment is (or is about to be) on a refund wallet: a + * `recover` execution that is pending or confirmed whose deposit has not left the + * refund path. The executor refuses to send another `recover` meanwhile. + */ +export async function activeRecoveryExists(): Promise { + const rows = await sequelize.query<{ id: string }>( + `SELECT e.id + FROM monerium_conversion_executions AS e + JOIN monerium_fiat_deposits AS d ON d.id = e.deposit_id + LEFT JOIN monerium_recoveries AS r ON r.deposit_id = e.deposit_id + WHERE e.kind = 'recover' + AND e.status IN ('pending', 'confirmed') + AND d.status IN ('recovering', 'recovery_failed') + AND (r.id IS NULL OR r.phase <> 'redeemed') + LIMIT 1`, + { type: QueryTypes.SELECT } + ); + return rows.length > 0; +} + +/** Forward-only status change under the forwarder lock: why it was refused, or null once the deposit has `status`. */ +export async function setDepositStatus( + deposit: MoneriumFiatDeposit, + status: MoneriumFiatDepositStatus +): Promise { + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) return "Monerium account not found"; + return withForwarderLock(account.forwarderAddress, async transaction => { + const current = await MoneriumFiatDeposit.findByPk(deposit.id, { transaction }); + if (!current) return "missing"; + if (current.status === status) return null; + if (!isForwardTransition(current.status, status)) { + return `Monerium deposit cannot transition from ${current.status} to ${status}`; + } + await current.update({ status }, { transaction }); + return null; + }); +} + +async function fail( + recovery: MoneriumRecovery, + deposit: MoneriumFiatDeposit, + deps: RecoveryDeps, + reason: string +): Promise { + logger.error(`monerium-b2b: REFUND FAILED — deposit ${deposit.id} in phase ${recovery.phase}: ${reason} (runbook §2.7)`); + await recovery.update({ error: reason.slice(0, 500) }); + await deps.setDepositStatus(deposit, MoneriumFiatDepositStatus.RecoveryFailed); +} + +async function retryOrFail( + recovery: MoneriumRecovery, + deposit: MoneriumFiatDeposit, + deps: RecoveryDeps, + phase: MoneriumRecoveryPhase, + reason: string +): Promise { + const attempts = recovery.attempts + 1; + if (attempts >= MAX_ATTEMPTS) { + await recovery.update({ attempts, phase }); + await fail(recovery, deposit, deps, `${reason} after ${attempts} attempts`); + return; + } + logger.warn(`monerium-b2b: refund step for deposit ${deposit.id} failed (attempt ${attempts}): ${reason}`); + await recovery.update({ attempts, error: reason.slice(0, 500), phase }); +} + +/** One step of one recovery. Returns after at most one value-moving send (plus its receipt wait). */ +export async function driveRecovery( + recovery: MoneriumRecovery, + deposit: MoneriumFiatDeposit, + deps: RecoveryDeps +): Promise { + const wallet = deps.recoveryWallet; + switch (recovery.phase) { + case MoneriumRecoveryPhase.Moved: { + const usdc = await deps.usdcBalance(wallet); + if (BigInt(recovery.usdcRecoveredRaw) === 0n || usdc === 0n) { + // Nothing to swap, or a swap already landed (a lost hash): what the wallet holds + // beyond the recovered EURe is the swap's output. + const eure = await deps.eureBalance(wallet); + const fromSwap = eure > BigInt(recovery.eureRecoveredRaw) ? eure - BigInt(recovery.eureRecoveredRaw) : 0n; + await recovery.update({ eureFromSwapRaw: fromSwap.toString(), phase: MoneriumRecoveryPhase.Swapped }); + return; + } + const { decimals, raw, slippageBps } = await deps.oracle(); + const minOut = reverseSwapMinOut(usdc, raw, decimals, slippageBps); + const path = await deps.reverseRoute(); + let hash: Hex; + try { + hash = await deps.sendReverseSwap(usdc, minOut, path); + } catch (error) { + await retryOrFail(recovery, deposit, deps, MoneriumRecoveryPhase.Moved, `reverse swap rejected: ${errorText(error)}`); + return; + } + await recovery.update({ phase: MoneriumRecoveryPhase.Swapping, reverseSwapTxHash: hash }); + return; + } + case MoneriumRecoveryPhase.Swapping: { + const hash = recovery.reverseSwapTxHash as Hex | null; + if (!hash) { + await recovery.update({ phase: MoneriumRecoveryPhase.Moved }); // crashed before the hash persisted: re-derive from balances + return; + } + const status = await deps.waitReceipt(hash); + if (status === "reverted") { + await retryOrFail(recovery, deposit, deps, MoneriumRecoveryPhase.Moved, `reverse swap ${hash} reverted`); + return; + } + const eure = await deps.eureBalance(wallet); + const fromSwap = eure > BigInt(recovery.eureRecoveredRaw) ? eure - BigInt(recovery.eureRecoveredRaw) : 0n; + await recovery.update({ eureFromSwapRaw: fromSwap.toString(), phase: MoneriumRecoveryPhase.Swapped }); + return; + } + case MoneriumRecoveryPhase.Swapped: { + const refundRaw = BigInt(deposit.amountRaw); + const { surplus, topUp } = refundNeed(refundRaw, await deps.eureBalance(wallet)); + if (topUp > 0n) { + const floatBalance = await deps.eureBalance(deps.floatWallet); + if (floatBalance < topUp) { + logger.error( + `monerium-b2b: FLOAT UNDERFUNDED — refund of deposit ${deposit.id} needs ${formatUnits(topUp, EURE_DECIMALS)} EURe, ` + + `the float holds ${formatUnits(floatBalance, EURE_DECIMALS)}; fund ${deps.floatWallet} (runbook §2.7)` + ); + return; // not a failure: retried every cycle once funded + } + let hash: Hex; + try { + hash = await deps.sendEure("float", wallet, topUp); + } catch (error) { + await retryOrFail( + recovery, + deposit, + deps, + MoneriumRecoveryPhase.Swapped, + `float top-up rejected: ${errorText(error)}` + ); + return; + } + // Only the transfer just sent may carry a hash: ToppingUp waits on whichever is set. + await recovery.update({ + floatTopupRaw: topUp.toString(), + floatTopupTxHash: hash, + phase: MoneriumRecoveryPhase.ToppingUp, + surplusTxHash: null + }); + return; + } + if (surplus > 0n) { + let hash: Hex; + try { + hash = await deps.sendEure("recovery", deps.floatWallet, surplus); + } catch (error) { + await retryOrFail( + recovery, + deposit, + deps, + MoneriumRecoveryPhase.Swapped, + `surplus sweep rejected: ${errorText(error)}` + ); + return; + } + await recovery.update({ + floatTopupTxHash: null, + phase: MoneriumRecoveryPhase.ToppingUp, + surplusRaw: surplus.toString(), + surplusTxHash: hash + }); + return; + } + await recovery.update({ phase: MoneriumRecoveryPhase.ToppedUp }); + return; + } + case MoneriumRecoveryPhase.ToppingUp: { + const hash = (recovery.floatTopupTxHash ?? recovery.surplusTxHash) as Hex | null; + if (!hash) { + await recovery.update({ phase: MoneriumRecoveryPhase.Swapped }); + return; + } + const status = await deps.waitReceipt(hash); + if (status === "reverted") { + await retryOrFail(recovery, deposit, deps, MoneriumRecoveryPhase.Swapped, `transfer ${hash} reverted`); + return; + } + // Re-derive: the wallet must now hold exactly the refund; anything else loops through Swapped. + const { surplus, topUp } = refundNeed(BigInt(deposit.amountRaw), await deps.eureBalance(wallet)); + await recovery.update({ + phase: topUp === 0n && surplus === 0n ? MoneriumRecoveryPhase.ToppedUp : MoneriumRecoveryPhase.Swapped + }); + return; + } + case MoneriumRecoveryPhase.ToppedUp: { + if (!deposit.payerIban || !deposit.payerName) { + await fail(recovery, deposit, deps, "the issue order carried no payer IBAN/name to refund to"); + return; + } + let amount: string; + try { + amount = refundEurAmount(deposit.amountRaw); + } catch (error) { + await fail(recovery, deposit, deps, errorText(error)); + return; + } + if (Number(amount) >= SUPPORTING_DOCUMENT_THRESHOLD_EUR) { + await fail( + recovery, + deposit, + deps, + `refunds of EUR ${SUPPORTING_DOCUMENT_THRESHOLD_EUR} or more need a supporting document; place the order by hand` + ); + return; + } + const memo = refundMemo(deposit.id); + // Exactly-once: the memo is the idempotency key at Monerium. + const existing = await deps.listOrdersByMemo(wallet, memo); + if (existing.length > 0) { + await recovery.update({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: existing[0].id, refundAmount: amount }); + return; + } + const message = buildMoneriumSepaRedemptionMessage(amount, deposit.payerIban, deps.now()); + const request: MoneriumRedeemOrderRequest = { + address: wallet, + amount, + chain: (await deps.moneriumChain()) as MoneriumRedeemOrderRequest["chain"], + counterpart: { + details: { companyName: deposit.payerName, country: deposit.payerIban.slice(0, 2) }, + identifier: { iban: deposit.payerIban, standard: "iban" } + }, + currency: "eur", + kind: "redeem", + memo, + message, + signature: await deps.signMessage(message) + }; + let placed: { id: string | null }; + try { + placed = await deps.createRedeemOrder(request); + } catch (error) { + await retryOrFail( + recovery, + deposit, + deps, + MoneriumRecoveryPhase.ToppedUp, + `redeem order rejected: ${errorText(error)}` + ); + return; + } + await recovery.update({ phase: MoneriumRecoveryPhase.Redeeming, redeemOrderId: placed.id, refundAmount: amount }); + return; + } + case MoneriumRecoveryPhase.Redeeming: { + let order: { rejectedReason?: string; state: string } | undefined; + if (recovery.redeemOrderId) { + order = await deps.getOrder(recovery.redeemOrderId); + } else { + const [found] = await deps.listOrdersByMemo(wallet, refundMemo(deposit.id)); + if (found) { + await recovery.update({ redeemOrderId: found.id }); + order = found; + } + } + if (!order) return; // accepted asynchronously: it shows up in the next listing + if (order.state === "processed") { + await recovery.update({ error: null, phase: MoneriumRecoveryPhase.Redeemed }); + await deps.setDepositStatus(deposit, MoneriumFiatDepositStatus.Refunded); + logger.info( + `monerium-b2b: deposit ${deposit.id} refunded (${recovery.refundAmount} EUR, order ${recovery.redeemOrderId})` + ); + } else if (order.state === "rejected") { + await fail(recovery, deposit, deps, `Monerium rejected the redeem order: ${order.rejectedReason ?? "no reason given"}`); + } + return; + } + case MoneriumRecoveryPhase.Redeemed: + return; + } +} + +/** + * Runs one step of the active recovery, or opens the next one: the oldest deposit in + * `recovering` whose `recover` execution is confirmed and that has no recovery row yet. + * A recovery whose deposit is `recovery_failed` waits for the operator and blocks the + * queue (one refund at a time; ponytail: per-client wallets would allow one per client, add + * when refunds queue up). + */ +export async function runRecoveryOrchestrator( + depsFor: (account: MoneriumAccount) => Promise = liveRecoveryDeps +): Promise { + let recovery = await MoneriumRecovery.findOne({ + order: [["created_at", "ASC"]], + where: { phase: { [Op.ne]: MoneriumRecoveryPhase.Redeemed } } + }); + if (!recovery) { + const moved = await sequelize.query<{ depositId: string; eureInRaw: string; usdcNetRaw: string }>( + `SELECT e.deposit_id AS "depositId", e.eure_in_raw AS "eureInRaw", e.usdc_net_raw AS "usdcNetRaw" + FROM monerium_conversion_executions AS e + JOIN monerium_fiat_deposits AS d ON d.id = e.deposit_id + LEFT JOIN monerium_recoveries AS r ON r.deposit_id = e.deposit_id + WHERE e.kind = 'recover' AND e.status = 'confirmed' AND d.status = 'recovering' AND r.id IS NULL + ORDER BY e.created_at ASC + LIMIT 1`, + { type: QueryTypes.SELECT } + ); + if (moved.length === 0) return; + recovery = await MoneriumRecovery.create({ + depositId: moved[0].depositId, + eureRecoveredRaw: moved[0].eureInRaw, + phase: MoneriumRecoveryPhase.Moved, + usdcRecoveredRaw: moved[0].usdcNetRaw ?? "0" + }); + } + const deposit = await MoneriumFiatDeposit.findByPk(recovery.depositId); + if (!deposit) return; + if (deposit.status === MoneriumFiatDepositStatus.RecoveryFailed) { + logger.error( + `monerium-b2b: refund of deposit ${deposit.id} waits for the operator (${recovery.error}); the refund queue is blocked` + ); + return; + } + if (deposit.status === MoneriumFiatDepositStatus.Refunded) { + await recovery.update({ phase: MoneriumRecoveryPhase.Redeemed }); // closed by hand + return; + } + if (recovery.error) { + await recovery.update({ attempts: 0, error: null }); // operator retry: resume from the preserved phase + } + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) return; + try { + const deps = await depsFor(account); + await driveRecovery(recovery, deposit, deps); + } catch (error) { + logger.error(`monerium-b2b: refund step for deposit ${deposit.id} errored:`, error); + } +} diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts new file mode 100644 index 000000000..e698d8d4c --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, mock, setSystemTime, spyOn } from "bun:test"; +import { classifyReferenceVenue, fetchCoinbaseProductStatus, fetchCoinbaseReference, isWithinReferenceBand } from "./reference-rate"; + +// The partner reference is the Coinbase EURC-USDC bid/ask midpoint (adr-0005 P12, +// amendment 2026-09-18): spot, no averaging, with a spread guard for thin books. + +function serve(body: unknown, status = 200) { + return spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(body), { status })); +} + +afterEach(() => { + mock.restore(); + setSystemTime(); +}); + +describe("isWithinReferenceBand", () => { + it("mirrors the contract's symmetric band around Chainlink", () => { + const oracle = 114_000_000n; + expect(isWithinReferenceBand(oracle, oracle, 100)).toBe(true); + expect(isWithinReferenceBand((oracle * 10_100n) / 10_000n, oracle, 100)).toBe(true); + expect(isWithinReferenceBand((oracle * 9_900n) / 10_000n, oracle, 100)).toBe(true); + expect(isWithinReferenceBand((oracle * 10_101n) / 10_000n, oracle, 100)).toBe(false); + expect(isWithinReferenceBand((oracle * 9_899n) / 10_000n, oracle, 100)).toBe(false); + }); +}); + +describe("fetchCoinbaseReference", () => { + it("reads the EURC-USDC ticker midpoint at the oracle's decimals as the reference", async () => { + setSystemTime(new Date(1_800_000_000_000)); + const fetchSpy = serve({ ask: "1.1475", bid: "1.1471", price: "1.1472", volume: "12.5" }); + expect(await fetchCoinbaseReference(8)).toEqual({ + price: "1.1473", + rateRaw: 114_730_000n, + source: "coinbase-exchange:EURC-USDC:mid", + time: new Date(1_800_000_000_000) + }); + expect(fetchSpy.mock.calls[0][0]).toBe("https://api.exchange.coinbase.com/products/EURC-USDC/ticker"); + }); + + it("floors the midpoint to the unit", async () => { + serve({ ask: "1.00000002", bid: "1.00000001" }); + expect((await fetchCoinbaseReference(8)).rateRaw).toBe(100_000_001n); + }); + + it("defers on a book wider than 50 bps", async () => { + serve({ ask: "1.1530", bid: "1.1470" }); + await expect(fetchCoinbaseReference(8)).rejects.toThrow("Coinbase EURC-USDC spread of 52 bps exceeds 50 bps"); + }); + + it("defers on an inverted or empty book", async () => { + serve({ ask: "1.1470", bid: "1.1475" }); + await expect(fetchCoinbaseReference(8)).rejects.toThrow( + "Coinbase top of book is inverted or empty (bid 1.1475, ask 1.1470)" + ); + mock.restore(); + serve({ ask: "1.0", bid: "0" }); + await expect(fetchCoinbaseReference(8)).rejects.toThrow("Coinbase top of book is inverted or empty (bid 0, ask 1.0)"); + }); + + it("defers on a bad status or anything but two positive decimals", async () => { + serve(null, 503); + await expect(fetchCoinbaseReference(8)).rejects.toThrow("Coinbase ticker responded 503"); + for (const body of [{ price: "1.1472" }, { ask: "1.1475" }, { ask: "abc", bid: "1.1471" }, { ask: 1.1475, bid: 1.1471 }, null]) { + mock.restore(); + serve(body); + await expect(fetchCoinbaseReference(8)).rejects.toThrow("Coinbase ticker response is malformed"); + } + }); +}); + +describe("reference venue status", () => { + it("accepts only an online product with trading enabled", () => { + expect(classifyReferenceVenue({ status: "online", tradingDisabled: false })).toBeNull(); + expect(classifyReferenceVenue({ status: "delisted", tradingDisabled: true })).toContain("is delisted"); + expect(classifyReferenceVenue({ status: "online", tradingDisabled: true })).toContain("trading disabled"); + }); + + it("reads the product status from Coinbase and rejects malformed answers", async () => { + const fetchSpy = serve({ id: "EURC-USDC", status: "online", trading_disabled: false }); + expect(await fetchCoinbaseProductStatus()).toEqual({ status: "online", tradingDisabled: false }); + expect(fetchSpy.mock.calls[0][0]).toBe("https://api.exchange.coinbase.com/products/EURC-USDC"); + mock.restore(); + serve({ status: "online" }); + await expect(fetchCoinbaseProductStatus()).rejects.toThrow("Coinbase product response is malformed"); + mock.restore(); + serve(null, 503); + await expect(fetchCoinbaseProductStatus()).rejects.toThrow("Coinbase product responded 503"); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/reference-rate.ts b/apps/api/src/api/services/monerium-b2b/reference-rate.ts new file mode 100644 index 000000000..69431078d --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -0,0 +1,102 @@ +import { formatUnits, parseUnits } from "viem"; + +/** + * Partner reference rate for the forwarder fee bands (docs/adr-0005-monerium-b2b-onramp.md, P12): + * the Coinbase Exchange EURC-USDC bid/ask midpoint, read fresh before every swap and + * recorded on the execution row (rate, source, time) so the partner can check it against + * Coinbase's public ticker. Spot rather than an average (amendment 2026-09-18): an average + * lags a moving market, and the lag would turn into subsidy in a falling one. The midpoint + * rather than the last trade because a last print can be one-sided or minutes stale on a + * quiet weekend; a wide spread is itself a thin market, and the keeper then defers rather + * than pricing against it. The keeper passes the rate into `swap`; the contract rejects it + * outside its Chainlink band. + */ + +/** + * The Coinbase Exchange product the reference is read from. EURC-USD and EURC-EUR were + * delisted on 2024-08-29 and still answer their endpoints with two-year-old data, so + * the product's status is monitored (`fetchCoinbaseProductStatus`), not assumed. + */ +export const COINBASE_REFERENCE_PRODUCT = "EURC-USDC"; +export const COINBASE_EURC_PRODUCT_URL = `https://api.exchange.coinbase.com/products/${COINBASE_REFERENCE_PRODUCT}`; +export const COINBASE_EURC_TICKER_URL = `${COINBASE_EURC_PRODUCT_URL}/ticker`; +export const COINBASE_REFERENCE_SOURCE = `coinbase-exchange:${COINBASE_REFERENCE_PRODUCT}:mid`; +/** A top of book wider than this is too thin to be a reference; the keeper defers. */ +export const MAX_SPREAD_BPS = 50; +const FETCH_TIMEOUT_MS = 5_000; + +export interface ReferenceQuote { + /** The reference as a decimal string at the oracle's decimals, e.g. "1.14320000". */ + price: string; + /** The reference scaled to the forwarder's ORACLE_DECIMALS. */ + rateRaw: bigint; + source: string; + /** When the reference was read. */ + time: Date; +} + +/** Mirrors VortexForwarder._checkedReference: |reference - oracle| <= oracle x band / 10000. */ +export function isWithinReferenceBand(rateRaw: bigint, oracleRaw: bigint, bandBps: number): boolean { + const tolerance = (oracleRaw * BigInt(bandBps)) / 10_000n; + return rateRaw + tolerance >= oracleRaw && rateRaw <= oracleRaw + tolerance; +} + +const DECIMAL = /^\d+(\.\d+)?$/; + +/** Reads the ticker and returns the midpoint. Any failure throws; the caller defers. */ +export async function fetchCoinbaseReference(decimals: number): Promise { + const time = new Date(); + const response = await fetch(COINBASE_EURC_TICKER_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Coinbase ticker responded ${response.status}`); + } + const ticker = (await response.json()) as { ask?: unknown; bid?: unknown } | null; + const bid = ticker?.bid; + const ask = ticker?.ask; + if (typeof bid !== "string" || typeof ask !== "string" || !DECIMAL.test(bid) || !DECIMAL.test(ask)) { + throw new Error("Coinbase ticker response is malformed"); + } + const bidRaw = parseUnits(bid, decimals); + const askRaw = parseUnits(ask, decimals); + if (bidRaw <= 0n || askRaw < bidRaw) { + throw new Error(`Coinbase top of book is inverted or empty (bid ${bid}, ask ${ask})`); + } + // The midpoint, floored to the unit; the spread in bps of it (floored). + const rateRaw = (bidRaw + askRaw) / 2n; + const spread = Number(((askRaw - bidRaw) * 10_000n) / rateRaw); + if (spread > MAX_SPREAD_BPS) { + throw new Error(`Coinbase ${COINBASE_REFERENCE_PRODUCT} spread of ${spread} bps exceeds ${MAX_SPREAD_BPS} bps`); + } + return { price: formatUnits(rateRaw, decimals), rateRaw, source: COINBASE_REFERENCE_SOURCE, time }; +} + +// ------------------------------------------------------------------ venue status + +export interface CoinbaseProductStatus { + status: string; + tradingDisabled: boolean; +} + +/** Why the reference product cannot serve as the venue right now, or null when it can. */ +export function classifyReferenceVenue(product: CoinbaseProductStatus): string | null { + if (product.status !== "online") { + return `Coinbase product ${COINBASE_REFERENCE_PRODUCT} is ${product.status}`; + } + if (product.tradingDisabled) { + return `Coinbase product ${COINBASE_REFERENCE_PRODUCT} has trading disabled`; + } + return null; +} + +/** Live status of the reference product. Any failure throws; the monitor reports it. */ +export async function fetchCoinbaseProductStatus(): Promise { + const response = await fetch(COINBASE_EURC_PRODUCT_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Coinbase product responded ${response.status}`); + } + const body = (await response.json()) as { status?: unknown; trading_disabled?: unknown } | null; + if (typeof body?.status !== "string" || typeof body.trading_disabled !== "boolean") { + throw new Error("Coinbase product response is malformed"); + } + return { status: body.status, tradingDisabled: body.trading_disabled }; +} diff --git a/apps/api/src/api/services/monerium-b2b/refund-wallet.test.ts b/apps/api/src/api/services/monerium-b2b/refund-wallet.test.ts new file mode 100644 index 000000000..36a9d32c9 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/refund-wallet.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test"; +import { refundAccountFor } from "./refund-wallet"; + +const SEED = `0x${"11".repeat(32)}`; +const PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; + +describe("refund wallet derivation", () => { + it("derives one stable wallet per Monerium profile from the seed", () => { + const wallet = refundAccountFor(PROFILE, SEED); + expect(refundAccountFor(PROFILE.toUpperCase(), SEED).address).toBe(wallet.address); + expect(refundAccountFor("1c9f8d3b-9a5f-4e54-8a3c-3a0a4d2e6b7f", SEED).address).not.toBe(wallet.address); + expect(refundAccountFor(PROFILE, `0x${"22".repeat(32)}`).address).not.toBe(wallet.address); + }); + + it("refuses to derive without a seed", () => { + expect(() => refundAccountFor(PROFILE, "")).toThrow("MONERIUM_B2B_REFUND_SEED"); + }); +}); diff --git a/apps/api/src/api/services/monerium-b2b/refund-wallet.ts b/apps/api/src/api/services/monerium-b2b/refund-wallet.ts new file mode 100644 index 000000000..24b3468ba --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/refund-wallet.ts @@ -0,0 +1,19 @@ +import { concat, type Hex, keccak256, type PrivateKeyAccount, toHex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { config } from "../../../config/vars"; + +/** + * Each client's refund wallet: a plain key derived from MONERIUM_B2B_REFUND_SEED and the + * client's Monerium profile ID. It is the forwarder's fixed `recoveryAddress`, is linked to + * the client's Monerium profile, and redeems a refund out of the client's own IBAN. Keyed on + * the profile, not the clone, so a replacement clone for the same client keeps the same + * wallet (Monerium links an address to one profile only). One secret covers every client, + * and the address is known before the forwarder is deployed. + */ +export function refundAccountFor( + moneriumProfileId: string, + seed: string | undefined = config.moneriumB2b.refundSeed +): PrivateKeyAccount { + if (!seed) throw new Error("MONERIUM_B2B_REFUND_SEED is not configured"); + return privateKeyToAccount(keccak256(concat([seed as Hex, toHex(`vortex-b2b-refund:${moneriumProfileId.toLowerCase()}`)]))); +} diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 0059ef036..5e333ae77 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -2,17 +2,19 @@ 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 { config } from "../../config/vars"; import { isKeeperChainConfigured } from "../services/monerium-b2b/chain"; -import { reconcileConfirmedExecutionAllocations, runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; +import { runConversionExecutor, SETTLING_STATUSES } 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"; +import { runRecoveryDeadlines, runRecoveryOrchestrator } from "../services/monerium-b2b/recovery"; -const DEFAULT_CRON_TIME = "* * * * *"; // every minute +/** Six-field cron with seconds: a waiting chunk is re-quoted every cycle (MONERIUM_B2B_KEEPER_CYCLE_SECONDS). */ +const DEFAULT_CRON_TIME = `*/${config.moneriumB2b.keeperCycleSeconds} * * * * *`; /** * Keeper loop for the Monerium B2B onramp (plan §3): webhook inbox -> mint watcher -> @@ -55,7 +57,6 @@ class MoneriumB2bWorker { } } else { const mintedAccountIds = await runMintWatcher(); - await reconcileConfirmedExecutionAllocations(); const candidateIds = await this.conversionCandidates(mintedAccountIds); for (const accountId of candidateIds) { try { @@ -66,6 +67,16 @@ class MoneriumB2bWorker { } await runDormancyGate(); + + // The refund path: deposits past the promised window are marked (or reported), + // and the one active refund advances by a step; both need the keeper's chain + // config, the orchestrator also the recovery and float keys (fail-fast config). + if (config.moneriumB2b.autoRecovery !== "off") { + await runRecoveryDeadlines(); + } + if (config.moneriumB2b.autoRecovery === "auto") { + await runRecoveryOrchestrator(); + } } // Manager-facing deposit events into the durable webhook outbox; the @@ -87,21 +98,22 @@ class MoneriumB2bWorker { /** * 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. + * with chain-indexed deposits still settling (converting, awaiting their forward, or + * marked for recovery). 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 } + `SELECT DISTINCT account_id AS "accountId" + FROM monerium_fiat_deposits + WHERE status IN (:settling) + AND block_number IS NOT NULL`, + { + replacements: { settling: SETTLING_STATUSES }, + type: QueryTypes.SELECT + } ); for (const row of outstanding) { candidates.add(row.accountId); diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 063ad2149..92c1f3aba 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -27,6 +27,7 @@ const requiredMoneriumB2bEnv = { MONERIUM_B2B_GUARDIAN_PRIVATE_KEY: "0x2222222222222222222222222222222222222222222222222222222222222222", MONERIUM_B2B_KEEPER_PRIVATE_KEY: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", MONERIUM_B2B_PRIVATE_RPC_URL: "https://private-rpc.example.com", + MONERIUM_B2B_REFUND_SEED: "0x3333333333333333333333333333333333333333333333333333333333333333", MONERIUM_B2B_RPC_URL: "https://rpc.example.com", MONERIUM_B2B_WEBHOOK_SECRET: "whsec_MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=", MONERIUM_WHITELABEL_CLIENT_ID: "test-whitelabel-client-id", diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index e0938221e..32eab3b53 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -247,12 +247,32 @@ interface Config { // Separate credential set from the legacy consumer OAuth integration above. moneriumB2b: { attestorPrivateKey: string | undefined; + /** off: nothing; alert: log deposits past the window; auto: mark them and run the refund. */ + autoRecovery: "off" | "alert" | "auto"; enabled: boolean; + /** Key of the EURe float wallet that tops a refund up to the exact amount. */ + floatPrivateKey: string | undefined; + /** Seconds between keeper cycles: how often a waiting chunk is re-quoted. */ + keeperCycleSeconds: number; forwarderFactoryAddress: string | undefined; guardianPrivateKey: string | undefined; keeperPrivateKey: string | undefined; privateRpcUrl: string | undefined; + /** Promised conversion window from the mint, in minutes; the on-chain RECOVERY_DELAY is its floor. */ + recoveryDeadlineMinutes: number; + /** + * 32-byte secret every client's refund wallet key is derived from (refund-wallet.ts): the + * wallet each forwarder recovers to, linked to the client's Monerium profile, signs the + * reverse swap and the redeem from the client's own IBAN. + */ + refundSeed: string | undefined; rpcUrl: string | undefined; + /** + * How much of a chunk's shortfall below the client's floor Vortex pays, as a ladder of + * "after N seconds waited, at most M bps of the reference value" steps; before the first + * step's time the keeper only executes fills at or above the floor. + */ + subsidyLadder: Array<{ afterSeconds: number; maxSubsidyBps: number }>; webhookSecret: string; }; subscanApiKey: string | undefined; @@ -299,6 +319,32 @@ interface Config { }; } +/** + * Launch subsidy ladder (adr-0005 amendment 2026-09-18): nothing for six minutes, then + * 10 bps more every two minutes up to 50, then 100 bps from minute sixteen on. + */ +export const DEFAULT_SUBSIDY_LADDER = "0:0,360:10,480:20,600:30,720:40,840:50,960:100"; + +/** Parses "seconds:bps,seconds:bps,..." into an ascending ladder; throws on anything malformed. */ +export function parseSubsidyLadder(raw: string | undefined): Array<{ afterSeconds: number; maxSubsidyBps: number }> { + const steps = (raw?.trim() || DEFAULT_SUBSIDY_LADDER).split(",").map(entry => { + const [seconds, bps] = entry.split(":").map(part => Number(part.trim())); + if (!Number.isInteger(seconds) || seconds < 0 || !Number.isInteger(bps) || bps < 0 || bps > 10_000) { + throw new Error(`MONERIUM_B2B_SUBSIDY_LADDER entry "${entry}" must be : with bps in 0..10000`); + } + return { afterSeconds: seconds, maxSubsidyBps: bps }; + }); + if (steps[0].afterSeconds !== 0) { + throw new Error("MONERIUM_B2B_SUBSIDY_LADDER must start at 0 seconds"); + } + for (let i = 1; i < steps.length; i++) { + if (steps[i].afterSeconds <= steps[i - 1].afterSeconds || steps[i].maxSubsidyBps < steps[i - 1].maxSubsidyBps) { + throw new Error("MONERIUM_B2B_SUBSIDY_LADDER steps must ascend in both seconds and bps"); + } + } + return steps; +} + export const config: Config = { adminSecret: process.env.ADMIN_SECRET || "", database: { @@ -365,16 +411,24 @@ export const config: Config = { // (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, + autoRecovery: (["alert", "auto"].includes(process.env.MONERIUM_B2B_AUTO_RECOVERY ?? "") + ? process.env.MONERIUM_B2B_AUTO_RECOVERY + : "off") as "off" | "alert" | "auto", enabled: process.env.MONERIUM_B2B_ENABLED === "true", + floatPrivateKey: process.env.MONERIUM_B2B_FLOAT_PRIVATE_KEY, 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, + keeperCycleSeconds: Number(process.env.MONERIUM_B2B_KEEPER_CYCLE_SECONDS || 20), 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, + recoveryDeadlineMinutes: Number(process.env.MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES || 120), + refundSeed: process.env.MONERIUM_B2B_REFUND_SEED, rpcUrl: process.env.MONERIUM_B2B_RPC_URL, + subsidyLadder: parseSubsidyLadder(process.env.MONERIUM_B2B_SUBSIDY_LADDER), webhookSecret: process.env.MONERIUM_B2B_WEBHOOK_SECRET || "" }, mykobo: { @@ -483,6 +537,7 @@ if (config.moneriumB2b.enabled) { 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.refundSeed) missing.push("MONERIUM_B2B_REFUND_SEED"); 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"); @@ -499,13 +554,28 @@ if (config.moneriumB2b.enabled) { ) { throw new Error("MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS must be a valid EVM address"); } + if (config.moneriumB2b.autoRecovery === "auto") { + if (!config.moneriumB2b.floatPrivateKey) { + throw new Error("MONERIUM_B2B_AUTO_RECOVERY=auto requires MONERIUM_B2B_FLOAT_PRIVATE_KEY"); + } + } + if (!Number.isInteger(config.moneriumB2b.recoveryDeadlineMinutes) || config.moneriumB2b.recoveryDeadlineMinutes <= 0) { + throw new Error("MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES must be a positive integer"); + } + if (!Number.isInteger(config.moneriumB2b.keeperCycleSeconds) || config.moneriumB2b.keeperCycleSeconds < 5) { + throw new Error("MONERIUM_B2B_KEEPER_CYCLE_SECONDS must be an integer of at least 5"); + } 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] + ["MONERIUM_B2B_KEEPER_PRIVATE_KEY", config.moneriumB2b.keeperPrivateKey], + ["MONERIUM_B2B_REFUND_SEED", config.moneriumB2b.refundSeed], + ...(config.moneriumB2b.floatPrivateKey + ? ([["MONERIUM_B2B_FLOAT_PRIVATE_KEY", config.moneriumB2b.floatPrivateKey]] as const) + : []) ] 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`); + throw new Error(`${name} must be a 32-byte 0x-prefixed hex value`); } } const b2bKeys = [ diff --git a/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts b/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts new file mode 100644 index 000000000..07bd7ab99 --- /dev/null +++ b/apps/api/src/database/migrations/078-monerium-fee-policy-ppm.ts @@ -0,0 +1,28 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// The forwarder prices swaps against a reference rate with a per-clone target and floor +// in parts per million (docs/adr-0005-monerium-b2b-onramp.md, B1/P11). The flat +// fee_bps mirror is replaced by both policy values; defaults are the agreed launch policy. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_accounts", "target_ppm", { + allowNull: false, + defaultValue: 1250, + type: DataTypes.INTEGER + }); + await queryInterface.addColumn("monerium_accounts", "floor_ppm", { + allowNull: false, + defaultValue: 1500, + type: DataTypes.INTEGER + }); + await queryInterface.removeColumn("monerium_accounts", "fee_bps"); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_accounts", "fee_bps", { + allowNull: false, + defaultValue: 0, + type: DataTypes.INTEGER + }); + await queryInterface.removeColumn("monerium_accounts", "floor_ppm"); + await queryInterface.removeColumn("monerium_accounts", "target_ppm"); +} diff --git a/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts new file mode 100644 index 000000000..18bcd794d --- /dev/null +++ b/apps/api/src/database/migrations/079-add-conversion-reference-and-subsidy.ts @@ -0,0 +1,42 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Every swap is priced against a partner reference rate (the Coinbase bid/ask midpoint) +// and may draw a subsidy from the vault up to the keeper's tier for the chunk +// (docs/architecture-monerium-b2b-onramp.md, fees section). The reference, the chosen +// route and the tier are persisted before broadcast (crash-recovery calldata identity + +// audit trail); the subsidy is recorded from the SwapExecuted event. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "reference_rate_raw", { + allowNull: true, + type: DataTypes.DECIMAL(38, 0) + }); + await queryInterface.addColumn("monerium_conversion_executions", "reference_source", { + allowNull: true, + type: DataTypes.STRING(64) + }); + await queryInterface.addColumn("monerium_conversion_executions", "reference_at", { + allowNull: true, + type: DataTypes.DATE + }); + await queryInterface.addColumn("monerium_conversion_executions", "route_index", { + allowNull: true, + type: DataTypes.INTEGER + }); + await queryInterface.addColumn("monerium_conversion_executions", "subsidy_raw", { + allowNull: true, + type: DataTypes.DECIMAL(38, 0) + }); + await queryInterface.addColumn("monerium_conversion_executions", "max_subsidy_raw", { + allowNull: true, + type: DataTypes.DECIMAL(38, 0) + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "max_subsidy_raw"); + await queryInterface.removeColumn("monerium_conversion_executions", "subsidy_raw"); + await queryInterface.removeColumn("monerium_conversion_executions", "route_index"); + await queryInterface.removeColumn("monerium_conversion_executions", "reference_at"); + await queryInterface.removeColumn("monerium_conversion_executions", "reference_source"); + await queryInterface.removeColumn("monerium_conversion_executions", "reference_rate_raw"); +} diff --git a/apps/api/src/database/migrations/080-monerium-whole-deposit-settlement.ts b/apps/api/src/database/migrations/080-monerium-whole-deposit-settlement.ts new file mode 100644 index 000000000..7079969bb --- /dev/null +++ b/apps/api/src/database/migrations/080-monerium-whole-deposit-settlement.ts @@ -0,0 +1,83 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Whole-deposit settlement (docs/adr-0005-monerium-b2b-onramp.md, amendment 2026-09-17): +// a swap converts one chunk of one deposit, so an execution carries the deposit it serves +// (1 deposit : N executions) and the N:M allocation join has no job left; a forward and a +// recovery are keeper transactions of their own kind. The client-held fallback role left +// the contract, and the deposit lifecycle gains the settlement and refund states. +const DEPOSIT_STATUS_VALUES = ["converting", "forwarded", "recovering", "refunded", "recovery_failed"]; + +export async function up(queryInterface: QueryInterface): Promise { + // ADD VALUE runs outside a transaction (umzug does not wrap migrations); the values + // are usable by the statements below. + for (const value of DEPOSIT_STATUS_VALUES) { + await queryInterface.sequelize.query(`ALTER TYPE "enum_monerium_fiat_deposits_status" ADD VALUE IF NOT EXISTS '${value}'`); + } + + await queryInterface.addColumn("monerium_conversion_executions", "kind", { + allowNull: false, + defaultValue: "swap", + type: DataTypes.ENUM("swap", "forward", "recover") + }); + await queryInterface.addColumn("monerium_conversion_executions", "deposit_id", { + allowNull: true, + references: { key: "id", model: "monerium_fiat_deposits" }, + type: DataTypes.UUID + }); + await queryInterface.addIndex("monerium_conversion_executions", ["deposit_id"]); + + // Backfill from the allocation join: an execution that served exactly one deposit is + // that deposit's chunk. One that spanned several deposits cannot be represented in the + // 1:N model and must be reconciled by hand before this deploys. + const [spanning] = (await queryInterface.sequelize.query( + "SELECT execution_id FROM monerium_deposit_allocations GROUP BY execution_id HAVING COUNT(*) > 1 LIMIT 1" + )) as [unknown[], unknown]; + if (spanning.length > 0) { + throw new Error("A Monerium conversion execution spans several deposits; reconcile the allocations before deploying"); + } + await queryInterface.sequelize.query( + "UPDATE monerium_conversion_executions AS e SET deposit_id = a.deposit_id " + + "FROM monerium_deposit_allocations AS a WHERE a.execution_id = e.id" + ); + // The previous contract forwarded every chunk on the spot: a fully allocated deposit is + // already at the client's destination. + await queryInterface.sequelize.query( + "UPDATE monerium_fiat_deposits AS d SET status = 'forwarded' WHERE d.status = 'minted' AND " + + "(SELECT COALESCE(SUM(a.eure_in_raw), 0) FROM monerium_deposit_allocations AS a WHERE a.deposit_id = d.id) >= d.amount_raw" + ); + + await queryInterface.dropTable("monerium_deposit_allocations", {}); + await queryInterface.removeColumn("monerium_accounts", "fallback_address"); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_accounts", "fallback_address", { + allowNull: true, + type: DataTypes.STRING(42) + }); + 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.addIndex("monerium_deposit_allocations", ["deposit_id", "execution_id"], { unique: true }); + await queryInterface.addIndex("monerium_deposit_allocations", ["execution_id"]); + await queryInterface.removeColumn("monerium_conversion_executions", "deposit_id"); + await queryInterface.removeColumn("monerium_conversion_executions", "kind"); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_monerium_conversion_executions_kind"'); + // Postgres cannot drop enum values; the added deposit statuses stay in the type. +} diff --git a/apps/api/src/database/migrations/081-monerium-recovery-automation.ts b/apps/api/src/database/migrations/081-monerium-recovery-automation.ts new file mode 100644 index 000000000..f93e2b686 --- /dev/null +++ b/apps/api/src/database/migrations/081-monerium-recovery-automation.ts @@ -0,0 +1,52 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Automated refund path (docs/architecture-monerium-b2b-onramp.md, "the refund path"): +// a deposit records when its EURe was minted (the promised window counts from there) +// and who paid it (the refund target, from the issue order's counterpart); a recovery +// row drives one deposit from the keeper's `recover` through the reverse swap, the +// float top-up and the Monerium redeem order, one recovery at a time. The DEPOSIT_RETURNED +// marker, like the received/converted markers, makes that event fire once per refund. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_fiat_deposits", "minted_at", { allowNull: true, type: DataTypes.DATE }); + await queryInterface.addColumn("monerium_fiat_deposits", "payer_iban", { allowNull: true, type: DataTypes.STRING(34) }); + await queryInterface.addColumn("monerium_fiat_deposits", "payer_name", { allowNull: true, type: DataTypes.STRING(140) }); + await queryInterface.addColumn("monerium_fiat_deposits", "returned_event_at", { allowNull: true, type: DataTypes.DATE }); + + await queryInterface.createTable("monerium_recoveries", { + attempts: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + created_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + deposit_id: { + allowNull: false, + references: { key: "id", model: "monerium_fiat_deposits" }, + type: DataTypes.UUID, + unique: true + }, + error: { allowNull: true, type: DataTypes.TEXT }, + eure_from_swap_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + eure_recovered_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) }, + float_topup_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + float_topup_tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + phase: { + allowNull: false, + type: DataTypes.ENUM("moved", "swapping", "swapped", "topping_up", "topped_up", "redeeming", "redeemed") + }, + redeem_order_id: { allowNull: true, type: DataTypes.STRING(64) }, + refund_amount: { allowNull: true, type: DataTypes.STRING(32) }, + reverse_swap_tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + surplus_raw: { allowNull: true, type: DataTypes.DECIMAL(38, 0) }, + surplus_tx_hash: { allowNull: true, type: DataTypes.STRING(66) }, + updated_at: { allowNull: false, defaultValue: DataTypes.NOW, type: DataTypes.DATE }, + usdc_recovered_raw: { allowNull: false, type: DataTypes.DECIMAL(38, 0) } + }); + await queryInterface.addIndex("monerium_recoveries", ["phase"]); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("monerium_recoveries", {}); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_monerium_recoveries_phase"'); + await queryInterface.removeColumn("monerium_fiat_deposits", "returned_event_at"); + await queryInterface.removeColumn("monerium_fiat_deposits", "payer_name"); + await queryInterface.removeColumn("monerium_fiat_deposits", "payer_iban"); + await queryInterface.removeColumn("monerium_fiat_deposits", "minted_at"); +} diff --git a/apps/api/src/database/migrations/085-add-monerium-lifecycle-events.ts b/apps/api/src/database/migrations/085-add-monerium-lifecycle-events.ts new file mode 100644 index 000000000..9ac6fca92 --- /dev/null +++ b/apps/api/src/database/migrations/085-add-monerium-lifecycle-events.ts @@ -0,0 +1,33 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Lifecycle reporting for partners (DEPOSIT_UPDATED / ACCOUNT_UPDATED): why a deposit is +// waiting or being refunded, Monerium's rejection reason, and the hash of the last +// snapshot sent so an event fires only when the snapshot changed. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_fiat_deposits", "waiting_reason", { allowNull: true, type: DataTypes.STRING(32) }); + await queryInterface.addColumn("monerium_fiat_deposits", "waiting_since", { allowNull: true, type: DataTypes.DATE }); + await queryInterface.addColumn("monerium_fiat_deposits", "rejected_reason", { allowNull: true, type: DataTypes.STRING(500) }); + await queryInterface.addColumn("monerium_fiat_deposits", "refund_reason", { allowNull: true, type: DataTypes.STRING(32) }); + await queryInterface.addColumn("monerium_fiat_deposits", "refund_started_at", { allowNull: true, type: DataTypes.DATE }); + await queryInterface.addColumn("monerium_fiat_deposits", "lifecycle_event_hash", { + allowNull: true, + type: DataTypes.STRING(64) + }); + await queryInterface.addColumn("monerium_fiat_deposits", "lifecycle_event_at", { allowNull: true, type: DataTypes.DATE }); + await queryInterface.addColumn("monerium_accounts", "lifecycle_event_hash", { allowNull: true, type: DataTypes.STRING(64) }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_accounts", "lifecycle_event_hash"); + for (const column of [ + "lifecycle_event_at", + "lifecycle_event_hash", + "refund_started_at", + "refund_reason", + "rejected_reason", + "waiting_since", + "waiting_reason" + ]) { + await queryInterface.removeColumn("monerium_fiat_deposits", column); + } +} diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 2e322ab07..a052bd8fc 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -13,8 +13,8 @@ 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 MoneriumRecovery from "./moneriumRecovery.model"; import MoneriumWebhookEvent from "./moneriumWebhookEvent.model"; import Notification from "./notification.model"; import NotificationPreference from "./notificationPreference.model"; @@ -39,10 +39,10 @@ MoneriumAccount.hasMany(MoneriumFiatDeposit, { as: "fiatDeposits", foreignKey: " 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" }); +MoneriumFiatDeposit.hasMany(MoneriumConversionExecution, { as: "executions", foreignKey: "depositId" }); +MoneriumConversionExecution.belongsTo(MoneriumFiatDeposit, { as: "deposit", foreignKey: "depositId" }); +MoneriumFiatDeposit.hasOne(MoneriumRecovery, { as: "recovery", foreignKey: "depositId" }); +MoneriumRecovery.belongsTo(MoneriumFiatDeposit, { as: "deposit", foreignKey: "depositId" }); MoneriumAccount.belongsTo(User, { as: "vortexProfile", foreignKey: "vortexProfileId" }); User.hasOne(MoneriumAccount, { as: "moneriumAccount", foreignKey: "vortexProfileId" }); Webhook.hasMany(WebhookDelivery, { as: "deliveries", foreignKey: "webhookId" }); @@ -146,8 +146,8 @@ const models = { MoneriumAccount, MoneriumChainCursor, MoneriumConversionExecution, - MoneriumDepositAllocation, MoneriumFiatDeposit, + MoneriumRecovery, MoneriumWebhookEvent, Notification, NotificationPreference, diff --git a/apps/api/src/models/moneriumAccount.model.ts b/apps/api/src/models/moneriumAccount.model.ts index 72e008be2..228e36535 100644 --- a/apps/api/src/models/moneriumAccount.model.ts +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -20,18 +20,30 @@ export interface MoneriumAccountAttributes { iban: string | null; forwarderAddress: string; destination: string; - fallbackAddress: string; - feeBps: number; + targetPpm: number; + floorPpm: number; configVersion: number; status: MoneriumAccountStatus; dormantSince: Date | null; + /** Hash of the last ACCOUNT_UPDATED snapshot sent to the partner. */ + lifecycleEventHash: string | null; createdAt: Date; updatedAt: Date; } type MoneriumAccountCreationAttributes = Optional< MoneriumAccountAttributes, - "id" | "vortexProfileId" | "iban" | "configVersion" | "status" | "dormantSince" | "createdAt" | "updatedAt" + | "id" + | "vortexProfileId" + | "iban" + | "targetPpm" + | "floorPpm" + | "configVersion" + | "status" + | "dormantSince" + | "lifecycleEventHash" + | "createdAt" + | "updatedAt" >; class MoneriumAccount @@ -44,11 +56,12 @@ class MoneriumAccount declare iban: string | null; declare forwarderAddress: string; declare destination: string; - declare fallbackAddress: string; - declare feeBps: number; + declare targetPpm: number; + declare floorPpm: number; declare configVersion: number; declare status: MoneriumAccountStatus; declare dormantSince: Date | null; + declare lifecycleEventHash: string | null; declare createdAt: Date; declare updatedAt: Date; } @@ -76,15 +89,12 @@ MoneriumAccount.init( field: "dormant_since", type: DataTypes.DATE }, - fallbackAddress: { + // Fee policy mirror (ppm below the reference rate) for accounting and drift + // detection only; the clone's values are authoritative (P11 reconciliation). + floorPpm: { allowNull: false, - field: "fallback_address", - type: DataTypes.STRING(42) - }, - feeBps: { - allowNull: false, - defaultValue: 0, - field: "fee_bps", + defaultValue: 1500, + field: "floor_ppm", type: DataTypes.INTEGER }, forwarderAddress: { @@ -102,6 +112,11 @@ MoneriumAccount.init( primaryKey: true, type: DataTypes.UUID }, + lifecycleEventHash: { + allowNull: true, + field: "lifecycle_event_hash", + type: DataTypes.STRING(64) + }, profileId: { allowNull: false, field: "profile_id", @@ -113,6 +128,12 @@ MoneriumAccount.init( defaultValue: MoneriumAccountStatus.Onboarding, type: DataTypes.ENUM(...Object.values(MoneriumAccountStatus)) }, + targetPpm: { + allowNull: false, + defaultValue: 1250, + field: "target_ppm", + type: DataTypes.INTEGER + }, updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, diff --git a/apps/api/src/models/moneriumConversionExecution.model.ts b/apps/api/src/models/moneriumConversionExecution.model.ts index d905b534d..06d620720 100644 --- a/apps/api/src/models/moneriumConversionExecution.model.ts +++ b/apps/api/src/models/moneriumConversionExecution.model.ts @@ -7,18 +7,41 @@ export enum MoneriumConversionExecutionStatus { 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. +/** Which keeper transaction the row records (docs/architecture-monerium-b2b-onramp.md, keeper). */ +export enum MoneriumConversionExecutionKind { + /** `swap(reference, route, amountIn)`: one chunk of one deposit, USDC kept on the clone. */ + Swap = "swap", + /** `forward(amount)`: the whole converted deposit to the client's destination. */ + Forward = "forward", + /** `recover(eure, usdc)`: the deposit's unconverted EURe and converted USDC to the recovery wallet. */ + Recover = "recover" +} + +// One row per keeper transaction on a forwarder, bound to the deposit it serves +// (1 deposit : N executions). For a swap, eureInRaw is the chunk and usdcNetRaw the +// client's net for it; for a forward, usdcNetRaw is the amount pushed to the +// destination; for a recovery, eureInRaw and usdcNetRaw are the two amounts moved. export interface MoneriumConversionExecutionAttributes { id: string; accountId: string; + kind: MoneriumConversionExecutionKind; + /** The deposit this transaction serves; null only for rows that predate the 1:N model. */ + depositId: string | null; eureInRaw: string; // 18-decimal base units usdcGrossRaw: string | null; // 6-decimal base units feeRaw: string | null; + /** USDC the subsidy vault paid straight to the destination for this swap (6 decimals). */ + subsidyRaw: string | null; usdcNetRaw: string | null; destination: string; + /** Partner reference the swap was priced against, ORACLE_DECIMALS; persisted before broadcast. */ + referenceRateRaw: string | null; + referenceSource: string | null; + referenceAt: Date | null; + /** Factory route index the swap executed. */ + routeIndex: number | null; + /** The keeper's subsidy tier for the chunk (6 decimals), the `maxSubsidy` argument; persisted before broadcast. */ + maxSubsidyRaw: string | null; txHash: string | null; /** The swap's transaction nonce, persisted BEFORE broadcast (crash-recovery identity). */ nonce: number | null; @@ -36,9 +59,17 @@ export interface MoneriumConversionExecutionAttributes { type MoneriumConversionExecutionCreationAttributes = Optional< MoneriumConversionExecutionAttributes, | "id" + | "kind" + | "depositId" | "usdcGrossRaw" | "feeRaw" + | "subsidyRaw" | "usdcNetRaw" + | "referenceRateRaw" + | "referenceSource" + | "referenceAt" + | "routeIndex" + | "maxSubsidyRaw" | "txHash" | "nonce" | "broadcastBlockNumber" @@ -56,11 +87,19 @@ class MoneriumConversionExecution { declare id: string; declare accountId: string; + declare kind: MoneriumConversionExecutionKind; + declare depositId: string | null; declare eureInRaw: string; declare usdcGrossRaw: string | null; declare feeRaw: string | null; + declare subsidyRaw: string | null; declare usdcNetRaw: string | null; declare destination: string; + declare referenceRateRaw: string | null; + declare referenceSource: string | null; + declare referenceAt: Date | null; + declare routeIndex: number | null; + declare maxSubsidyRaw: string | null; declare txHash: string | null; declare nonce: number | null; declare broadcastBlockNumber: number | null; @@ -95,6 +134,11 @@ MoneriumConversionExecution.init( field: "created_at", type: DataTypes.DATE }, + depositId: { + allowNull: true, + field: "deposit_id", + type: DataTypes.UUID + }, destination: { allowNull: false, type: DataTypes.STRING(42) @@ -118,15 +162,50 @@ MoneriumConversionExecution.init( primaryKey: true, type: DataTypes.UUID }, + kind: { + allowNull: false, + defaultValue: MoneriumConversionExecutionKind.Swap, + type: DataTypes.ENUM(...Object.values(MoneriumConversionExecutionKind)) + }, + maxSubsidyRaw: { + allowNull: true, + field: "max_subsidy_raw", + type: DataTypes.DECIMAL(38, 0) + }, nonce: { allowNull: true, type: DataTypes.INTEGER }, + referenceAt: { + allowNull: true, + field: "reference_at", + type: DataTypes.DATE + }, + referenceRateRaw: { + allowNull: true, + field: "reference_rate_raw", + type: DataTypes.DECIMAL(38, 0) + }, + referenceSource: { + allowNull: true, + field: "reference_source", + type: DataTypes.STRING(64) + }, + routeIndex: { + allowNull: true, + field: "route_index", + type: DataTypes.INTEGER + }, status: { allowNull: false, defaultValue: MoneriumConversionExecutionStatus.Pending, type: DataTypes.ENUM(...Object.values(MoneriumConversionExecutionStatus)) }, + subsidyRaw: { + allowNull: true, + field: "subsidy_raw", + type: DataTypes.DECIMAL(38, 0) + }, swapLogIndex: { allowNull: true, field: "swap_log_index", @@ -155,7 +234,7 @@ MoneriumConversionExecution.init( } }, { - indexes: [{ fields: ["account_id", "status"] }], + indexes: [{ fields: ["account_id", "status"] }, { fields: ["deposit_id"] }], modelName: "MoneriumConversionExecution", sequelize, tableName: "monerium_conversion_executions" diff --git a/apps/api/src/models/moneriumDepositAllocation.model.ts b/apps/api/src/models/moneriumDepositAllocation.model.ts deleted file mode 100644 index 2fb0f2138..000000000 --- a/apps/api/src/models/moneriumDepositAllocation.model.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 index dfca1552d..65f143b07 100644 --- a/apps/api/src/models/moneriumFiatDeposit.model.ts +++ b/apps/api/src/models/moneriumFiatDeposit.model.ts @@ -2,15 +2,31 @@ import { DataTypes, Model, Op, Optional } from "sequelize"; import sequelize from "../config/database"; export enum MoneriumFiatDepositStatus { + /** Provider order placed, EURe not minted yet. */ Pending = "pending", + /** EURe minted to the forwarder; convertible once chain-indexed. */ Minted = "minted", + /** Provider compliance hold before the mint. */ Held = "held", - Returned = "returned" + /** Provider returned the payment before the mint. Terminal. */ + Returned = "returned", + /** At least one chunk swap was sent; USDC accumulates on the forwarder. */ + Converting = "converting", + /** The whole converted deposit reached the client's destination. Terminal. */ + Forwarded = "forwarded", + /** The promised window was missed (or an operator intervened): funds go to the recovery wallet for a bank refund. */ + Recovering = "recovering", + /** The exact EUR amount was redeemed to the payer's bank account. Terminal. */ + Refunded = "refunded", + /** A recovery step failed beyond retry; operator runbook. Terminal until reset by an operator. */ + RecoveryFailed = "recovery_failed" } // 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). +// mint. Status transitions are forward-only (plan §3, R06/R13): the provider states +// first, then the settlement (converting → forwarded) or refund (recovering → refunded) +// branch; executions bound to the deposit carry the chain evidence for each step. export interface MoneriumFiatDepositAttributes { id: string; accountId: string; @@ -23,8 +39,25 @@ export interface MoneriumFiatDepositAttributes { logIndex: number | null; blockHash: string | null; blockNumber: number | null; + /** Timestamp of the mint block: the promised conversion window counts from here. */ + mintedAt: Date | null; + /** The payer's bank account and name from the issue order's counterpart: the refund target. */ + payerIban: string | null; + payerName: string | null; receivedEventAt: Date | null; convertedEventAt: Date | null; + returnedEventAt: Date | null; + /** Why the keeper is waiting to convert (a DepositWaitingReason), and since when. */ + waitingReason: string | null; + waitingSince: Date | null; + /** Monerium's reason when it rejected the payment before minting. */ + rejectedReason: string | null; + /** Why the deposit entered the refund path (a DepositRefundReason), and when. */ + refundReason: string | null; + refundStartedAt: Date | null; + /** Hash and time of the last DEPOSIT_UPDATED snapshot evaluated (set silently, never bumps updated_at). */ + lifecycleEventHash: string | null; + lifecycleEventAt: Date | null; createdAt: Date; updatedAt: Date; } @@ -38,8 +71,19 @@ type MoneriumFiatDepositCreationAttributes = Optional< | "logIndex" | "blockHash" | "blockNumber" + | "mintedAt" + | "payerIban" + | "payerName" | "receivedEventAt" | "convertedEventAt" + | "returnedEventAt" + | "waitingReason" + | "waitingSince" + | "rejectedReason" + | "refundReason" + | "refundStartedAt" + | "lifecycleEventHash" + | "lifecycleEventAt" | "createdAt" | "updatedAt" >; @@ -59,8 +103,19 @@ class MoneriumFiatDeposit declare logIndex: number | null; declare blockHash: string | null; declare blockNumber: number | null; + declare mintedAt: Date | null; + declare payerIban: string | null; + declare payerName: string | null; declare receivedEventAt: Date | null; declare convertedEventAt: Date | null; + declare returnedEventAt: Date | null; + declare waitingReason: string | null; + declare waitingSince: Date | null; + declare rejectedReason: string | null; + declare refundReason: string | null; + declare refundStartedAt: Date | null; + declare lifecycleEventHash: string | null; + declare lifecycleEventAt: Date | null; declare createdAt: Date; declare updatedAt: Date; } @@ -115,22 +170,67 @@ MoneriumFiatDeposit.init( primaryKey: true, type: DataTypes.UUID }, + lifecycleEventAt: { + allowNull: true, + field: "lifecycle_event_at", + type: DataTypes.DATE + }, + lifecycleEventHash: { + allowNull: true, + field: "lifecycle_event_hash", + type: DataTypes.STRING(64) + }, logIndex: { allowNull: true, field: "log_index", type: DataTypes.INTEGER }, + mintedAt: { + allowNull: true, + field: "minted_at", + type: DataTypes.DATE + }, moneriumOrderId: { allowNull: false, field: "monerium_order_id", type: DataTypes.STRING(64), unique: true }, + payerIban: { + allowNull: true, + field: "payer_iban", + type: DataTypes.STRING(34) + }, + payerName: { + allowNull: true, + field: "payer_name", + type: DataTypes.STRING(140) + }, receivedEventAt: { allowNull: true, field: "received_event_at", type: DataTypes.DATE }, + refundReason: { + allowNull: true, + field: "refund_reason", + type: DataTypes.STRING(32) + }, + refundStartedAt: { + allowNull: true, + field: "refund_started_at", + type: DataTypes.DATE + }, + rejectedReason: { + allowNull: true, + field: "rejected_reason", + type: DataTypes.STRING(500) + }, + returnedEventAt: { + allowNull: true, + field: "returned_event_at", + type: DataTypes.DATE + }, status: { allowNull: false, defaultValue: MoneriumFiatDepositStatus.Pending, @@ -146,6 +246,16 @@ MoneriumFiatDeposit.init( defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE + }, + waitingReason: { + allowNull: true, + field: "waiting_reason", + type: DataTypes.STRING(32) + }, + waitingSince: { + allowNull: true, + field: "waiting_since", + type: DataTypes.DATE } }, { diff --git a/apps/api/src/models/moneriumRecovery.model.ts b/apps/api/src/models/moneriumRecovery.model.ts new file mode 100644 index 000000000..f74152f78 --- /dev/null +++ b/apps/api/src/models/moneriumRecovery.model.ts @@ -0,0 +1,121 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +/** + * Where a refund stands (docs/architecture-monerium-b2b-onramp.md, "the refund path"). + * A failure keeps the phase it failed in and marks the deposit `recovery_failed`; an + * operator retry (deposit back to `recovering`) resumes from that phase. + */ +export enum MoneriumRecoveryPhase { + /** The keeper's `recover` is confirmed: the payment sits on the recovery wallet. */ + Moved = "moved", + /** The reverse swap (USDC -> EURe) was sent. */ + Swapping = "swapping", + /** The recovery wallet holds only EURe for this payment. */ + Swapped = "swapped", + /** The float top-up (or the surplus sweep) was sent. */ + ToppingUp = "topping_up", + /** The recovery wallet holds exactly the refund amount. */ + ToppedUp = "topped_up", + /** The Monerium redeem order was placed. */ + Redeeming = "redeeming", + /** Monerium processed the redeem order: the payer was refunded. Terminal. */ + Redeemed = "redeemed" +} + +// One row per refunded deposit; at most one row is active (not redeemed) at a time, +// because every step reasons about the dedicated recovery wallet's balances. +export interface MoneriumRecoveryAttributes { + id: string; + depositId: string; + phase: MoneriumRecoveryPhase; + /** Moved off the clone by `recover` (18 / 6 decimals). */ + eureRecoveredRaw: string; + usdcRecoveredRaw: string; + reverseSwapTxHash: string | null; + /** EURe the reverse swap produced (18 decimals). */ + eureFromSwapRaw: string | null; + floatTopupTxHash: string | null; + /** EURe the float paid to reach the refund amount: the refund's subsidy (18 decimals). */ + floatTopupRaw: string | null; + /** EURe the reverse swap produced beyond the refund amount, swept to the float (18 decimals). */ + surplusRaw: string | null; + surplusTxHash: string | null; + /** The EUR amount redeemed, as Monerium expects it ("1234.56"). */ + refundAmount: string | null; + redeemOrderId: string | null; + attempts: number; + error: string | null; + createdAt: Date; + updatedAt: Date; +} + +type MoneriumRecoveryCreationAttributes = Optional< + MoneriumRecoveryAttributes, + | "id" + | "reverseSwapTxHash" + | "eureFromSwapRaw" + | "floatTopupTxHash" + | "floatTopupRaw" + | "surplusRaw" + | "surplusTxHash" + | "refundAmount" + | "redeemOrderId" + | "attempts" + | "error" + | "createdAt" + | "updatedAt" +>; + +class MoneriumRecovery + extends Model + implements MoneriumRecoveryAttributes +{ + declare id: string; + declare depositId: string; + declare phase: MoneriumRecoveryPhase; + declare eureRecoveredRaw: string; + declare usdcRecoveredRaw: string; + declare reverseSwapTxHash: string | null; + declare eureFromSwapRaw: string | null; + declare floatTopupTxHash: string | null; + declare floatTopupRaw: string | null; + declare surplusRaw: string | null; + declare surplusTxHash: string | null; + declare refundAmount: string | null; + declare redeemOrderId: string | null; + declare attempts: number; + declare error: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +MoneriumRecovery.init( + { + attempts: { allowNull: false, defaultValue: 0, type: DataTypes.INTEGER }, + createdAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "created_at", type: DataTypes.DATE }, + depositId: { allowNull: false, field: "deposit_id", type: DataTypes.UUID, unique: true }, + error: { allowNull: true, type: DataTypes.TEXT }, + eureFromSwapRaw: { allowNull: true, field: "eure_from_swap_raw", type: DataTypes.DECIMAL(38, 0) }, + eureRecoveredRaw: { allowNull: false, field: "eure_recovered_raw", type: DataTypes.DECIMAL(38, 0) }, + floatTopupRaw: { allowNull: true, field: "float_topup_raw", type: DataTypes.DECIMAL(38, 0) }, + floatTopupTxHash: { allowNull: true, field: "float_topup_tx_hash", type: DataTypes.STRING(66) }, + id: { defaultValue: DataTypes.UUIDV4, primaryKey: true, type: DataTypes.UUID }, + phase: { allowNull: false, type: DataTypes.ENUM(...Object.values(MoneriumRecoveryPhase)) }, + redeemOrderId: { allowNull: true, field: "redeem_order_id", type: DataTypes.STRING(64) }, + refundAmount: { allowNull: true, field: "refund_amount", type: DataTypes.STRING(32) }, + reverseSwapTxHash: { allowNull: true, field: "reverse_swap_tx_hash", type: DataTypes.STRING(66) }, + surplusRaw: { allowNull: true, field: "surplus_raw", type: DataTypes.DECIMAL(38, 0) }, + surplusTxHash: { allowNull: true, field: "surplus_tx_hash", type: DataTypes.STRING(66) }, + updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE }, + usdcRecoveredRaw: { allowNull: false, field: "usdc_recovered_raw", type: DataTypes.DECIMAL(38, 0) } + }, + { + indexes: [{ fields: ["phase"] }], + modelName: "MoneriumRecovery", + sequelize, + tableName: "monerium_recoveries" + } +); + +export default MoneriumRecovery; diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 2785b4e77..690778c57 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -41,6 +41,7 @@ if (!process.env.RUN_LIVE_TESTS) { 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_REFUND_SEED = "0x3333333333333333333333333333333333333333333333333333333333333333"; 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. 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 index cbcaef1e9..a00c3ee9f 100644 --- a/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts +++ b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts @@ -2,8 +2,10 @@ 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 MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit, { MoneriumFiatDepositStatus } from "../models/moneriumFiatDeposit.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; import { createTestApiKey, createTestUser } from "../test-utils/factories"; @@ -13,7 +15,6 @@ import { provisionMoneriumB2bAccount } from "../api/services/monerium-b2b/accoun const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; -const FALLBACK = "0x3333333333333333333333333333333333333333"; const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; describe("monerium b2b account read surface", () => { @@ -58,7 +59,6 @@ describe("monerium b2b account read surface", () => { contactEmail: "ops@client.example.com", destination: DESTINATION, externalSubjectId: "client-1", - fallbackAddress: FALLBACK, forwarderAddress: FORWARDER, managerProfileId: manager.id, moneriumProfileId: MONERIUM_PROFILE @@ -78,33 +78,41 @@ describe("monerium b2b account read surface", () => { expect(account.body.account).toMatchObject({ accountId: mapped.accountId, destination: DESTINATION, - fallbackAddress: FALLBACK, - feeBps: 0, + floorPpm: 1500, forwarderAddress: FORWARDER, iban: null, status: "onboarding" }); + expect(account.body.account).not.toHaveProperty("fallbackAddress"); + const convertedDeposit = await MoneriumFiatDeposit.create({ + accountId: mapped.accountId, + currency: "eur", + amountRaw: "100000000000000000000", + moneriumOrderId: "order-1", + status: MoneriumFiatDepositStatus.Forwarded, + txHash: "0xmint" + }); const execution = await MoneriumConversionExecution.create({ + feeRaw: "8000000", + referenceRateRaw: "114000000", + subsidyRaw: "0", accountId: mapped.accountId, + depositId: convertedDeposit.id, destination: DESTINATION, eureInRaw: "100000000000000000000", status: MoneriumConversionExecutionStatus.Confirmed, txHash: "0xswap", usdcNetRaw: "108000000" }); - const convertedDeposit = await MoneriumFiatDeposit.create({ + await MoneriumConversionExecution.create({ accountId: mapped.accountId, - currency: "eur", - amountRaw: "100000000000000000000", - moneriumOrderId: "order-1", - status: MoneriumFiatDepositStatus.Minted, - txHash: "0xmint" - }); - await MoneriumDepositAllocation.create({ depositId: convertedDeposit.id, + destination: DESTINATION, eureInRaw: "100000000000000000000", - executionId: execution.id, + kind: MoneriumConversionExecutionKind.Forward, + status: MoneriumConversionExecutionStatus.Confirmed, + txHash: "0xforward", usdcNetRaw: "108000000" }); await MoneriumFiatDeposit.create({ @@ -127,22 +135,25 @@ describe("monerium b2b account read surface", () => { 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.map(row => row.status)).toEqual(["pending", "forwarded"]); expect(rows[1]).toMatchObject({ amountRaw: "100000000000000000000", conversions: [ { eureInRaw: "100000000000000000000", + execution: { feeRaw: "8000000", referenceRateRaw: "114000000", subsidyRaw: "0" }, executionId: execution.id, status: "confirmed", txHash: "0xswap", usdcNetRaw: "108000000" } ], + forwardTxHash: "0xforward", + refund: null, txHash: "0xmint", usdcNetRaw: "108000000" }); - expect(rows[0]).toMatchObject({ conversions: [], usdcNetRaw: "0" }); + expect(rows[0]).toMatchObject({ conversions: [], forwardTxHash: null, refund: null, usdcNetRaw: "0" }); expect(deposits.body.pagination).toMatchObject({ total: 2 }); }); @@ -187,7 +198,7 @@ describe("monerium b2b account read surface", () => { const response = await app.request("/v1/webhook", { body: JSON.stringify({ - events: ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"], + events: ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED", "DEPOSIT_UPDATED", "ACCOUNT_UPDATED"], url: "https://manager.example.com/vortex/deposits" }), headers: { "Content-Type": "application/json", ...managerHeaders }, @@ -195,7 +206,7 @@ describe("monerium b2b account read surface", () => { }); 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.events).toEqual(["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED", "DEPOSIT_UPDATED", "ACCOUNT_UPDATED"]); expect(body.quoteId).toBeNull(); // The transaction-family requirement still holds at the same HTTP surface. @@ -206,4 +217,47 @@ describe("monerium b2b account read surface", () => { }); expect(legacyWithoutTarget.status).toBe(400); }); + + it("lists the manager's onramp accounts and finds one by Monerium profile ID", async () => { + const { delegatedHeaders, managerHeaders, mapped } = await setupMappedChild(); + + const all = await jsonRequest("/v1/monerium-b2b/accounts", managerHeaders); + expect(all.status).toBe(200); + expect(all.body).toMatchObject({ + accounts: [ + { + accountId: mapped.accountId, + externalSubjectId: "client-1", + iban: null, + moneriumProfileId: MONERIUM_PROFILE, + profileId: mapped.profileId, + status: "onboarding" + } + ], + pagination: { total: 1 } + }); + + const byProfile = await jsonRequest(`/v1/monerium-b2b/accounts?moneriumProfileId=${MONERIUM_PROFILE}`, managerHeaders); + expect((byProfile.body.accounts as unknown[]).length).toBe(1); + const other = await jsonRequest(`/v1/monerium-b2b/accounts?moneriumProfileId=${crypto.randomUUID()}`, managerHeaders); + expect((other.body.accounts as unknown[]).length).toBe(0); + expect((await jsonRequest("/v1/monerium-b2b/accounts?moneriumProfileId=nope", managerHeaders)).status).toBe(400); + + // Manager key only: no delegation header, and a foreign manager sees nothing. + expect((await jsonRequest("/v1/monerium-b2b/accounts", delegatedHeaders)).status).toBe(400); + 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/accounts", { "X-API-Key": strangerCredential.plaintextKey }); + expect(foreign.body).toMatchObject({ accounts: [], pagination: { total: 0 } }); + + const notAManager = await createTestUser(); + const plainCredential = await createTestApiKey({ userId: notAManager.id }); + expect((await jsonRequest("/v1/monerium-b2b/accounts", { "X-API-Key": plainCredential.plaintextKey })).status).toBe(403); + }); }); diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index 0d7517c65..66975b7b7 100644 --- a/contracts/monerium-forwarder/README.md +++ b/contracts/monerium-forwarder/README.md @@ -2,11 +2,16 @@ 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. +Monerium link message from the Vortex attestor, a conversion policy that swaps over a +factory-whitelisted Uniswap v3 route and settles the fill against a partner reference +rate (fee above the target, top-up from the shared `VortexSubsidyVault` below the floor, +Chainlink floor on the client's net), whole-payment forwarding (chunks accumulate as USDC on the +clone and leave in one `forward`), and a keeper-only, delay-gated `recover` to the client's refund +wallet, fixed per clone at deployment (`recoveryAddress`), for bank refunds. - Spec: [docs/architecture-monerium-b2b-onramp.md](../../docs/architecture-monerium-b2b-onramp.md) §2 -- Parameter values (slippage, delays, caps, fee) are decided in + and its "Fees, reference rate and subsidy" section +- Parameter values (floor, delays, caps, fee policy, reference band, vault limits) 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. @@ -41,6 +46,5 @@ node. Published manifests live in `manifests/`. 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). +block explorer. The per-clone destination has no setter, so any change to it is reported as +a failure like every other immutable; guardian-tunable parameters are notices. diff --git a/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json b/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json deleted file mode 100644 index 713fbbeef..000000000 --- a/contracts/monerium-forwarder/manifests/11155111-0xcBE354e847bF597513148918E7EbDff72aC75842.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "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/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts index da1707f7f..cd1b43717 100644 --- a/contracts/monerium-forwarder/script/manifest-core.ts +++ b/contracts/monerium-forwarder/script/manifest-core.ts @@ -11,7 +11,7 @@ import { Address, getAddress, Hex, keccak256, PublicClient, parseAbi, parseAbiIt * source on a block explorer. */ -export const MANIFEST_VERSION = 2; +export const MANIFEST_VERSION = 5; export const MANIFEST_PURPOSE = "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). " + @@ -30,17 +30,21 @@ export const factoryAbi = parseAbi([ "function globalPaused() view returns (bool)", "function minSwapAmount() view returns (uint256)", "function perSwapCap() view returns (uint256)", - "function isForwarder(address forwarder) view returns (bool)" + "function isForwarder(address forwarder) view returns (bool)", + "function subsidyVault() view returns (address)", + "function routeCount() view returns (uint256)", + "function route(uint256 index) view returns (bytes path, bool enabled)" ]); export const forwarderDeployedEvent = parseAbiItem( - "event ForwarderDeployed(address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt)" + "event ForwarderDeployed(address indexed forwarder, address indexed destination, address recoveryAddress, uint32 targetPpm, uint32 floorPpm, bytes32 salt)" ); export const forwarderConfigAbi = parseAbi([ "function destination() view returns (address)", - "function fallbackAddress() view returns (address)", - "function feeBps() view returns (uint16)" + "function recoveryAddress() view returns (address)", + "function targetPpm() view returns (uint32)", + "function floorPpm() view returns (uint32)" ]); export const implementationAbi = parseAbi([ @@ -55,11 +59,10 @@ export const implementationAbi = parseAbi([ "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 MAX_FEE_PPM() view returns (uint32)", + "function MAX_REFERENCE_DEVIATION_BPS() view returns (uint16)", + "function RECOVERY_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)" @@ -76,43 +79,40 @@ export interface ImplementationImmutables { FEE_RECIPIENT: string; LINK_HASH_191: Hex; LINK_MESSAGE: string; - MAX_FEE_BPS: number; + MAX_FEE_PPM: number; MAX_ORACLE_AGE: string; + MAX_REFERENCE_DEVIATION_BPS: number; ORACLE: string; ORACLE_DECIMALS: number; - POOL_FEE_EURC_USDC: number; - POOL_FEE_EURE_EURC: number; + RECOVERY_DELAY: string; 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. */ + /** Guardian-adjustable under the contract's bounded, timelocked fee policy (ppm below the reference). */ guardianMutable: { - feeBps: number; + floorPpm: number; + targetPpm: number; }; - /** Factory registration is fixed for the lifetime of the clone. Mismatch = incident. */ + /** + * Fixed for the lifetime of the clone: the destination and the client's refund wallet + * (`recoveryAddress`) have no setter (a change means a new clone, runbook §5) and + * factory registration never changes. Mismatch = incident. + */ immutables: { + destination: string; isForwarder: boolean; + recoveryAddress: string; }; /** keccak256 of the clone's runtime code; must equal the EIP-1167 code for `implementation.address`. */ runtimeBytecodeHash: Hex; @@ -129,14 +129,16 @@ export interface CoreState { }; /** * Guardian-tunable within the immutable bounds (registry P6/P7) plus role/pause - * state. Drift here is legitimate operation: the verifier reports NOTICE, not - * failure. + * state, the subsidy vault and the on-chain validated route whitelist. Drift here + * is legitimate operation: the verifier reports NOTICE, not failure. */ operational: { globalPaused: boolean; guardian: string; minSwapAmount: string; perSwapCap: string; + routes: { enabled: boolean; path: Hex }[]; + subsidyVault: string; }; runtimeBytecodeHash: Hex; }; @@ -286,15 +288,35 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres const factory = getAddress(factoryAddress); const chainId = await client.getChainId(); - const [implementation, minSwapFloor, capCeiling, guardian, globalPaused, minSwapAmount, perSwapCap] = await Promise.all([ + const [ + implementation, + minSwapFloor, + capCeiling, + guardian, + globalPaused, + minSwapAmount, + perSwapCap, + subsidyVault, + routeCount + ] = 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") + read(client, factoryAbi, factory, "perSwapCap"), + read
(client, factoryAbi, factory, "subsidyVault"), + read(client, factoryAbi, factory, "routeCount") ]); + const routes = await Promise.all( + Array.from({ length: Number(routeCount) }, (_, index) => + read<[Hex, boolean]>(client, factoryAbi, factory, "route", [BigInt(index)]).then(([path, enabled]) => ({ + enabled, + path + })) + ) + ); const [ eure, @@ -308,11 +330,10 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres feeRecipient, maxOracleAge, slippageBps, - maxFeeBps, - sweepDelay, + maxFeePpm, + maxReferenceDeviationBps, + recoveryDelay, triggerDelay, - poolFeeEureEurc, - poolFeeEurcUsdc, linkHash191, recoveryHash, linkMessage @@ -328,11 +349,10 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres 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, "MAX_FEE_PPM"), + read(client, implementationAbi, implementation, "MAX_REFERENCE_DEVIATION_BPS"), + read(client, implementationAbi, implementation, "RECOVERY_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") @@ -351,7 +371,9 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres globalPaused, guardian: getAddress(guardian), minSwapAmount: minSwapAmount.toString(), - perSwapCap: perSwapCap.toString() + perSwapCap: perSwapCap.toString(), + routes, + subsidyVault: subsidyVault === "0x0000000000000000000000000000000000000000" ? subsidyVault : getAddress(subsidyVault) }, runtimeBytecodeHash: await codeHash(client, factory) }, @@ -365,16 +387,15 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres FEE_RECIPIENT: getAddress(feeRecipient), LINK_HASH_191: linkHash191, LINK_MESSAGE: linkMessage, - MAX_FEE_BPS: Number(maxFeeBps), + MAX_FEE_PPM: Number(maxFeePpm), MAX_ORACLE_AGE: maxOracleAge.toString(), + MAX_REFERENCE_DEVIATION_BPS: Number(maxReferenceDeviationBps), ORACLE: getAddress(oracle), ORACLE_DECIMALS: Number(oracleDecimals), - POOL_FEE_EURC_USDC: Number(poolFeeEurcUsdc), - POOL_FEE_EURE_EURC: Number(poolFeeEureEurc), + RECOVERY_DELAY: recoveryDelay.toString(), RECOVERY_HASH: recoveryHash, ROUTER: getAddress(router), SLIPPAGE_BPS: Number(slippageBps), - SWEEP_DELAY: sweepDelay.toString(), TRIGGER_DELAY: triggerDelay.toString(), USDC: getAddress(usdc) }, @@ -392,29 +413,29 @@ export async function readForwarderEntry( ): Promise { const factory = getAddress(factoryAddress); const forwarder = getAddress(forwarderAddress); - const [destination, fallbackAddress, feeBps, isForwarder, forwarderCodeHash] = await Promise.all([ + const [destination, recoveryAddress, targetPpm, floorPpm, isForwarder, forwarderCodeHash] = await Promise.all([ read
(client, forwarderConfigAbi, forwarder, "destination"), - read
(client, forwarderConfigAbi, forwarder, "fallbackAddress"), - read(client, forwarderConfigAbi, forwarder, "feeBps"), + read
(client, forwarderConfigAbi, forwarder, "recoveryAddress"), + read(client, forwarderConfigAbi, forwarder, "targetPpm"), + read(client, forwarderConfigAbi, forwarder, "floorPpm"), 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) + floorPpm: Number(floorPpm), + targetPpm: Number(targetPpm) }, immutables: { - isForwarder + destination: getAddress(destination), + isForwarder, + recoveryAddress: getAddress(recoveryAddress) }, runtimeBytecodeHash: forwarderCodeHash }; diff --git a/contracts/monerium-forwarder/script/verify-manifest.test.ts b/contracts/monerium-forwarder/script/verify-manifest.test.ts index 420901354..6d354c8b3 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.test.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.test.ts @@ -1,14 +1,40 @@ import { describe, expect, it } from "bun:test"; -import { severityFor } from "./verify-manifest"; +import { Diff, diffSection, severityFor } from "./verify-manifest"; describe("manifest diff severity", () => { - it("treats guardian fee changes as notices", () => { - expect(severityFor("forwarders.0x123.guardianMutable.feeBps")).toBe("NOTICE"); + it("treats guardian fee policy, vault and route changes as notices", () => { + expect(severityFor("forwarders.0x123.guardianMutable.targetPpm")).toBe("NOTICE"); + expect(severityFor("forwarders.0x123.guardianMutable.floorPpm")).toBe("NOTICE"); + expect(severityFor("factory.operational.subsidyVault")).toBe("NOTICE"); + expect(severityFor("factory.operational.routes.0.enabled")).toBe("NOTICE"); }); - it("keeps client changes expected and immutable changes fatal", () => { - expect(severityFor("forwarders.0x123.clientMutable.destination")).toBe("EXPECTED-TRANSITION"); + it("treats the per-clone destination and registration as immutable", () => { + expect(severityFor("forwarders.0x123.immutables.destination")).toBe("FAIL"); + expect(severityFor("forwarders.0x123.immutables.recoveryAddress")).toBe("FAIL"); expect(severityFor("forwarders.0x123.immutables.isForwarder")).toBe("FAIL"); expect(severityFor("forwarders.0x123.runtimeBytecodeHash")).toBe("FAIL"); }); }); + +describe("manifest section diff", () => { + const routes = (enabled: boolean) => ({ routes: [{ enabled: true, path: "0xaa" }, { enabled, path: "0xbb" }] }); + + it("walks route arrays by index so a same-length toggle or path change is visible", () => { + const diffs: Diff[] = []; + diffSection("factory.operational", routes(true), routes(false), diffs); + expect(diffs).toEqual([ + { actual: "false", expected: "true", path: "factory.operational.routes.1.enabled", severity: "NOTICE" } + ]); + }); + + it("reports an added route as a missing manifest entry", () => { + const diffs: Diff[] = []; + diffSection("factory.operational", { routes: [] }, { routes: [{ enabled: true, path: "0xaa" }] }, diffs); + expect(diffs.map(diff => diff.path).sort()).toEqual([ + "factory.operational.routes.0.enabled", + "factory.operational.routes.0.path" + ]); + expect(diffs.every(diff => diff.expected === "" && diff.severity === "NOTICE")).toBe(true); + }); +}); diff --git a/contracts/monerium-forwarder/script/verify-manifest.ts b/contracts/monerium-forwarder/script/verify-manifest.ts index b08b84993..271b22319 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.ts @@ -34,19 +34,15 @@ import { * 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 + * FAIL immutable/bytecode/deploy-provenance mismatch (the per-clone destination + * is immutable too: it has no setter) -> exit 1 + * 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"; +export type Severity = "FAIL" | "NOTICE"; -interface Diff { +export interface Diff { actual: string; expected: string; path: string; @@ -54,7 +50,13 @@ interface Diff { } function flatten(value: unknown, prefix: string, out: Map): void { - if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (Array.isArray(value)) { + // Arrays (the route whitelist) are walked by index: String([...]) would collapse + // every entry to "[object Object]" and hide a same-length content change. + value.forEach((child, index) => flatten(child, `${prefix}.${index}`, out)); + return; + } + if (value !== null && typeof value === "object") { for (const [key, child] of Object.entries(value)) { flatten(child, prefix ? `${prefix}.${key}` : key, out); } @@ -64,13 +66,12 @@ function flatten(value: unknown, prefix: string, out: Map): void } 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 { +export function diffSection(path: string, expected: unknown, actual: unknown, diffs: Diff[]): void { const expectedFlat = new Map(); const actualFlat = new Map(); flatten(expected, path, expectedFlat); @@ -186,16 +187,15 @@ async function main(): Promise { } 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)`); + console.log(`VERIFICATION FAILED: ${failures} mismatch(es), ${notices} notice(s)`); process.exit(1); } - if (transitions > 0 || notices > 0) { + if (notices > 0) { console.log( - `VERIFICATION PASSED with ${transitions} owner-authorized transition(s) and ${notices} notice(s) — ` + + `VERIFICATION PASSED with ${notices} notice(s) — ` + "regenerate and republish the manifest to fold them in (consistency evidence only, NOT a trust root — R01)" ); return; diff --git a/contracts/monerium-forwarder/src/VortexForwarder.sol b/contracts/monerium-forwarder/src/VortexForwarder.sol index 88689bbba..f7e8e6eec 100644 --- a/contracts/monerium-forwarder/src/VortexForwarder.sol +++ b/contracts/monerium-forwarder/src/VortexForwarder.sol @@ -36,6 +36,13 @@ interface IVortexForwarderFactory { function minSwapAmount() external view returns (uint256); function perSwapCap() external view returns (uint256); function MIN_SWAP_FLOOR() external view returns (uint256); + function isForwarder(address account) external view returns (bool); + function subsidyVault() external view returns (address); + function route(uint256 index) external view returns (bytes memory path, bool enabled); +} + +interface IVortexSubsidyVault { + function pay(uint256 amount, uint256 referenceOut) external; } /// @title VortexForwarder @@ -44,11 +51,15 @@ interface IVortexForwarderFactory { /// 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. +/// 1. a factory-whitelisted EURe -> USDC swap (oracle-floored, output kept here), +/// 2. USDC to the client's `destination` (plus a fee <= MAX_FEE_PPM to FEE_RECIPIENT), +/// 3. EURe and USDC to this client's fixed `recoveryAddress`, only by the keeper and +/// only once a batch has been open for RECOVERY_DELAY (the refund path). +/// The keeper converts a bank payment in `swap` chunks that accumulate as USDC on +/// the clone and pushes the whole payment to `destination` with one `forward`, so +/// the client sees one USDC transfer per pay-in. Vortex (guardian/keeper) can +/// execute that policy, pause it, recover a stuck payment to the client's refund +/// wallet for a bank refund, 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). @@ -58,6 +69,7 @@ contract VortexForwarder { bytes4 private constant EIP1271_MAGIC = 0x1626ba7e; bytes4 private constant EIP1271_FAIL = 0xffffffff; uint16 private constant BPS = 10_000; + uint32 private constant PPM = 1_000_000; string public constant LINK_MESSAGE = "I hereby declare that I am the address owner."; @@ -74,16 +86,19 @@ contract VortexForwarder { 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 + uint16 public immutable SLIPPAGE_BPS; // registry P1: floor on the client's NET, after fee and subsidy + uint32 public immutable MAX_FEE_PPM; // registry P2: caps both the fee and the floor policy + /// @dev How far a keeper-supplied reference may sit from Chainlink. Bounds the keeper's + /// pricing power: a wrong reference can move fee/subsidy only inside this band, and + /// MAX_FEE_PPM plus the vault's caps bound it further. + uint16 public immutable MAX_REFERENCE_DEVIATION_BPS; + /// @dev Registry P3: how long a batch must have been open before the keeper may move + /// it to `recoveryAddress` — the promised conversion window, enforced on chain. + uint256 public immutable RECOVERY_DELAY; 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. + /// @dev EIP-191 personal-message hash of LINK_MESSAGE, the only hash the attestor's + /// signature is accepted for (G0 sandbox validation confirmed Monerium presents it). bytes32 public immutable LINK_HASH_191; /// @dev Monerium issuer-recovery message hash (registry T1). bytes32(0) = disabled. @@ -102,11 +117,10 @@ contract VortexForwarder { address feeRecipient; uint256 maxOracleAge; uint16 slippageBps; - uint16 maxFeeBps; - uint256 sweepDelay; + uint32 maxFeePpm; + uint16 maxReferenceDeviationBps; + uint256 recoveryDelay; uint256 triggerDelay; - uint24 poolFeeEureEurc; - uint24 poolFeeEurcUsdc; bytes32 recoveryHash; } @@ -115,60 +129,84 @@ contract VortexForwarder { 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. + /// @dev The only address a recovery can move funds to: this client's Vortex-held refund + /// wallet, linked to the client's Monerium profile, from which the bank refund is + /// redeemed out of the client's own IBAN. Fixed at deployment, no setter. + address public recoveryAddress; + /// @dev Fee policy, in ppm below the reference rate. The client is targeted at + /// reference x (1 - targetPpm): any fill above that becomes fee (<= MAX_FEE_PPM); + /// a fill below reference x (1 - floorPpm) is topped up from the subsidy vault. + /// Guardian-adjustable; increases (worse for the client) are timelocked (P11). + uint32 public targetPpm; + uint32 public floorPpm; + + /// @dev P11 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; + uint32 public pendingTargetPpm; + uint32 public pendingFloorPpm; + uint64 public pendingFeePolicyEffectiveAt; - bool public clientPaused; // set by fallbackAddress only - bool public guardianPaused; // set by guardian only (protective-only; cannot block fallback paths) + bool public guardianPaused; // set by guardian only (protective-only; never blocks recovery) - /// @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; + /// @dev When the current batch opened: the first time funds (EURe >= MIN_SWAP_FLOOR + /// or any USDC) were seen on the clone since it was last emptied by a forward or + /// a recovery. Start time for RECOVERY_DELAY and TRIGGER_DELAY. A partial swap + /// never re-times it, so chunking cannot restart the recovery clock. + uint64 public batchOpenedAt; 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 Initialized(address destination, address recoveryAddress, uint32 targetPpm, uint32 floorPpm); + event FeePolicyDecreased(uint32 previousTarget, uint32 previousFloor, uint32 target, uint32 floor); + event FeePolicyIncreaseAnnounced( + uint32 currentTarget, uint32 currentFloor, uint32 pendingTarget, uint32 pendingFloor, uint64 effectiveAt + ); + event FeePolicyIncreaseApplied(uint32 previousTarget, uint32 previousFloor, uint32 target, uint32 floor); + event FeePolicyIncreaseCancelled(uint32 pendingTarget, uint32 pendingFloor); + event Poked(uint64 batchOpenedAt); + /// @param referenceRate The rate the fee bands were computed against (keeper-supplied + /// for privileged swaps, Chainlink for permissionless ones), ORACLE_DECIMALS. + /// @param subsidy USDC the vault paid to this clone on top of `usdcOut`. + event SwapExecuted( + address indexed caller, + uint256 routeIndex, + uint256 eureIn, + uint256 usdcOut, + uint256 referenceRate, + uint256 fee, + uint256 subsidy + ); + event Forwarded(address indexed caller, uint256 amount); + event Recovered(address indexed caller, uint256 eureAmount, uint256 usdcAmount); event GuardianPausedSet(bool paused); - event TokenSwept(address indexed token, address indexed to, uint256 amount); // ----------------------------------------------------------------- errors error AlreadyInitialized(); error NotFactory(); - error NotFallbackAddress(); error NotGuardian(); + error NotKeeper(); error NotAuthorizedYet(); error Paused(); error ZeroAddress(); error InvalidConfigAddress(); - error FeeTooHigh(); + error InvalidFeePolicy(); error BelowMinimum(); + error InvalidAmount(); error StalePrice(); error InvalidPrice(); error InsufficientOutput(); error Overspend(); - error NotStranded(); - error NoPendingFee(); + error NoPendingFeePolicy(); + error ReferenceOutOfBand(); + error SubsidyUnavailable(); + error SubsidyAboveCap(); error DelayNotElapsed(); error TransferFailed(); error Reentrancy(); + error InvalidRoute(); // ------------------------------------------------------------ constructor @@ -184,11 +222,10 @@ contract VortexForwarder { FEE_RECIPIENT = cfg.feeRecipient; MAX_ORACLE_AGE = cfg.maxOracleAge; SLIPPAGE_BPS = cfg.slippageBps; - MAX_FEE_BPS = cfg.maxFeeBps; - SWEEP_DELAY = cfg.sweepDelay; + MAX_FEE_PPM = cfg.maxFeePpm; + MAX_REFERENCE_DEVIATION_BPS = cfg.maxReferenceDeviationBps; + RECOVERY_DELAY = cfg.recoveryDelay; 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)); @@ -206,31 +243,40 @@ contract VortexForwarder { _reentrancyGuard = 0; } - modifier onlyFallback() { - if (msg.sender != fallbackAddress) revert NotFallbackAddress(); + modifier onlyGuardian() { + if (msg.sender != FACTORY.guardian()) revert NotGuardian(); _; } - modifier onlyGuardian() { - if (msg.sender != FACTORY.guardian()) revert NotGuardian(); + modifier onlyKeeper() { + if (!_privileged()) revert NotKeeper(); + _; + } + + modifier whenNotPaused() { + if (guardianPaused || FACTORY.globalPaused()) revert Paused(); _; } // ---------------------------------------------------------- initialization /// @notice Called by the factory in the same transaction as clone deployment. - function initialize(address destination_, address fallbackAddress_, uint16 feeBps_) external { + function initialize(address destination_, address recoveryAddress_, uint32 targetPpm_, uint32 floorPpm_) external { if (msg.sender != address(FACTORY)) revert NotFactory(); if (initialized) revert AlreadyInitialized(); + // A zero recovery address would make `recover` burn client funds; one equal to the + // destination would turn the refund path into an early, unpriced delivery. _validateConfigAddress(destination_); - _validateConfigAddress(fallbackAddress_); - if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh(); + _validateConfigAddress(recoveryAddress_); + if (recoveryAddress_ == destination_) revert InvalidConfigAddress(); + _validateFeePolicy(targetPpm_, floorPpm_); initialized = true; destination = destination_; - fallbackAddress = fallbackAddress_; - feeBps = feeBps_; - emit Initialized(destination_, fallbackAddress_, feeBps_); + recoveryAddress = recoveryAddress_; + targetPpm = targetPpm_; + floorPpm = floorPpm_; + emit Initialized(destination_, recoveryAddress_, targetPpm_, floorPpm_); } // -------------------------------------------------------------- EIP-1271 @@ -269,196 +315,302 @@ contract VortexForwarder { return EIP1271_MAGIC; } - // ------------------------------------------------------------ stranding marker (R03) + // ------------------------------------------------------------ batch marker - /// @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. + /// @notice Permissionless. Opens the batch marker when funds are present and it is + /// not armed yet (start time for RECOVERY_DELAY / TRIGGER_DELAY); clears it + /// when the clone is empty. Never re-times an armed marker. 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); + _syncBatch(false); + } + + /// @dev Armed against the IMMUTABLE swap floor, not the guardian-tunable minSwapAmount, + /// so no guardian action can keep a funded batch from being timed (review r1 F1). + /// `reset` re-times the marker for whatever remains after a forward or a + /// recovery closed the previous batch; otherwise an armed marker is left alone so + /// a partial swap can never restart the recovery clock. + function _syncBatch(bool reset) internal { + bool funded = EURE.balanceOf(address(this)) >= FACTORY.MIN_SWAP_FLOOR() || USDC.balanceOf(address(this)) > 0; + uint64 next = 0; + if (funded) { + next = (reset || batchOpenedAt == 0) ? uint64(block.timestamp) : batchOpenedAt; + } + if (next != batchOpenedAt) { + batchOpenedAt = next; + emit Poked(next); } } // ------------------------------------------------------------------ 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(); + /// @notice Convert `amountIn` EURe held by this contract to USDC, which stays on the + /// clone until `forward`. Callable by guardian/keepers any time; by anyone once + /// the batch marker is older than TRIGGER_DELAY (liveness fallback). + /// @param referenceRate The partner-agreed reference (EUR/USD, ORACLE_DECIMALS) the + /// fee bands are priced against. A privileged caller must supply one within + /// MAX_REFERENCE_DEVIATION_BPS of Chainlink; a permissionless caller's value is + /// ignored and Chainlink is used, and no subsidy is paid on that path — the + /// rate guarantee applies to keeper-executed swaps. + /// @param routeIndex Which factory-whitelisted route to execute. The keeper quotes + /// every enabled route off-chain and picks the best; a poor pick only ever + /// costs Vortex (more subsidy, less fee), never the client, whose outcome is + /// bounded by the oracle floor whichever route runs. + /// @param amountIn Exactly how much EURe to convert: at least minSwapAmount, at most + /// perSwapCap and the balance. Explicit so the keeper's chunking maps every + /// swap to one bank payment. + /// @param maxSubsidy The most USDC the caller lets the vault pay for this swap: the + /// keeper's escalation tier for the time the chunk has waited (docs, fees + /// section). Binding at execution, so a fill that moved between the quote and + /// the swap cannot draw more than the tier; the vault's own cap and budget + /// still apply on top. Ignored on the permissionless path, which pays nothing. + function swap(uint256 referenceRate, uint256 routeIndex, uint256 amountIn, uint256 maxSubsidy) + external + nonReentrant + whenNotPaused + { + bool privileged = _privileged(); + if (!privileged && !_batchOlderThan(TRIGGER_DELAY)) revert NotAuthorizedYet(); + + if (amountIn < FACTORY.minSwapAmount()) revert BelowMinimum(); + if (amountIn > FACTORY.perSwapCap() || amountIn > EURE.balanceOf(address(this))) revert InvalidAmount(); + + uint256 oraclePrice = _oraclePrice(); + uint256 referenceUsed = privileged ? _checkedReference(referenceRate, oraclePrice) : oraclePrice; + uint256 oracleFloor = _floorOut(amountIn, oraclePrice); + + uint256 usdcReceived = _swap(routeIndex, amountIn); + (uint256 fee, uint256 subsidy) = + _settle(amountIn, usdcReceived, referenceUsed, oracleFloor, privileged, maxSubsidy); + + // The oracle floor is enforced on the client's NET (fill - fee + subsidy), not on + // the raw fill. A privileged swap is settled to at least this floor by `_settle` + // (fee first, then the tier-bounded subsidy) or reverts there; the permissionless + // path pays no subsidy, so its fill must clear the floor on its own. Reverting + // undoes the swap and any subsidy transfer alike. + if (usdcReceived - fee + subsidy < oracleFloor) revert InsufficientOutput(); + + _syncBatch(false); + emit SwapExecuted(msg.sender, routeIndex, amountIn, usdcReceived, referenceUsed, fee, subsidy); + } - 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(); - } + /// @dev Executes the whitelisted route and returns the USDC received. The router + /// minimum is deliberately 0: the router cannot see the fee and subsidy that + /// determine the client's net, so the floor is enforced by `swap` after + /// settlement instead, and a failing floor reverts the whole call. + function _swap(uint256 routeIndex, uint256 amountIn) internal returns (uint256 usdcReceived) { + (bytes memory path, bool routeEnabled) = FACTORY.route(routeIndex); + if (!routeEnabled) revert InvalidRoute(); 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 + path: path, recipient: address(this), amountIn: amountIn, amountOutMinimum: 0 }) ); _approve(EURE, address(ROUTER), 0); - uint256 usdcReceived = USDC.balanceOf(address(this)) - usdcBefore; - if (usdcReceived < minOut) revert InsufficientOutput(); + usdcReceived = USDC.balanceOf(address(this)) - usdcBefore; 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); + /// @dev Applies the fee bands (docs/architecture-monerium-b2b-onramp.md, "Fees, reference rate and subsidy"), + /// with the Chainlink floor `oracleFloor` as a lower bound on both the target and the + /// floor, so that a reference sitting far below a stale Chainlink round costs Vortex + /// fee and subsidy instead of stopping the swap (amendment 2026-09-18): + /// - fill above max(reference x (1 - targetPpm), oracleFloor): the surplus is the + /// fee, <= MAX_FEE_PPM — the fee gives way before the client drops under the floor; + /// - fill between the floor and the target: no fee, no subsidy; + /// - fill below max(reference x (1 - floorPpm), oracleFloor): a privileged swap draws + /// the shortfall from the vault onto this clone; a permissionless swap pays nothing. + /// The caller's `maxSubsidy` bounds the shortfall first; the vault reverts (and so + /// does the swap) when its cap, budget, pause or balance cannot cover it, and the + /// forwarder reverts unless exactly the shortfall arrived here — a swap is never + /// partially subsidized. A depeg beyond what the tier and the vault cover still + /// reverts. + function _settle( + uint256 amountIn, + uint256 usdcReceived, + uint256 referenceUsed, + uint256 oracleFloor, + bool privileged, + uint256 maxSubsidy + ) internal returns (uint256 fee, uint256 subsidy) { + uint256 referenceOut = _usdcValue(amountIn, referenceUsed); + uint256 targetOut = (referenceOut * (PPM - targetPpm)) / PPM; + if (targetOut < oracleFloor) targetOut = oracleFloor; + if (usdcReceived > targetOut) { + fee = usdcReceived - targetOut; + uint256 maxFee = (usdcReceived * MAX_FEE_PPM) / PPM; + if (fee > maxFee) fee = maxFee; + _transfer(USDC, FEE_RECIPIENT, fee); + return (fee, 0); } - - // 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); + uint256 floorOut = (referenceOut * (PPM - floorPpm)) / PPM; + if (floorOut < oracleFloor) floorOut = oracleFloor; + if (usdcReceived >= floorOut || !privileged) return (0, 0); + + subsidy = floorOut - usdcReceived; + if (subsidy > maxSubsidy) revert SubsidyAboveCap(); + address vault = FACTORY.subsidyVault(); + if (vault == address(0)) revert SubsidyUnavailable(); + // The vault is guardian-settable without a timelock, so its word is not enough: + // count the subsidy only once exactly that amount has landed here. + uint256 before = USDC.balanceOf(address(this)); + IVortexSubsidyVault(vault).pay(subsidy, referenceOut); + if (USDC.balanceOf(address(this)) - before != subsidy) revert SubsidyUnavailable(); + return (0, subsidy); } - /// @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) { + /// @dev Validated Chainlink EUR/USD price (registry P8 staleness ceiling). + function _oraclePrice() 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; + return uint256(answer); } - // -------------------------------------------------------------- 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); + /// @dev A keeper-supplied reference must lie within MAX_REFERENCE_DEVIATION_BPS of Chainlink. + function _checkedReference(uint256 supplied, uint256 oraclePrice) internal view returns (uint256) { + uint256 tolerance = (oraclePrice * MAX_REFERENCE_DEVIATION_BPS) / BPS; + if (supplied + tolerance < oraclePrice || supplied > oraclePrice + tolerance) revert ReferenceOutOfBand(); + return supplied; } - // ------------------------------------------------------- client (fallback) authority + /// @dev amountIn (EURe, 18 dec) x price (ORACLE_DECIMALS) rescaled to USDC (6 dec). + /// Scale denominator: 10^(18 + oracleDecimals - 6). Floor rounding: error < 1 + /// unit of USDC. Assumes USDC/USD = 1 within SLIPPAGE_BPS (assumption A4). + function _usdcValue(uint256 amountIn, uint256 price) internal view returns (uint256) { + return (amountIn * price) / (10 ** (12 + uint256(ORACLE_DECIMALS))); + } - function setDestination(address destination_) external onlyFallback { - _validateConfigAddress(destination_); - emit DestinationUpdated(destination, destination_); - destination = destination_; + /// @dev The least the client may end up with: Chainlink value x (1 - SLIPPAGE_BPS). + function _floorOut(uint256 amountIn, uint256 oraclePrice) internal view returns (uint256) { + return (_usdcValue(amountIn, oraclePrice) * (BPS - SLIPPAGE_BPS)) / BPS; } - function setFallbackAddress(address fallbackAddress_) external onlyFallback { - _validateConfigAddress(fallbackAddress_); - emit FallbackAddressUpdated(fallbackAddress, fallbackAddress_); - fallbackAddress = fallbackAddress_; + // --------------------------------------------------------------- forward + + /// @notice Pushes `amount` of the accumulated USDC to `destination`: the keeper calls + /// this once with the whole converted bank payment, so the client sees one + /// transfer per pay-in. Closes the batch marker when nothing remains. + function forward(uint256 amount) external nonReentrant whenNotPaused onlyKeeper { + if (amount == 0 || amount > USDC.balanceOf(address(this))) revert InvalidAmount(); + _transfer(USDC, destination, amount); + _syncBatch(true); + emit Forwarded(msg.sender, amount); } - function setClientPaused(bool paused) external onlyFallback { - clientPaused = paused; - emit ClientPausedSet(paused); + /// @notice Pushes the whole USDC balance to `destination`. Keeper any time (also the + /// home for unsolicited USDC, R09); anyone once the batch marker is older than + /// TRIGGER_DELAY, so a Vortex outage can never trap converted funds on chain. + /// Batches may merge on that path — the per-payment mapping is the keeper's. + function forwardAll() external nonReentrant whenNotPaused { + if (!_privileged() && !_batchOlderThan(TRIGGER_DELAY)) revert NotAuthorizedYet(); + uint256 amount = USDC.balanceOf(address(this)); + if (amount == 0) revert InvalidAmount(); + _transfer(USDC, destination, amount); + _syncBatch(true); + emit Forwarded(msg.sender, amount); } - /// @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); + // -------------------------------------------------------------- recovery + + /// @notice Moves a stuck bank payment — its unconverted EURe and its chunk-swapped + /// USDC — to `recoveryAddress` so Vortex can refund the exact EUR amount to the + /// payer's bank account (docs/architecture-monerium-b2b-onramp.md, recovery). + /// Keeper/guardian only, and only once the batch has been open for + /// RECOVERY_DELAY: the contract, not the keeper, enforces the promised window. + /// Deliberately NOT gated on pause flags: pause-then-recover is the incident + /// sequence. Amounts are explicit because a younger payment may share the clone. + function recover(uint256 eureAmount, uint256 usdcAmount) external nonReentrant onlyKeeper { + if (!_batchOlderThan(RECOVERY_DELAY)) revert DelayNotElapsed(); + if (eureAmount == 0 && usdcAmount == 0) revert InvalidAmount(); + if (eureAmount > EURE.balanceOf(address(this)) || usdcAmount > USDC.balanceOf(address(this))) { + revert InvalidAmount(); } - emit TokenSwept(token, to, balance); + _transfer(EURE, recoveryAddress, eureAmount); + _transfer(USDC, recoveryAddress, usdcAmount); + _syncBatch(true); + emit Recovered(msg.sender, eureAmount, usdcAmount); } // ----------------------------------------------------------- guardian authority - /// @notice Protective-only: blocks swaps (compliance holds, dormancy gate — R05). - /// Cannot move funds, change config, or block fallback paths. + /// @notice Protective-only: blocks swaps and forwards (compliance holds, dormancy gate — + /// R05). Cannot move funds, change config, or block a recovery. function setGuardianPaused(bool paused) external onlyGuardian { guardianPaused = paused; emit GuardianPausedSet(paused); } - /// @dev P11: fee increases take effect only this long after their on-chain + /// @dev P11: fee policy 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. + /// the current policy cannot be swapped under a silently worse 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; + /// @notice Guardian fee-policy adjustment (P11), always bounded by the immutable + /// MAX_FEE_PPM. A change that raises neither value (or re-states the current + /// ones) applies immediately and cancels any pending increase; a change that + /// raises either value is announced and becomes applicable only after + /// FEE_INCREASE_TIMELOCK. Announcing again replaces the pending pair and + /// restarts its clock. + function setFeePolicy(uint32 newTargetPpm, uint32 newFloorPpm) external onlyGuardian { + _validateFeePolicy(newTargetPpm, newFloorPpm); + if (newTargetPpm <= targetPpm && newFloorPpm <= floorPpm) { + if (pendingFeePolicyEffectiveAt != 0) { + emit FeePolicyIncreaseCancelled(pendingTargetPpm, pendingFloorPpm); + pendingTargetPpm = 0; + pendingFloorPpm = 0; + pendingFeePolicyEffectiveAt = 0; } - if (newFeeBps != feeBps) { - emit FeeBpsDecreased(feeBps, newFeeBps); - feeBps = newFeeBps; + if (newTargetPpm != targetPpm || newFloorPpm != floorPpm) { + emit FeePolicyDecreased(targetPpm, floorPpm, newTargetPpm, newFloorPpm); + targetPpm = newTargetPpm; + floorPpm = newFloorPpm; } } else { - pendingFeeBps = newFeeBps; - pendingFeeBpsEffectiveAt = uint64(block.timestamp + FEE_INCREASE_TIMELOCK); - emit FeeBpsIncreaseAnnounced(feeBps, newFeeBps, pendingFeeBpsEffectiveAt); + pendingTargetPpm = newTargetPpm; + pendingFloorPpm = newFloorPpm; + pendingFeePolicyEffectiveAt = uint64(block.timestamp + FEE_INCREASE_TIMELOCK); + emit FeePolicyIncreaseAnnounced(targetPpm, floorPpm, newTargetPpm, newFloorPpm, pendingFeePolicyEffectiveAt); } } - /// @notice Applies an announced fee increase once its timelock has elapsed. + /// @notice Applies an announced fee-policy 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; + function applyFeePolicy() external { + if (pendingFeePolicyEffectiveAt == 0) revert NoPendingFeePolicy(); + if (block.timestamp < pendingFeePolicyEffectiveAt) revert DelayNotElapsed(); + emit FeePolicyIncreaseApplied(targetPpm, floorPpm, pendingTargetPpm, pendingFloorPpm); + targetPpm = pendingTargetPpm; + floorPpm = pendingFloorPpm; + pendingTargetPpm = 0; + pendingFloorPpm = 0; + pendingFeePolicyEffectiveAt = 0; } // ---------------------------------------------------------------- helpers + function _privileged() internal view returns (bool) { + return msg.sender == FACTORY.guardian() || FACTORY.isKeeper(msg.sender); + } + + /// @dev Whether the batch marker is armed and older than `delay`. + function _batchOlderThan(uint256 delay) internal view returns (bool) { + return batchOpenedAt != 0 && block.timestamp - batchOpenedAt >= delay; + } + + /// @dev The floor is the worse-for-the-client bound, so it may never sit above the + /// target, and both are capped by the immutable MAX_FEE_PPM. + function _validateFeePolicy(uint32 targetPpm_, uint32 floorPpm_) internal view { + if (targetPpm_ > floorPpm_ || floorPpm_ > MAX_FEE_PPM) revert InvalidFeePolicy(); + } + function _validateConfigAddress(address account) internal view { if (account == address(0)) revert ZeroAddress(); if ( diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol index 0e12fde06..65a33007e 100644 --- a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol +++ b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol @@ -10,6 +10,11 @@ import {VortexForwarder} from "./VortexForwarder.sol"; contract VortexForwarderFactory { address public immutable implementation; + /// @dev Route validation only ever admits paths between these three tokens. + address public immutable EURE; + address public immutable EURC; + address public immutable USDC; + /// @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; @@ -25,13 +30,39 @@ contract VortexForwarderFactory { mapping(address => bool) public isForwarder; + /// @notice Swap routes clones may execute (Uniswap V3 packed paths). Guardian-managed + /// without a timelock: every entry is validated to run only between EURe, EURC + /// and USDC on the implementation's immutable router, and the client's outcome + /// is bounded by the oracle floor whichever route is chosen. Entries are never + /// removed, only disabled, so an index stays stable for the keeper. + struct Route { + bytes path; + bool enabled; + } + + Route[] private _routes; + + /// @notice The VortexSubsidyVault clones draw from; address(0) disables subsidies. + /// Guardian-settable without a timelock: the vault only ever pays Vortex + /// money, and a clone counts a subsidy only after verifying that exactly the + /// shortfall reached its own destination, so a swap cannot be harmed by it. + address public subsidyVault; + event ForwarderDeployed( - address indexed forwarder, address indexed destination, address fallbackAddress, uint16 feeBps, bytes32 salt + address indexed forwarder, + address indexed destination, + address recoveryAddress, + uint32 targetPpm, + uint32 floorPpm, + bytes32 salt ); event KeeperSet(address indexed keeper, bool enabled); event GlobalPausedSet(bool paused); event MinSwapAmountSet(uint256 value); event PerSwapCapSet(uint256 value); + event SubsidyVaultSet(address indexed vault); + event RouteAdded(uint256 indexed index, bytes path); + event RouteEnabledSet(uint256 indexed index, bool enabled); event GuardianTransferStarted(address indexed current, address indexed pending); event GuardianTransferred(address indexed previous, address indexed current); @@ -39,6 +70,7 @@ contract VortexForwarderFactory { error NotPendingGuardian(); error OutOfBounds(); error CloneFailed(); + error InvalidRoute(); modifier onlyGuardian() { if (msg.sender != guardian) revert NotGuardian(); @@ -50,14 +82,19 @@ contract VortexForwarderFactory { uint256 minSwapFloor, uint256 capCeiling, uint256 initialMinSwapAmount, - uint256 initialPerSwapCap + uint256 initialPerSwapCap, + bytes memory initialRoute ) { guardian = msg.sender; implementation = address(new VortexForwarder(cfg)); + EURE = cfg.eure; + EURC = cfg.eurc; + USDC = cfg.usdc; MIN_SWAP_FLOOR = minSwapFloor; CAP_CEILING = capCeiling; _setMinSwapAmount(initialMinSwapAmount); _setPerSwapCap(initialPerSwapCap); + _addRoute(initialRoute); } // ------------------------------------------------------------- deployment @@ -65,15 +102,17 @@ contract VortexForwarderFactory { /// @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) - { + function deployForwarder( + address destination, + address recoveryAddress, + uint32 targetPpm, + uint32 floorPpm, + bytes32 salt + ) external onlyGuardian returns (address forwarder) { forwarder = _cloneDeterministic(implementation, salt); - VortexForwarder(forwarder).initialize(destination, fallbackAddress, feeBps); + VortexForwarder(forwarder).initialize(destination, recoveryAddress, targetPpm, floorPpm); isForwarder[forwarder] = true; - emit ForwarderDeployed(forwarder, destination, fallbackAddress, feeBps, salt); + emit ForwarderDeployed(forwarder, destination, recoveryAddress, targetPpm, floorPpm, salt); } function predictAddress(bytes32 salt) external view returns (address) { @@ -101,6 +140,33 @@ contract VortexForwarderFactory { _setPerSwapCap(value); } + function setSubsidyVault(address vault) external onlyGuardian { + subsidyVault = vault; + emit SubsidyVaultSet(vault); + } + + // ------------------------------------------------------------------ routes + + function addRoute(bytes calldata path) external onlyGuardian returns (uint256 index) { + return _addRoute(path); + } + + function setRouteEnabled(uint256 index, bool enabled) external onlyGuardian { + if (index >= _routes.length) revert InvalidRoute(); + _routes[index].enabled = enabled; + emit RouteEnabledSet(index, enabled); + } + + function routeCount() external view returns (uint256) { + return _routes.length; + } + + function route(uint256 index) external view returns (bytes memory path, bool enabled) { + if (index >= _routes.length) revert InvalidRoute(); + Route storage entry = _routes[index]; + return (entry.path, entry.enabled); + } + /// @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 { @@ -129,11 +195,50 @@ contract VortexForwarderFactory { emit PerSwapCapSet(value); } + /// @dev Admits only EURe -> USDC or EURe -> EURC -> USDC over Uniswap V3's four fee + /// tiers (packed path: token, fee, token[, fee, token]). Anything else, including + /// any other intermediate token, is rejected so a route can never introduce a + /// token the forwarder does not already trust. + function _addRoute(bytes memory path) internal returns (uint256 index) { + uint256 hops; + if (path.length == 43) hops = 1; + else if (path.length == 66) hops = 2; + else revert InvalidRoute(); + + if (_addressAt(path, 0) != EURE) revert InvalidRoute(); + if (_addressAt(path, path.length - 20) != USDC) revert InvalidRoute(); + if (hops == 2 && _addressAt(path, 23) != EURC) revert InvalidRoute(); + for (uint256 i = 0; i < hops; i++) { + if (!_isKnownFeeTier(_feeAt(path, 20 + i * 23))) revert InvalidRoute(); + } + + index = _routes.length; + _routes.push(Route({path: path, enabled: true})); + emit RouteAdded(index, path); + } + + function _isKnownFeeTier(uint24 fee) internal pure returns (bool) { + return fee == 100 || fee == 500 || fee == 3000 || fee == 10000; + } + + function _addressAt(bytes memory data, uint256 offset) internal pure returns (address value) { + // solhint-disable-next-line no-inline-assembly + assembly { + value := shr(96, mload(add(add(data, 32), offset))) + } + } + + function _feeAt(bytes memory data, uint256 offset) internal pure returns (uint24 value) { + // solhint-disable-next-line no-inline-assembly + assembly { + value := shr(232, mload(add(add(data, 32), offset))) + } + } + /// @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" - ); + return + abi.encodePacked(hex"3d602d80600a3d3981f3363d3d373d3d3d363d73", target, hex"5af43d82803e903d91602b57fd5bf3"); } function _cloneDeterministic(address target, bytes32 salt) internal returns (address instance) { diff --git a/contracts/monerium-forwarder/src/VortexSubsidyVault.sol b/contracts/monerium-forwarder/src/VortexSubsidyVault.sol new file mode 100644 index 000000000..3446ef4a1 --- /dev/null +++ b/contracts/monerium-forwarder/src/VortexSubsidyVault.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IERC20, IVortexForwarderFactory} from "./VortexForwarder.sol"; + +/// @title VortexSubsidyVault +/// @notice Treasury-funded USDC pool that tops a forwarder swap up to the client's floor +/// rate (docs/architecture-monerium-b2b-onramp.md, "Fees, reference rate and subsidy"). Only factory-registered +/// clones can draw; every draw is bounded by a per-swap cap (ppm of the swap's +/// reference value) and a daily budget; withdrawals can only go back to the +/// treasury. The vault never holds client funds — it only ever pushes Vortex +/// money to a clone's destination — so its guardian-settable limits bound Vortex's +/// exposure, not the client's. +contract VortexSubsidyVault { + uint32 private constant PPM = 1_000_000; + + IERC20 public immutable USDC; + address public immutable TREASURY; + IVortexForwarderFactory public immutable FACTORY; + + /// @notice Per-swap cap, relative to the swap's reference value (amountIn x reference). + uint32 public maxSubsidyPpm; + /// @notice USDC base units the vault may pay out per UTC day. + uint256 public dailyBudget; + /// @dev UTC day index (block.timestamp / 1 days) the `spentToday` counter belongs to. + uint256 public currentDay; + uint256 public spentToday; + bool public paused; + + event SubsidyPaid(address indexed forwarder, uint256 amount); + event MaxSubsidyPpmSet(uint32 value); + event DailyBudgetSet(uint256 value); + event PausedSet(bool paused); + event Withdrawn(uint256 amount); + + error NotGuardian(); + error NotForwarder(); + error VaultPaused(); + error SubsidyCapExceeded(); + error BudgetExhausted(); + error ZeroAddress(); + error TransferFailed(); + + modifier onlyGuardian() { + if (msg.sender != FACTORY.guardian()) revert NotGuardian(); + _; + } + + constructor( + IERC20 usdc, + address treasury, + IVortexForwarderFactory factory, + uint32 initialMaxSubsidyPpm, + uint256 initialDailyBudget + ) { + if (address(usdc) == address(0) || treasury == address(0) || address(factory) == address(0)) { + revert ZeroAddress(); + } + USDC = usdc; + TREASURY = treasury; + FACTORY = factory; + maxSubsidyPpm = initialMaxSubsidyPpm; + dailyBudget = initialDailyBudget; + } + + /// @notice Pays `amount` USDC to the calling clone. Reverts — and + /// with it the clone's whole swap — whenever the cap, the budget, the pause + /// or the balance cannot cover it, so a swap is never partially subsidized. + /// @param referenceOut The swap's reference value in USDC base units; the cap basis. + function pay(uint256 amount, uint256 referenceOut) external { + if (!FACTORY.isForwarder(msg.sender)) revert NotForwarder(); + if (paused) revert VaultPaused(); + if (amount > (referenceOut * maxSubsidyPpm) / PPM) revert SubsidyCapExceeded(); + + uint256 day = block.timestamp / 1 days; + if (day != currentDay) { + currentDay = day; + spentToday = 0; + } + if (spentToday + amount > dailyBudget) revert BudgetExhausted(); + spentToday += amount; + + _transfer(msg.sender, amount); + emit SubsidyPaid(msg.sender, amount); + } + + // ----------------------------------------------------------- guardian authority + + function setMaxSubsidyPpm(uint32 value) external onlyGuardian { + maxSubsidyPpm = value; + emit MaxSubsidyPpmSet(value); + } + + function setDailyBudget(uint256 value) external onlyGuardian { + dailyBudget = value; + emit DailyBudgetSet(value); + } + + function setPaused(bool paused_) external onlyGuardian { + paused = paused_; + emit PausedSet(paused_); + } + + /// @notice Returns funds to the treasury. There is no other withdrawal target. + function withdraw(uint256 amount) external onlyGuardian { + _transfer(TREASURY, amount); + emit Withdrawn(amount); + } + + function _transfer(address to, uint256 amount) internal { + if (amount == 0) return; + (bool success, bytes memory data) = address(USDC).call(abi.encodeCall(IERC20.transfer, (to, amount))); + if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); + } +} diff --git a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol index aff5591ec..fe65b9594 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.fork.t.sol @@ -2,8 +2,10 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; -import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarder, IERC20, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; +import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; +import {NO_CAP} from "./VortexForwarder.t.sol"; interface IUniswapV3Factory { function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address); @@ -27,13 +29,17 @@ contract VortexForwarderForkTest is Test { address constant UNIV3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; // Chainlink EUR/USD proxy — verify against data.chain.link before deploy (registry P8). address constant CHAINLINK_EUR_USD = 0xb49f677943BC038e9857d61E7d053CaA2C1734C1; + // Initial whitelisted route: EURe -> EURC -> USDC on the 5 bps tiers (registry P10). + uint24 constant POOL_FEE_EURE_EURC = 500; + uint24 constant POOL_FEE_EURC_USDC = 500; VortexForwarderFactory factory; VortexForwarder fwd; + VortexSubsidyVault vault; address attestor = vm.addr(0xA11CE); address destination = makeAddr("destination"); - address fallbackAddr = makeAddr("fallbackAddr"); + address recoveryWallet = makeAddr("recoveryWallet"); address keeper = makeAddr("keeper"); bool forked; @@ -54,21 +60,32 @@ contract VortexForwarderForkTest is Test { attestor: attestor, feeRecipient: makeAddr("feeRecipient"), maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h - slippageBps: 100, - maxFeeBps: 100, - sweepDelay: 60 days, + slippageBps: 60, + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, + recoveryDelay: 2 hours, // registry P3 triggerDelay: 24 hours, - poolFeeEureEurc: 500, - poolFeeEurcUsdc: 500, recoveryHash: bytes32(0) }), 1e18, 50_000e18, 25e18, - 10_000e18 + 10_000e18, + abi.encodePacked(EURE_V2, POOL_FEE_EURE_EURC, EURC, POOL_FEE_EURC_USDC, USDC) ); factory.setKeeper(keeper, true); - fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + vault = new VortexSubsidyVault( + IERC20(USDC), makeAddr("treasury"), IVortexForwarderFactory(address(factory)), 5_000, 200e6 + ); + deal(USDC, address(vault), 1_000e6); + factory.setSubsidyVault(address(vault)); + fwd = VortexForwarder(factory.deployForwarder(destination, recoveryWallet, 1_250, 1_500, bytes32(uint256(1)))); + } + + /// The keeper's reference in these tests is Chainlink itself (trivially inside the band). + function _reference() internal view returns (uint256) { + (, int256 answer,,,) = fwd.ORACLE().latestRoundData(); + return uint256(answer); } modifier onlyForked() { @@ -85,20 +102,23 @@ contract VortexForwarderForkTest is Test { assertGt(updatedAt, 0); } - function test_fork_pinnedPathUsesV2AndPoolsExist() public onlyForked { + function test_fork_initialRouteUsesV2AndPoolsExist() public onlyForked { assertEq(address(fwd.EURE()), EURE_V2); assertTrue(address(fwd.EURE()) != EURE_V1_DEPRECATED, "route must never touch deprecated V1 EURe"); + (bytes memory path, bool enabled) = factory.route(0); + assertTrue(enabled); + assertEq(path, abi.encodePacked(EURE_V2, POOL_FEE_EURE_EURC, EURC, POOL_FEE_EURC_USDC, USDC)); - // 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()); + // Both hops of the initial route must exist on-chain with the chosen fee tiers. + address hop1 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURE_V2, EURC, POOL_FEE_EURE_EURC); + address hop2 = IUniswapV3Factory(UNIV3_FACTORY).getPool(EURC, USDC, 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 { + function test_fork_swapThenForward_executesWithinOracleBounds() public onlyForked { uint256 amountIn = 1_000e18; deal(EURE_V2, address(fwd), amountIn); // stdStorage balance override @@ -106,20 +126,38 @@ contract VortexForwarderForkTest is Test { 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"); + fwd.swap(_reference(), 0, amountIn, NO_CAP); + assertEq(IERC20Meta(USDC).balanceOf(destination), 0, "USDC must wait on the clone until forward"); + + // With the vault funded the client lands at or above the policy floor (15 bps), + // whether by fill, fee, or subsidy; the oracle floor is the hard lower bound. + uint256 converted = IERC20Meta(USDC).balanceOf(address(fwd)); + assertGe(converted, (fair * 998_500) / 1_000_000, "below the policy floor"); + assertGe(converted, (fair * 9_940) / 10_000, "below the oracle floor"); + assertLe(converted, (fair * 10_300) / 10_000, "implausibly above oracle rate"); assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 0, "EURe left behind"); + + vm.prank(keeper); + fwd.forward(converted); + assertEq(IERC20Meta(USDC).balanceOf(destination), converted); assertEq(IERC20Meta(USDC).balanceOf(address(fwd)), 0, "USDC left behind"); } - function test_fork_perSwapCapLeavesRemainder() public onlyForked { + function test_fork_chunkedPayment_accumulatesThenRecovers() public onlyForked { deal(EURE_V2, address(fwd), 12_000e18); // cap is 10k vm.prank(keeper); - fwd.swapAndForward(); + fwd.swap(_reference(), 0, 10_000e18, NO_CAP); assertEq(IERC20Meta(EURE_V2).balanceOf(address(fwd)), 2_000e18); - assertGt(IERC20Meta(USDC).balanceOf(destination), 0); + uint256 converted = IERC20Meta(USDC).balanceOf(address(fwd)); + assertGt(converted, 0); + + // The promised window passes with the remainder unconverted: recover the whole + // payment (unconverted EURe + converted USDC) to the recovery wallet. + vm.warp(block.timestamp + fwd.RECOVERY_DELAY()); + vm.prank(keeper); + fwd.recover(2_000e18, converted); + assertEq(IERC20Meta(EURE_V2).balanceOf(recoveryWallet), 2_000e18); + assertEq(IERC20Meta(USDC).balanceOf(recoveryWallet), converted); + assertEq(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 index b959d0b9f..0049fcbf0 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.invariants.t.sol @@ -2,17 +2,22 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; -import {VortexForwarder} from "../src/VortexForwarder.sol"; +import {VortexForwarder, IERC20, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; -import {MockERC20, MockOracle, MockRouter} from "./VortexForwarder.t.sol"; +import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; +import {MockERC20, MockOracle, MockRouter, NO_CAP} 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. +/// EURe may sit in the forwarder, be consumed by the router, or reach the Vortex +/// recovery wallet; USDC may sit on the forwarder or reach destination, feeRecipient +/// or the recovery wallet (plus vault subsidies, which land on the forwarder first); +/// nothing else, ever. A recovery is only ever possible RECOVERY_DELAY after a batch +/// opened, and a chunk swap never re-times an open batch. contract ForwarderHandler is Test { VortexForwarderFactory public factory; VortexForwarder public fwd; + VortexSubsidyVault public vault; MockERC20 public eure; MockERC20 public usdc; MockERC20 public eurc; @@ -20,15 +25,29 @@ contract ForwarderHandler is Test { MockRouter public router; address public destination = makeAddr("destination"); - address public fallbackAddr = makeAddr("fallbackAddr"); + address public recoveryWallet = makeAddr("recoveryWallet"); address public keeper = makeAddr("keeper"); address public rando = makeAddr("rando"); address public feeRecipient = makeAddr("feeRecipient"); + address public treasury = makeAddr("treasury"); uint256 public ghostEureMinted; uint256 public ghostUsdcPaidByRouter; - uint256 public fallbackSweepFailures; - uint16 public immutable INITIAL_FEE_BPS = 50; + uint256 public ghostSubsidyPaid; + /// Successful swaps whose client net landed below the oracle floor, or keeper swaps + /// below the policy floor, or fees above MAX_FEE_PPM. Must stay zero. + uint256 public pricingViolations; + /// Recoveries that succeeded less than RECOVERY_DELAY after their batch opened. Must stay zero. + uint256 public earlyRecoveries; + /// Swaps that changed an already-armed batch marker. Must stay zero. + uint256 public markerRetimes; + /// Swaps whose vault subsidy exceeded the caller's maxSubsidy. Must stay zero. + uint256 public capViolations; + uint32 public immutable INITIAL_TARGET_PPM = 1_250; + uint32 public immutable INITIAL_FLOOR_PPM = 1_500; + uint256 public constant VAULT_FUNDING = 10_000e6; + uint256 public constant RECOVERY_DELAY = 2 hours; + uint256 constant REFERENCE = 1.14e8; constructor() { eure = new MockERC20("EURe", 18); @@ -47,22 +66,32 @@ contract ForwarderHandler is Test { 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, + slippageBps: 60, + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, + recoveryDelay: RECOVERY_DELAY, // registry P3 triggerDelay: 24 hours, - poolFeeEureEurc: 500, - poolFeeEurcUsdc: 500, recoveryHash: bytes32(0) }), 1e18, 50_000e18, 25e18, - 10_000e18 + 10_000e18, + abi.encodePacked(address(eure), uint24(500), address(eurc), uint24(500), address(usdc)) ); factory.setKeeper(keeper, true); - fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, INITIAL_FEE_BPS, bytes32(uint256(1)))); - ghostExpectedFeeBps = INITIAL_FEE_BPS; + vault = new VortexSubsidyVault( + IERC20(address(usdc)), treasury, IVortexForwarderFactory(address(factory)), 5_000, 200e6 + ); + usdc.mint(address(vault), VAULT_FUNDING); + factory.setSubsidyVault(address(vault)); + fwd = VortexForwarder( + factory.deployForwarder( + destination, recoveryWallet, INITIAL_TARGET_PPM, INITIAL_FLOOR_PPM, bytes32(uint256(1)) + ) + ); + ghostExpectedTargetPpm = INITIAL_TARGET_PPM; + ghostExpectedFloorPpm = INITIAL_FLOOR_PPM; } // ------------------------------------------------------------- actions @@ -81,82 +110,122 @@ contract ForwarderHandler is Test { 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); + /// Router pays a randomized amount around the fair oracle value: far below exercises + /// the floor/cap reverts, slightly below the subsidy path, above the fee path. + function keeperSwap(uint96 raw, uint96 rawAmount, uint32 rawCap) external { + _swapAs(keeper, raw, rawAmount, rawCap); } - function randoSwap(uint96 raw) external { - _swapAs(rando, raw); + function randoSwap(uint96 raw, uint96 rawAmount, uint32 rawCap) external { + _swapAs(rando, raw, rawAmount, rawCap); } - 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); + struct SwapSnapshot { + uint256 routerMinted; + uint256 vault; + uint256 clone; + uint256 fee; + uint64 marker; + } - uint256 routerUsdcBefore = usdc.totalMinted(); + function _swapAs(address caller, uint96 raw, uint96 rawAmount, uint32 rawCap) internal { + oracle.set(1.14e8, block.timestamp); + (uint256 amountIn, uint256 fair) = _arrangeFill(raw, rawAmount); + // The keeper's tier: sometimes nothing, sometimes a few USDC, sometimes unbounded. + uint256 maxSubsidy = rawCap % 3 == 0 ? NO_CAP : uint256(rawCap) % 12e6; + SwapSnapshot memory before = SwapSnapshot({ + routerMinted: usdc.totalMinted(), + vault: usdc.balanceOf(address(vault)), + clone: usdc.balanceOf(address(fwd)), + fee: usdc.balanceOf(feeRecipient), + marker: fwd.batchOpenedAt() + }); vm.prank(caller); - try fwd.swapAndForward() { - ghostUsdcPaidByRouter += usdc.totalMinted() - routerUsdcBefore; + try fwd.swap(REFERENCE, 0, amountIn, maxSubsidy) { + _recordSwap(caller, fair, maxSubsidy, before); } catch {} } - function sweepStranded() external { - try fwd.sweepStrandedEure() {} catch {} + /// Mostly legal amounts (occasionally out of bounds to exercise the reverts) and a + /// router payout randomized around the fair oracle value. + function _arrangeFill(uint96 raw, uint96 rawAmount) internal returns (uint256 amountIn, uint256 fair) { + uint256 balance = eure.balanceOf(address(fwd)); + uint256 ceiling = balance > 10_000e18 ? 10_000e18 : balance; + amountIn = bound(uint256(rawAmount), 0, ceiling + 30e18); + fair = (amountIn * REFERENCE) / 1e20; + router.setNextOut(bound(uint256(raw), (fair * 95) / 100, (fair * 105) / 100)); + } + + function _recordSwap(address caller, uint256 fair, uint256 maxSubsidy, SwapSnapshot memory before) internal { + uint256 paid = usdc.totalMinted() - before.routerMinted; + ghostUsdcPaidByRouter += paid; + uint256 subsidy = before.vault - usdc.balanceOf(address(vault)); + ghostSubsidyPaid += subsidy; + if (subsidy > maxSubsidy) capViolations++; + uint256 net = usdc.balanceOf(address(fwd)) - before.clone; // fill - fee + subsidy + if (net < (fair * 9_940) / 10_000) pricingViolations++; // Chainlink - 60 bps + if (caller == keeper && net < (fair * (1_000_000 - fwd.floorPpm())) / 1_000_000) pricingViolations++; + if (usdc.balanceOf(feeRecipient) - before.fee > paid / 100) pricingViolations++; // MAX_FEE_PPM + if (before.marker != 0 && fwd.batchOpenedAt() != before.marker) markerRetimes++; + } + + function keeperForward(uint96 raw) external { + uint256 amount = bound(uint256(raw), 0, usdc.balanceOf(address(fwd)) + 1e6); + vm.prank(keeper); + try fwd.forward(amount) {} catch {} + } + + function forwardAllAs(bool asKeeper) external { + vm.prank(asKeeper ? keeper : rando); + try fwd.forwardAll() {} catch {} + } + + function keeperRecover(uint96 rawEure, uint96 rawUsdc) external { + uint256 eureAmount = bound(uint256(rawEure), 0, eure.balanceOf(address(fwd)) + 1e18); + uint256 usdcAmount = bound(uint256(rawUsdc), 0, usdc.balanceOf(address(fwd)) + 1e6); + uint64 opened = fwd.batchOpenedAt(); + vm.prank(keeper); + try fwd.recover(eureAmount, usdcAmount) { + if (opened == 0 || block.timestamp - opened < RECOVERY_DELAY) earlyRecoveries++; + } 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 + /// P11 ghost model: what the fee policy 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 + /// someone calls applyFeePolicy. + uint32 public ghostExpectedTargetPpm; + uint32 public ghostExpectedFloorPpm; + + function guardianSetFeePolicy(uint32 rawTarget, uint32 rawFloor) external { + uint32 target = rawTarget % 11_000; + uint32 floor = target + rawFloor % 1_500; // sometimes above MAX_FEE_PPM: exercises InvalidFeePolicy + try fwd.setFeePolicy(target, floor) { + if (target <= ghostExpectedTargetPpm && floor <= ghostExpectedFloorPpm) { + ghostExpectedTargetPpm = target; // decrease/cancel: immediate + ghostExpectedFloorPpm = floor; } - // increase: pending only — ghost updates when applyFee succeeds + // increase: pending only — ghost updates when applyFeePolicy succeeds } catch {} } - function applyFee() external { - try fwd.applyFeeBps() { - ghostExpectedFeeBps = fwd.feeBps(); // apply succeeded past its timelock + function applyFeePolicy() external { + try fwd.applyFeePolicy() { + ghostExpectedTargetPpm = fwd.targetPpm(); // apply succeeded past its timelock + ghostExpectedFloorPpm = fwd.floorPpm(); } 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 {} + if (selector % 5 == 0) try fwd.setGuardianPaused(true) {} catch {} + if (selector % 5 == 1) try fwd.forward(usdc.balanceOf(address(fwd))) {} catch {} + if (selector % 5 == 2) try fwd.recover(eure.balanceOf(address(fwd)), usdc.balanceOf(address(fwd))) {} catch {} + if (selector % 5 == 3) try fwd.setFeePolicy(99, 99) {} catch {} + if (selector % 5 == 4) try vault.setDailyBudget(type(uint256).max) {} catch {} vm.stopPrank(); } } @@ -170,40 +239,70 @@ contract VortexForwarderInvariantTest is Test { } /// 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. + /// either still there, consumed by the router (swap), or at the recovery wallet. function invariant_eureConservation() public view { uint256 accounted = handler.eure().balanceOf(address(handler.fwd())) - + handler.eure().balanceOf(address(handler.router())) + handler.eure().balanceOf(handler.fallbackAddr()); + + handler.eure().balanceOf(address(handler.router())) + handler.eure().balanceOf(handler.recoveryWallet()); 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"); + /// Exit-path exhaustiveness for USDC: everything the router ever paid plus every + /// subsidy the vault ever paid is either still on the forwarder or split between + /// destination, feeRecipient and the recovery wallet; the vault only ever shrinks + /// by what it paid. + function invariant_usdcOnlyReachesDestinationFeeOrRecovery() public view { + uint256 accounted = handler.usdc().balanceOf(address(handler.fwd())) + + handler.usdc().balanceOf(handler.destination()) + handler.usdc().balanceOf(handler.feeRecipient()) + + handler.usdc().balanceOf(handler.recoveryWallet()); + assertEq( + accounted, + handler.ghostUsdcPaidByRouter() + handler.ghostSubsidyPaid(), + "USDC leaked to an unexpected address" + ); + assertEq( + handler.usdc().balanceOf(address(handler.vault())), + handler.VAULT_FUNDING() - handler.ghostSubsidyPaid(), + "vault balance disagrees with subsidies paid" + ); } - /// 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()); + /// Every successful swap respects the pricing bounds: the client's net never sits + /// below the oracle floor, a keeper swap never below the policy floor, and the fee + /// never exceeds MAX_FEE_PPM. + function invariant_pricingBounds() public view { + assertEq(handler.pricingViolations(), 0, "a swap violated a pricing bound"); } - /// 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"); + /// A recovery can only ever happen RECOVERY_DELAY after the batch opened: the + /// contract, not the keeper, enforces the promised window. + function invariant_recoveryNeverEarly() public view { + assertEq(handler.earlyRecoveries(), 0, "a recovery ran before RECOVERY_DELAY"); + } + + /// Chunking a payment never restarts its recovery clock. + function invariant_swapNeverRetimesTheBatch() public view { + assertEq(handler.markerRetimes(), 0, "a swap re-timed an open batch"); + } + + /// The vault never pays more for a swap than the caller allowed (A+). + function invariant_subsidyNeverAboveCallerCap() public view { + assertEq(handler.capViolations(), 0, "the vault paid above the caller's maxSubsidy"); + } + + /// Config changes only through their authorized paths: the fee policy 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), stays + /// ordered and capped; the destination never changes at all. + function invariant_configIntegrity() public view { + assertEq(handler.fwd().targetPpm(), handler.ghostExpectedTargetPpm(), "target moved outside the timelock path"); + assertEq(handler.fwd().floorPpm(), handler.ghostExpectedFloorPpm(), "floor moved outside the timelock path"); + assertLe(handler.fwd().targetPpm(), handler.fwd().floorPpm(), "target above floor"); + assertLe(handler.fwd().floorPpm(), 10_000, "floor exceeded MAX_FEE_PPM"); + assertEq(handler.fwd().destination(), handler.destination()); } - /// The stranding marker never points into the future. - function invariant_strandedSinceNotInFuture() public view { - assertLe(handler.fwd().strandedSince(), block.timestamp); + /// The batch marker never points into the future. + function invariant_batchMarkerNotInFuture() public view { + assertLe(handler.fwd().batchOpenedAt(), block.timestamp); } } diff --git a/contracts/monerium-forwarder/test/VortexForwarder.t.sol b/contracts/monerium-forwarder/test/VortexForwarder.t.sol index 049f341f4..e11146086 100644 --- a/contracts/monerium-forwarder/test/VortexForwarder.t.sol +++ b/contracts/monerium-forwarder/test/VortexForwarder.t.sol @@ -2,8 +2,14 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; -import {VortexForwarder, IERC20, ISwapRouter02} from "../src/VortexForwarder.sol"; +import {VortexForwarder, IERC20, ISwapRouter02, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; import {VortexForwarderFactory} from "../src/VortexForwarderFactory.sol"; +import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; + +// Reference rate the keeper passes in the unit tests; equal to the mock oracle price. +uint256 constant REF = 1.14e8; +// "No keeper tier": lets the vault's own cap decide, as the tests did before A+. +uint256 constant NO_CAP = type(uint256).max; contract MockERC20 { string public name; @@ -61,6 +67,7 @@ contract MockRouter { MockERC20 public immutable eure; MockERC20 public immutable usdc; uint256 public nextOut; + bytes public lastPath; constructor(MockERC20 eure_, MockERC20 usdc_) { eure = eure_; @@ -73,20 +80,27 @@ contract MockRouter { function exactInput(ISwapRouter02.ExactInputParams calldata params) external payable returns (uint256) { eure.transferFrom(msg.sender, address(this), params.amountIn); + lastPath = params.path; 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. +/// Malicious router that tries to re-enter swap during the swap. contract MockReentrantRouter { function exactInput(ISwapRouter02.ExactInputParams calldata) external payable returns (uint256) { - VortexForwarder(msg.sender).swapAndForward(); // must revert via reentrancy guard + VortexForwarder(msg.sender).swap(REF, 0, 1_000e18, NO_CAP); // must revert via reentrancy guard return 0; } } +/// Vault that accepts pay() and transfers nothing: what a misconfigured or hostile +/// guardian-set vault looks like from the forwarder's side. +contract NoopVault { + function pay(uint256, uint256) external {} +} + contract VortexForwarderTest is Test { MockERC20 eure; MockERC20 eurc; @@ -95,17 +109,31 @@ contract VortexForwarderTest is Test { MockRouter router; VortexForwarderFactory factory; VortexForwarder fwd; + VortexSubsidyVault vault; uint256 attestorPk = 0xA11CE; address attestor; address feeRecipient = makeAddr("feeRecipient"); + address treasury = makeAddr("treasury"); address destination = makeAddr("destination"); - address fallbackAddr = makeAddr("fallbackAddr"); + address recoveryWallet = makeAddr("recoveryWallet"); address keeper = makeAddr("keeper"); address rando = makeAddr("rando"); uint256 constant TRIGGER_DELAY = 24 hours; - uint256 constant SWEEP_DELAY = 60 days; + uint256 constant RECOVERY_DELAY = 2 hours; // registry P3: the promised conversion window + + // Fee policy defaults (proposal): target 12.5 bps, floor 15 bps below the reference. + uint32 constant TARGET_PPM = 1_250; + uint32 constant FLOOR_PPM = 1_500; + // Vault defaults: 50 bps of the reference value per swap, 200 USDC per day. + uint32 constant MAX_SUBSIDY_PPM = 5_000; + uint256 constant DAILY_BUDGET = 200e6; + // 1000 EURe at 1.14 = 1140 USDC reference value and its derived bounds. + uint256 constant TARGET_1K = 1_138_575_000; // reference - 12.5 bps + uint256 constant FLOOR_1K = 1_138_290_000; // reference - 15 bps + uint256 constant ORACLE_FLOOR_1K = 1_133_160_000; // Chainlink - 60 bps + uint256 constant TARGET_10K = 11_385_750_000; function setUp() public { attestor = vm.addr(attestorPk); @@ -116,34 +144,54 @@ contract VortexForwarderTest is Test { 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) - }), + _config(address(router), bytes32(0)), 1e18, // MIN_SWAP_FLOOR 50_000e18, // CAP_CEILING 25e18, // minSwapAmount - 10_000e18 // perSwapCap + 10_000e18, // perSwapCap + _route(500, 500) ); factory.setKeeper(keeper, true); - fwd = VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(1)))); + vault = new VortexSubsidyVault( + IERC20(address(usdc)), treasury, IVortexForwarderFactory(address(factory)), MAX_SUBSIDY_PPM, DAILY_BUDGET + ); + usdc.mint(address(vault), 1_000e6); + factory.setSubsidyVault(address(vault)); + fwd = VortexForwarder( + factory.deployForwarder(destination, recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(1))) + ); } // ---------------------------------------------------------------- helpers + function _config(address router_, bytes32 recoveryHash) + internal + view + returns (VortexForwarder.ImmutableConfig memory) + { + return VortexForwarder.ImmutableConfig({ + eure: address(eure), + eurc: address(eurc), + usdc: address(usdc), + router: router_, + oracle: address(oracle), + attestor: attestor, + feeRecipient: feeRecipient, + maxOracleAge: 52 hours, // P8: covers observed Chainlink weekend gaps up to 48h + slippageBps: 60, // P1: tolerates ~45 bps of weekend drift under a stale Chainlink round + maxFeePpm: 10_000, + maxReferenceDeviationBps: 100, + recoveryDelay: RECOVERY_DELAY, + triggerDelay: TRIGGER_DELAY, + recoveryHash: recoveryHash + }); + } + + /// Uniswap V3 packed path EURe -> EURC -> USDC at the given fee tiers. + function _route(uint24 tier1, uint24 tier2) internal view returns (bytes memory) { + return abi.encodePacked(address(eure), tier1, address(eurc), tier2, address(usdc)); + } + 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); @@ -154,6 +202,11 @@ contract VortexForwarderTest is Test { eure.mint(address(fwd), amount); } + function _keeperSwap(uint256 amountIn) internal { + vm.prank(keeper); + fwd.swap(REF, 0, amountIn, NO_CAP); + } + // ---------------------------------------------------------------- EIP-1271 function test_linkSignature_valid_eip191Only() public view { @@ -195,29 +248,11 @@ contract VortexForwarderTest is Test { 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 + _config(address(router), recoveryHash), 1e18, 50_000e18, 25e18, 10_000e18, _route(500, 500) + ); + VortexForwarder fwd2 = VortexForwarder( + f2.deployForwarder(destination, recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(8))) ); - 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); @@ -251,8 +286,9 @@ contract VortexForwarderTest is Test { } function test_linkSignature_rejectsCrossCloneReplay() public { - VortexForwarder other = - VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(2)))); + VortexForwarder other = VortexForwarder( + factory.deployForwarder(destination, recoveryWallet, TARGET_PPM, FLOOR_PPM, 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)); @@ -262,249 +298,381 @@ contract VortexForwarderTest is Test { function test_initialize_onlyFactory_andOnce() public { vm.expectRevert(VortexForwarder.NotFactory.selector); - fwd.initialize(rando, rando, 0); + fwd.initialize(rando, recoveryWallet, 0, 0); vm.prank(address(factory)); vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); - fwd.initialize(rando, rando, 0); + fwd.initialize(rando, recoveryWallet, 0, 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); + impl.initialize(rando, recoveryWallet, 0, 0); } - // ---------------------------------------------------------------- swap + function test_deploy_fixesThePerClientRecoveryAddress() public view { + assertEq(fwd.recoveryAddress(), recoveryWallet); + } - function test_swapAndForward_happyPath_forwardsToDestination() public { - _fund(1_000e18); - // minOut = 1000 * 1.14 * 0.99 = 1128.6 USDC - router.setNextOut(1_130e6); + function test_deploy_rejectsRecoveryAddressAsDestination() public { + vm.expectRevert(VortexForwarder.InvalidConfigAddress.selector); + factory.deployForwarder(recoveryWallet, recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(3))); + } + + function test_deploy_rejectsZeroOrTokenRecoveryAddress() public { + vm.expectRevert(VortexForwarder.ZeroAddress.selector); + factory.deployForwarder(destination, address(0), TARGET_PPM, FLOOR_PPM, bytes32(uint256(3))); + vm.expectRevert(VortexForwarder.InvalidConfigAddress.selector); + factory.deployForwarder(destination, address(usdc), TARGET_PPM, FLOOR_PPM, bytes32(uint256(3))); + } + + function test_recover_sendsToEachClonesOwnRecoveryAddress() public { + address otherRecovery = makeAddr("otherRecovery"); + VortexForwarder other = VortexForwarder( + factory.deployForwarder(destination, otherRecovery, TARGET_PPM, FLOOR_PPM, bytes32(uint256(4))) + ); + eure.mint(address(other), 300e18); + other.poke(); + vm.warp(block.timestamp + other.RECOVERY_DELAY()); vm.prank(keeper); - fwd.swapAndForward(); - assertEq(usdc.balanceOf(destination), 1_130e6); + other.recover(300e18, 0); + assertEq(eure.balanceOf(otherRecovery), 300e18); + assertEq(eure.balanceOf(recoveryWallet), 0); + } + + // ---------------------------------------------------------------- swap + forward + + function test_swap_keepsUsdcOnTheClone_untilForward() public { + _fund(1_000e18); + router.setNextOut(TARGET_1K); // exactly the target: no fee, no subsidy + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K, "USDC must accumulate on the clone"); + assertEq(usdc.balanceOf(destination), 0); assertEq(eure.balanceOf(address(fwd)), 0); assertEq(eure.allowance(address(fwd), address(router)), 0); + + vm.prank(keeper); + fwd.forward(TARGET_1K); + assertEq(usdc.balanceOf(destination), TARGET_1K); + assertEq(usdc.balanceOf(address(fwd)), 0); + assertEq(fwd.batchOpenedAt(), 0, "an emptied clone closes its batch"); } - function test_swapAndForward_enforcesOracleMinOut() public { - _fund(1_000e18); - router.setNextOut(1_100e6); // below 1128.6 -> router-side minOut check fires + /// One bank payment, several chunks, one transfer: the partner's 1:1 mapping. + function test_chunkedPayment_forwardedAsOneTransfer() public { + _fund(25_000e18); // cap is 10k: three chunks + router.setNextOut(TARGET_10K); + _keeperSwap(10_000e18); + _keeperSwap(10_000e18); + router.setNextOut(5 * TARGET_1K); + _keeperSwap(5_000e18); + uint256 total = 2 * TARGET_10K + 5 * TARGET_1K; + assertEq(usdc.balanceOf(address(fwd)), total); + assertEq(usdc.balanceOf(destination), 0, "nothing reaches the client before the whole payment is converted"); + vm.prank(keeper); - vm.expectRevert("Too little received"); - fwd.swapAndForward(); + fwd.forward(total); + assertEq(usdc.balanceOf(destination), total); + } + + function test_swap_enforcesOracleFloorOnTheNet() public { + // Permissionless path (no subsidy): a fill below Chainlink - 60 bps must revert in + // the forwarder's own post-condition, not in the router (its minimum is zero). + _fund(1_000e18); + fwd.poke(); + skip(TRIGGER_DELAY + 1); + oracle.set(1.14e8, block.timestamp); + router.setNextOut(ORACLE_FLOOR_1K - 1); + vm.prank(rando); + vm.expectRevert(VortexForwarder.InsufficientOutput.selector); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + + router.setNextOut(ORACLE_FLOOR_1K); + vm.prank(rando); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + assertEq(usdc.balanceOf(address(fwd)), ORACLE_FLOOR_1K); } - function test_swapAndForward_revertsOnStaleOracle() public { + function test_swap_revertsOnStaleOracle() public { _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); 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(); + fwd.swap(REF, 0, 1_000e18, NO_CAP); } - function test_swapAndForward_publicOnlyAfterTriggerDelay() public { + function test_swap_publicOnlyAfterTriggerDelay() public { _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swapAndForward(); + fwd.swap(REF, 0, 1_000e18, NO_CAP); fwd.poke(); vm.prank(rando); vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); - fwd.swapAndForward(); + fwd.swap(REF, 0, 1_000e18, NO_CAP); skip(TRIGGER_DELAY + 1); oracle.set(1.14e8, block.timestamp); vm.prank(rando); - fwd.swapAndForward(); - assertEq(usdc.balanceOf(destination), 1_130e6); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); } - function test_swapAndForward_revertsOnZeroOrNegativePrice() public { + function test_swap_revertsOnZeroOrNegativePrice() public { _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); oracle.set(0, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swapAndForward(); + fwd.swap(REF, 0, 1_000e18, NO_CAP); oracle.set(-1, block.timestamp); vm.prank(keeper); vm.expectRevert(VortexForwarder.InvalidPrice.selector); - fwd.swapAndForward(); + fwd.swap(REF, 0, 1_000e18, NO_CAP); } - /// 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 { + function test_swap_amountBounds() public { + _fund(15_000e18); // cap is 10k, minimum 25 + router.setNextOut(TARGET_10K); + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.BelowMinimum.selector); + fwd.swap(REF, 0, 24e18, NO_CAP); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.swap(REF, 0, 10_000e18 + 1, NO_CAP); // above the cap + fwd.swap(REF, 0, 10_000e18, NO_CAP); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.swap(REF, 0, 5_000e18 + 1, NO_CAP); // above the balance + vm.stopPrank(); + assertEq(eure.balanceOf(address(fwd)), 5_000e18); // remainder awaits the next chunk + } + + /// A partial swap must never restart the recovery clock: the marker keeps the time + /// the batch opened, whatever remains on the clone. + function test_swap_neverRetimesTheBatchMarker() public { _fund(15_000e18); // cap is 10k fwd.poke(); - assertGt(fwd.strandedSince(), 0); - router.setNextOut(11_290e6); + uint64 opened = fwd.batchOpenedAt(); + assertGt(opened, 0); + router.setNextOut(TARGET_10K); skip(1 hours); - vm.prank(keeper); - fwd.swapAndForward(); + _keeperSwap(10_000e18); assertEq(eure.balanceOf(address(fwd)), 5_000e18); - assertEq(fwd.strandedSince(), block.timestamp, "remainder must stay armed (fresh timestamp)"); + assertEq(fwd.batchOpenedAt(), opened, "a chunk swap re-timed the batch"); } - 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_swap_armsTheBatchMarkerWhenNobodyPoked() public { + _fund(1_000e18); + router.setNextOut(TARGET_1K); + skip(3 hours); + _keeperSwap(1_000e18); + assertEq(fwd.batchOpenedAt(), block.timestamp); } - function test_swapAndForward_pausedByGuardianOrClientOrGlobal() public { + function test_swapAndForward_pausedByGuardianOrGlobal() public { _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); fwd.setGuardianPaused(true); // test contract is factory guardian - vm.prank(keeper); + vm.startPrank(keeper); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.forward(TARGET_1K); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.forwardAll(); + vm.stopPrank(); fwd.setGuardianPaused(false); - vm.prank(fallbackAddr); - fwd.setClientPaused(true); - vm.prank(keeper); + factory.setGlobalPaused(true); + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.Paused.selector); + fwd.swap(REF, 0, 1_000e18, NO_CAP); vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(); - vm.prank(fallbackAddr); - fwd.setClientPaused(false); + fwd.forward(TARGET_1K); + vm.stopPrank(); + } - factory.setGlobalPaused(true); + function test_forward_keeperOnly_andBounded() public { + _fund(1_000e18); + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotKeeper.selector); + fwd.forward(TARGET_1K); + + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.forward(0); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.forward(TARGET_1K + 1); + fwd.forward(TARGET_1K - 1); // an explicit amount leaves the rest for a later forward + vm.stopPrank(); + assertEq(usdc.balanceOf(destination), TARGET_1K - 1); + assertEq(usdc.balanceOf(address(fwd)), 1); + assertGt(fwd.batchOpenedAt(), 0, "USDC left behind keeps a batch open"); + } + + /// A forward closes the previous batch: whatever a younger payment left behind is + /// timed from now, never from the older payment's arrival. + function test_forward_retimesTheMarkerForRemainingFunds() public { + _fund(1_000e18); + fwd.poke(); + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); + skip(1 hours); + _fund(500e18); // a younger payment lands while the first is being forwarded vm.prank(keeper); - vm.expectRevert(VortexForwarder.Paused.selector); - fwd.swapAndForward(); + fwd.forward(TARGET_1K); + assertEq(fwd.batchOpenedAt(), block.timestamp, "remaining EURe belongs to a new batch"); } - function test_unsolicitedUsdc_forwardedWithNextSwap() public { + function test_unsolicitedUsdc_forwardAllPushesEverything() public { usdc.mint(address(fwd), 500e6); // unsolicited direct transfer (R09) _fund(1_000e18); - router.setNextOut(1_130e6); + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); vm.prank(keeper); - fwd.swapAndForward(); - assertEq(usdc.balanceOf(destination), 1_130e6 + 500e6); + fwd.forwardAll(); + assertEq(usdc.balanceOf(destination), TARGET_1K + 500e6); + assertEq(fwd.batchOpenedAt(), 0); + } + + function test_forwardAll_publicOnlyAfterTriggerDelay() public { + _fund(1_000e18); + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); // arms the marker + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.forwardAll(); + + skip(TRIGGER_DELAY + 1); + vm.prank(rando); + fwd.forwardAll(); // liveness fallback: a dead Vortex cannot trap converted funds + assertEq(usdc.balanceOf(destination), TARGET_1K); + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotAuthorizedYet.selector); + fwd.forwardAll(); // the emptied clone closed its batch: the public path is armed again only by new funds } 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 + _config(address(evil), bytes32(0)), 1e18, 50_000e18, 25e18, 10_000e18, _route(500, 500) ); f2.setKeeper(keeper, true); - VortexForwarder fwd2 = VortexForwarder(f2.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(7)))); + VortexForwarder fwd2 = VortexForwarder( + f2.deployForwarder(destination, recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(7))) + ); eure.mint(address(fwd2), 1_000e18); vm.prank(keeper); vm.expectRevert(VortexForwarder.Reentrancy.selector); - fwd2.swapAndForward(); + fwd2.swap(REF, 0, 1_000e18, NO_CAP); } // ---------------------------------------------------------------- recovery - function test_sweepStrandedEure_afterDelay_toFallbackOnly() public { - _fund(500e18); - fwd.poke(); + function test_recover_keeperOnly_afterRecoveryDelay_toRecoveryWalletOnly() public { + _fund(1_500e18); // 1000 converted, 500 stuck unconverted + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); + uint64 opened = fwd.batchOpenedAt(); + + vm.prank(rando); + vm.expectRevert(VortexForwarder.NotKeeper.selector); + fwd.recover(500e18, TARGET_1K); + vm.prank(keeper); vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); - fwd.sweepStrandedEure(); + fwd.recover(500e18, TARGET_1K); - skip(SWEEP_DELAY + 1); - vm.prank(rando); // permissionless - fwd.sweepStrandedEure(); - assertEq(eure.balanceOf(fallbackAddr), 500e18); - assertEq(fwd.strandedSince(), 0); - } + vm.warp(opened + RECOVERY_DELAY - 1); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.recover(500e18, TARGET_1K); - /// 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); + vm.warp(opened + RECOVERY_DELAY); + vm.prank(keeper); + fwd.recover(500e18, TARGET_1K); + assertEq(eure.balanceOf(recoveryWallet), 500e18); + assertEq(usdc.balanceOf(recoveryWallet), TARGET_1K); + assertEq(usdc.balanceOf(destination), 0, "a recovered payment never reaches the client"); + assertEq(fwd.batchOpenedAt(), 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"); + function test_recover_requiresAnOpenBatch() public { + vm.prank(keeper); + vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); + fwd.recover(1, 0); // marker never armed: no batch to recover + } - skip(SWEEP_DELAY + 1); - fwd.sweepStrandedEure(); - assertEq(eure.balanceOf(fallbackAddr), 500e18); + function test_recover_amountsAreExplicitAndBounded() public { + _fund(1_000e18); + fwd.poke(); + skip(RECOVERY_DELAY); + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.recover(0, 0); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.recover(1_000e18 + 1, 0); + vm.expectRevert(VortexForwarder.InvalidAmount.selector); + fwd.recover(0, 1); + fwd.recover(400e18, 0); // only this payment's share: a younger payment may share the clone + vm.stopPrank(); + assertEq(eure.balanceOf(recoveryWallet), 400e18); + assertEq(eure.balanceOf(address(fwd)), 600e18); + assertEq(fwd.batchOpenedAt(), block.timestamp, "what remains is timed as a new batch"); } - function test_fallbackSweep_worksWhilePaused() public { - _fund(500e18); + function test_recover_worksWhilePaused() public { + _fund(1_000e18); + fwd.poke(); + skip(RECOVERY_DELAY); fwd.setGuardianPaused(true); - vm.prank(fallbackAddr); - fwd.sweep(address(eure), fallbackAddr); - assertEq(eure.balanceOf(fallbackAddr), 500e18); + factory.setGlobalPaused(true); + vm.prank(keeper); + fwd.recover(1_000e18, 0); // pause-then-recover is the incident sequence + assertEq(eure.balanceOf(recoveryWallet), 1_000e18); } - function test_fallbackEureSweep_resetsDeadManTimer() public { + /// Review r1 F1 regression, carried over: raising the tunable minSwapAmount above a + /// funded balance must NOT let a poke() clear the marker — the batch is timed against + /// the immutable MIN_SWAP_FLOOR and must survive any guardian action. + function test_guardianCannotDisarmTheBatchMarker_byRaisingMinSwap() public { _fund(500e18); fwd.poke(); - skip(SWEEP_DELAY + 1); - - vm.prank(fallbackAddr); - fwd.sweep(address(eure), fallbackAddr); - assertEq(fwd.strandedSince(), 0); + uint64 opened = fwd.batchOpenedAt(); + assertGt(opened, 0); - _fund(500e18); - vm.expectRevert(VortexForwarder.NotStranded.selector); - fwd.sweepStrandedEure(); + factory.setMinSwapAmount(1_000e18); // guardian raises threshold above balance + skip(1 hours); + fwd.poke(); // anyone can poke; marker must survive, un-retimed + assertEq(fwd.batchOpenedAt(), opened, "guardian disarmed or re-timed the batch"); } - 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_poke_clearsAnArmedMarkerOnlyWhenEmpty() public { + _fund(1_000e18); + fwd.poke(); + assertGt(fwd.batchOpenedAt(), 0); + fwd.poke(); + assertGt(fwd.batchOpenedAt(), 0); + skip(RECOVERY_DELAY); + vm.prank(keeper); + fwd.recover(1_000e18, 0); + assertEq(fwd.batchOpenedAt(), 0); + usdc.mint(address(fwd), 1); // any USDC opens a batch: it must be forwarded or recovered + fwd.poke(); + assertEq(fwd.batchOpenedAt(), block.timestamp); } function test_guardianPause_gated() public { @@ -518,7 +686,7 @@ contract VortexForwarderTest is Test { function test_predictAddress_matchesDeployment() public { bytes32 salt = bytes32(uint256(42)); address predicted = factory.predictAddress(salt); - address deployed = factory.deployForwarder(destination, fallbackAddr, 0, salt); + address deployed = factory.deployForwarder(destination, recoveryWallet, TARGET_PPM, FLOOR_PPM, salt); assertEq(predicted, deployed); } @@ -533,90 +701,377 @@ contract VortexForwarderTest is Test { 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))); + // ---------------------------------------------------------------- routes + + function test_routes_initialRouteIsEnabledAndUsed() public { + (bytes memory path, bool enabled) = factory.route(0); + assertEq(path, _route(500, 500)); + assertTrue(enabled); + assertEq(factory.routeCount(), 1); + + _fund(1_000e18); + router.setNextOut(TARGET_1K); + _keeperSwap(1_000e18); + assertEq(router.lastPath(), _route(500, 500)); + } + + function test_routes_keeperSelectsAmongWhitelistedRoutes() public { + bytes memory direct = abi.encodePacked(address(eure), uint24(3000), address(usdc)); + uint256 index = factory.addRoute(direct); + assertEq(index, 1); + + _fund(1_000e18); + router.setNextOut(TARGET_1K); + vm.prank(keeper); + fwd.swap(REF, 1, 1_000e18, NO_CAP); + assertEq(router.lastPath(), direct); + } + + function test_routes_unknownOrDisabledRouteReverts() public { + _fund(1_000e18); + router.setNextOut(TARGET_1K); + + vm.prank(keeper); + vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); + fwd.swap(REF, 7, 1_000e18, NO_CAP); + + factory.setRouteEnabled(0, false); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.InvalidRoute.selector); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + + vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); + factory.setRouteEnabled(7, false); + } + + function test_routes_validationRejectsAnythingOutsideTheThreeTokens() public { + address evil = makeAddr("evilToken"); + bytes[6] memory bad = [ + abi.encodePacked(evil, uint24(500), address(eurc), uint24(500), address(usdc)), // wrong start + abi.encodePacked(address(eure), uint24(500), address(eurc), uint24(500), evil), // wrong end + abi.encodePacked(address(eure), uint24(500), evil, uint24(500), address(usdc)), // wrong hop + abi.encodePacked(address(eure), uint24(250), address(eurc), uint24(500), address(usdc)), // bad tier + abi.encodePacked(address(eure), uint24(500), address(usdc), uint24(500)), // malformed length + abi.encodePacked( + address(eure), uint24(500), address(eurc), uint24(500), address(eurc), uint24(500), address(usdc) + ) // three hops + ]; + for (uint256 i = 0; i < bad.length; i++) { + vm.expectRevert(VortexForwarderFactory.InvalidRoute.selector); + factory.addRoute(bad[i]); + } + assertEq(factory.routeCount(), 1); + } + + function test_routes_guardianOnly() public { + vm.startPrank(rando); + vm.expectRevert(VortexForwarderFactory.NotGuardian.selector); + factory.addRoute(_route(100, 100)); + vm.expectRevert(VortexForwarderFactory.NotGuardian.selector); + factory.setRouteEnabled(0, false); + vm.expectRevert(VortexForwarderFactory.NotGuardian.selector); + factory.setSubsidyVault(rando); + vm.stopPrank(); + } + + // ---------------------------------------------------------------- fee bands + + function test_swap_aboveTarget_surplusIsTheFee() public { + _fund(1_000e18); + router.setNextOut(1_145e6); + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); + assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); + assertEq(usdc.balanceOf(address(vault)), 1_000e6); + } + + function test_swap_feeCappedAtMaxFeePpm() public { + _fund(1_000e18); + router.setNextOut(1_200e6); // ~5% above the reference + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(feeRecipient), 12e6); // 1% of the fill, not the whole surplus + assertEq(usdc.balanceOf(address(fwd)), 1_188e6); + } + + function test_swap_betweenFloorAndTarget_noFeeNoSubsidy() public { + _fund(1_000e18); + router.setNextOut(1_138_400_000); + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), 1_138_400_000); + assertEq(usdc.balanceOf(feeRecipient), 0); + assertEq(usdc.balanceOf(address(vault)), 1_000e6); + } + + function test_swap_belowFloor_vaultTopsUpTheCloneToTheFloor() public { + _fund(1_000e18); + router.setNextOut(1_136e6); + _keeperSwap(1_000e18); + uint256 subsidy = FLOOR_1K - 1_136e6; // 2.29 USDC + assertEq(usdc.balanceOf(address(fwd)), FLOOR_1K, "the subsidy lands on the clone, forwarded with the payment"); + assertEq(usdc.balanceOf(destination), 0); + assertEq(usdc.balanceOf(address(vault)), 1_000e6 - subsidy); + assertEq(vault.spentToday(), subsidy); + assertEq(usdc.balanceOf(feeRecipient), 0); } - // ------------------------------------------------------- fee timelock (P11) + function test_swap_rawFillBelowOracleFloor_isRescuedBySubsidy() public { + _fund(1_000e18); + router.setNextOut(1_133e6); // below Chainlink - 60 bps, within the vault's per-swap cap + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), FLOOR_1K); + } - function test_setFeeBps_onlyGuardianAndCapped() public { + /// A+: the keeper's tier binds at execution. A fill that needs more than the caller + /// allowed reverts the whole swap, whatever the vault would have paid. + function test_swap_subsidyAboveKeeperCap_revertsTheWholeSwap() public { + _fund(1_000e18); + router.setNextOut(1_136e6); // needs 2.29 USDC + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyAboveCap.selector); + fwd.swap(REF, 0, 1_000e18, 2_290_000 - 1); + assertEq(eure.balanceOf(address(fwd)), 1_000e18); + assertEq(usdc.balanceOf(address(vault)), 1_000e6); + + vm.prank(keeper); + fwd.swap(REF, 0, 1_000e18, 2_290_000); // exactly the shortfall: allowed + assertEq(usdc.balanceOf(address(fwd)), FLOOR_1K); + assertEq(usdc.balanceOf(address(vault)), 1_000e6 - 2_290_000); + } + + function test_swap_keeperCapZero_onlyFillsAtOrAboveTheFloorSucceed() public { + _fund(1_000e18); + router.setNextOut(1_136e6); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyAboveCap.selector); + fwd.swap(REF, 0, 1_000e18, 0); // the ladder's first tiers: wait for the market + router.setNextOut(FLOOR_1K); + vm.prank(keeper); + fwd.swap(REF, 0, 1_000e18, 0); + assertEq(usdc.balanceOf(address(fwd)), FLOOR_1K); + } + + function test_swap_subsidyOverCap_revertsTheWholeSwap() public { + _fund(1_000e18); + router.setNextOut(1_130e6); // needs 8.29 USDC; the cap is 50 bps of 1140 = 5.7 USDC + vm.prank(keeper); + vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + assertEq(eure.balanceOf(address(fwd)), 1_000e18); + assertEq(usdc.balanceOf(address(fwd)), 0); + } + + function test_swap_subsidyNotDelivered_revertsTheWholeSwap() public { + factory.setSubsidyVault(address(new NoopVault())); + _fund(1_000e18); + router.setNextOut(1_130e6); // below both floors; the 8.29 USDC top-up the vault "pays" never arrives + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyUnavailable.selector); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + assertEq(eure.balanceOf(address(fwd)), 1_000e18); + assertEq(usdc.balanceOf(address(fwd)), 0); + } + + function test_swap_subsidyOverBudget_reverts() public { + vault.setDailyBudget(1e6); + _fund(1_000e18); + router.setNextOut(1_136e6); + vm.prank(keeper); + vm.expectRevert(VortexSubsidyVault.BudgetExhausted.selector); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + } + + function test_swap_withoutVault_onlyFillsAtOrAboveTheFloorSucceed() public { + factory.setSubsidyVault(address(0)); + _fund(1_000e18); + router.setNextOut(1_136e6); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyUnavailable.selector); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + + router.setNextOut(1_145e6); + _keeperSwap(1_000e18); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); + } + + /// Amendment 2026-09-18: a reference far below a stale Chainlink round no longer stops + /// the swap — the client is settled to the Chainlink floor instead, at Vortex's cost, + /// within the keeper's tier and the vault's cap. + function test_swap_depeggedReference_isLiftedToTheOracleFloorWhenTheTierAndVaultAllow() public { + _fund(1_000e18); + uint256 lowReference = (REF * 9_910) / 10_000; // 90 bps below Chainlink: inside the band + // The floor at that reference (~1128.05 USDC) is below Chainlink - 60 bps (1133.16): + // the subsidy tops the client up to 1133.16, not to 1128.05. + router.setNextOut(1_127e6); + uint256 needed = ORACLE_FLOOR_1K - 1_127e6; // 6.16 USDC + + // The launch vault cap (50 bps of the reference value, ~5.65 USDC) cannot cover it. + vm.prank(keeper); + vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); + fwd.swap(lowReference, 0, 1_000e18, NO_CAP); + + vault.setMaxSubsidyPpm(10_000); // the ladder's top: 100 bps + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyAboveCap.selector); + fwd.swap(lowReference, 0, 1_000e18, needed - 1); // the keeper's tier still binds + + vm.prank(keeper); + fwd.swap(lowReference, 0, 1_000e18, needed); + assertEq(usdc.balanceOf(address(fwd)), ORACLE_FLOOR_1K, "settled to the Chainlink floor"); + assertEq(usdc.balanceOf(address(vault)), 1_000e6 - needed); + } + + /// A fill above the low reference's target but below the Chainlink floor: the fee + /// gives way first, so the client still lands on the floor. + function test_swap_depeggedReference_feeGivesWayBeforeTheOracleFloor() public { + _fund(1_000e18); + uint256 lowReference = (REF * 9_900) / 10_000; // 100 bps below Chainlink: the band's edge + // The reference target is 1_127_189_250; the fill of 1140 is above it, but the fee may + // only take what sits above the Chainlink floor (1_133_160_000). + router.setNextOut(1_140e6); + vm.prank(keeper); + fwd.swap(lowReference, 0, 1_000e18, 0); + assertEq(usdc.balanceOf(address(fwd)), ORACLE_FLOOR_1K); + assertEq(usdc.balanceOf(feeRecipient), 1_140e6 - ORACLE_FLOOR_1K); + assertEq(usdc.balanceOf(address(vault)), 1_000e6, "no subsidy was needed"); + + // Below the floor with a zero tier: the swap waits (reverts), it does not execute short. + _fund(1_000e18); + router.setNextOut(1_128e6); + vm.prank(keeper); + vm.expectRevert(VortexForwarder.SubsidyAboveCap.selector); + fwd.swap(lowReference, 0, 1_000e18, 0); + } + + function test_swap_referenceOutsideTheBandReverts() public { + _fund(1_000e18); + router.setNextOut(1_150e6); + vm.startPrank(keeper); + vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); + fwd.swap((REF * 10_101) / 10_000, 0, 1_000e18, NO_CAP); // 101 bps above + vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); + fwd.swap((REF * 9_899) / 10_000, 0, 1_000e18, NO_CAP); // 101 bps below + vm.expectRevert(VortexForwarder.ReferenceOutOfBand.selector); + fwd.swap(0, 0, 1_000e18, NO_CAP); + fwd.swap((REF * 10_100) / 10_000, 0, 1_000e18, NO_CAP); // exactly 100 bps: allowed + vm.stopPrank(); + assertGt(usdc.balanceOf(address(fwd)), 0); + } + + function test_swap_permissionless_pricesAgainstChainlinkAndPaysNoSubsidy() public { + _fund(1_000e18); + fwd.poke(); + skip(TRIGGER_DELAY + 1); + oracle.set(1.14e8, block.timestamp); + router.setNextOut(1_136e6); // below the floor: the client simply gets the fill + vm.prank(rando); + fwd.swap(1, 0, 1_000e18, NO_CAP); // garbage reference is ignored on this path + assertEq(usdc.balanceOf(address(fwd)), 1_136e6); + assertEq(usdc.balanceOf(address(vault)), 1_000e6); + + _fund(1_000e18); + router.setNextOut(1_145e6); // above the Chainlink-based target: the fee still applies + vm.prank(rando); + fwd.swap(999, 0, 1_000e18, NO_CAP); + assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); + } + + // ------------------------------------------------------- fee policy (P11) + + function test_feePolicy_validatedAtDeploy() public { + vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); + factory.deployForwarder(destination, recoveryWallet, 2_000, 1_500, bytes32(uint256(9))); // target above floor + vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); + factory.deployForwarder(destination, recoveryWallet, 1_000, 10_001, bytes32(uint256(9))); // floor above cap + } + + function test_setFeePolicy_onlyGuardianAndValidated() public { vm.prank(rando); vm.expectRevert(VortexForwarder.NotGuardian.selector); - fwd.setFeeBps(10); + fwd.setFeePolicy(1_000, 1_000); - vm.expectRevert(VortexForwarder.FeeTooHigh.selector); - fwd.setFeeBps(101); // above MAX_FEE_BPS, even for the guardian + vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); + fwd.setFeePolicy(1_600, 1_500); + vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); + fwd.setFeePolicy(1_000, 10_001); } - 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())); + function test_setFeePolicy_increaseIsTimelocked() public { + fwd.setFeePolicy(2_500, 3_000); + // Announced, not applied: swaps in the window still use the old policy. + assertEq(fwd.targetPpm(), TARGET_PPM); + assertEq(fwd.floorPpm(), FLOOR_PPM); + assertEq(fwd.pendingTargetPpm(), 2_500); + assertEq(fwd.pendingFloorPpm(), 3_000); + assertEq(fwd.pendingFeePolicyEffectiveAt(), uint64(block.timestamp + fwd.FEE_INCREASE_TIMELOCK())); vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); - fwd.applyFeeBps(); + fwd.applyFeePolicy(); 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.prank(rando); // apply is permissionless: the announcement is the authorization + fwd.applyFeePolicy(); + assertEq(fwd.targetPpm(), 2_500); + assertEq(fwd.floorPpm(), 3_000); + assertEq(fwd.pendingFeePolicyEffectiveAt(), 0); + + vm.expectRevert(VortexForwarder.NoPendingFeePolicy.selector); + fwd.applyFeePolicy(); + } - vm.expectRevert(VortexForwarder.NoPendingFee.selector); - fwd.applyFeeBps(); + function test_setFeePolicy_raisingEitherValueIsAnIncrease() public { + fwd.setFeePolicy(1_000, 1_600); // target down, floor up: timelocked as a whole + assertEq(fwd.targetPpm(), TARGET_PPM); + assertEq(fwd.floorPpm(), FLOOR_PPM); + assertEq(fwd.pendingTargetPpm(), 1_000); + assertEq(fwd.pendingFloorPpm(), 1_600); } - function test_setFeeBps_decreaseIsImmediateAndCancelsPending() public { - // Raise to 50 through the timelock first. - fwd.setFeeBps(50); + function test_setFeePolicy_decreaseIsImmediateAndCancelsPending() public { + fwd.setFeePolicy(2_500, 3_000); 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); + fwd.applyFeePolicy(); + + fwd.setFeePolicy(4_000, 4_000); // announce a further increase + fwd.setFeePolicy(1_000, 1_200); // decrease before it applies: immediate, cancels + assertEq(fwd.targetPpm(), 1_000); + assertEq(fwd.floorPpm(), 1_200); + assertEq(fwd.pendingFeePolicyEffectiveAt(), 0); vm.warp(block.timestamp + 24 hours); - vm.expectRevert(VortexForwarder.NoPendingFee.selector); - fwd.applyFeeBps(); + vm.expectRevert(VortexForwarder.NoPendingFeePolicy.selector); + fwd.applyFeePolicy(); } - function test_setFeeBps_reannounceReplacesAndRestartsClock() public { - fwd.setFeeBps(50); + function test_setFeePolicy_reannounceReplacesAndRestartsClock() public { + fwd.setFeePolicy(2_500, 3_000); vm.warp(block.timestamp + 12 hours); - fwd.setFeeBps(80); // replaces the pending 50 and restarts the 24h clock - assertEq(fwd.pendingFeeBps(), 80); + fwd.setFeePolicy(4_000, 4_000); // replaces the pending pair and restarts the 24h clock + assertEq(fwd.pendingTargetPpm(), 4_000); - vm.warp(block.timestamp + 12 hours + 1); // 24h after FIRST announcement only + vm.warp(block.timestamp + 12 hours + 1); // 24h after the FIRST announcement only vm.expectRevert(VortexForwarder.DelayNotElapsed.selector); - fwd.applyFeeBps(); + fwd.applyFeePolicy(); vm.warp(block.timestamp + 12 hours); - fwd.applyFeeBps(); - assertEq(fwd.feeBps(), 80); + fwd.applyFeePolicy(); + assertEq(fwd.targetPpm(), 4_000); + assertEq(fwd.floorPpm(), 4_000); } - 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_setFeePolicy_restatingCurrentCancelsWithoutChange() public { + fwd.setFeePolicy(2_500, 3_000); + fwd.setFeePolicy(TARGET_PPM, FLOOR_PPM); // re-state the current values: cancel-only gesture + assertEq(fwd.targetPpm(), TARGET_PPM); + assertEq(fwd.floorPpm(), FLOOR_PPM); + assertEq(fwd.pendingFeePolicyEffectiveAt(), 0); } - function test_swapDuringPendingIncrease_usesOldFee() public { - fwd.setFeeBps(50); // pending, not applied + function test_swapDuringPendingIncrease_usesOldPolicy() public { + fwd.setFeePolicy(2_500, 3_000); // 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); + router.setNextOut(1_145e6); + _keeperSwap(1_000e18); + // The fee closes the gap to the OLD target: the announced policy never touches a swap. + assertEq(usdc.balanceOf(feeRecipient), 1_145e6 - TARGET_1K); + assertEq(usdc.balanceOf(address(fwd)), TARGET_1K); } } diff --git a/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol b/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol new file mode 100644 index 000000000..bc44efe79 --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {IERC20, IVortexForwarderFactory} from "../src/VortexForwarder.sol"; +import {VortexSubsidyVault} from "../src/VortexSubsidyVault.sol"; +import {MockERC20} from "./VortexForwarder.t.sol"; + +/// Minimal stand-in for the factory: the vault only reads the guardian and the clone registry. +contract MockFactory { + address public guardian; + mapping(address => bool) public isForwarder; + + constructor(address guardian_) { + guardian = guardian_; + } + + function register(address forwarder, bool enabled) external { + isForwarder[forwarder] = enabled; + } +} + +contract VortexSubsidyVaultTest is Test { + MockERC20 usdc; + MockFactory factory; + VortexSubsidyVault vault; + + address treasury = makeAddr("treasury"); + address forwarder = makeAddr("forwarder"); + address rando = makeAddr("rando"); + + uint32 constant MAX_SUBSIDY_PPM = 5_000; // 50 bps + uint256 constant DAILY_BUDGET = 100e6; // 100 USDC + uint256 constant REFERENCE_OUT = 10_000e6; // a EUR 10k swap at reference -> cap = 50 USDC + + function setUp() public { + usdc = new MockERC20("USDC", 6); + factory = new MockFactory(address(this)); + factory.register(forwarder, true); + vault = new VortexSubsidyVault( + IERC20(address(usdc)), treasury, IVortexForwarderFactory(address(factory)), MAX_SUBSIDY_PPM, DAILY_BUDGET + ); + usdc.mint(address(vault), 1_000e6); + } + + function test_pay_onlyRegisteredForwarders() public { + vm.prank(rando); + vm.expectRevert(VortexSubsidyVault.NotForwarder.selector); + vault.pay(1e6, REFERENCE_OUT); + + factory.register(forwarder, false); + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.NotForwarder.selector); + vault.pay(1e6, REFERENCE_OUT); + } + + function test_pay_transfersAndCountsAgainstTheDay() public { + vm.prank(forwarder); + vault.pay(30e6, REFERENCE_OUT); + assertEq(usdc.balanceOf(forwarder), 30e6); + assertEq(vault.spentToday(), 30e6); + assertEq(vault.currentDay(), block.timestamp / 1 days); + } + + function test_pay_enforcesPerSwapCap() public { + vm.prank(forwarder); + vault.pay(50e6, REFERENCE_OUT); // exactly the cap is fine + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); + vault.pay(50e6 + 1, REFERENCE_OUT); + } + + function test_pay_enforcesDailyBudget_andResetsNextDay() public { + vm.startPrank(forwarder); + vault.pay(50e6, REFERENCE_OUT); + vault.pay(50e6, REFERENCE_OUT); // budget fully used + vm.expectRevert(VortexSubsidyVault.BudgetExhausted.selector); + vault.pay(1, REFERENCE_OUT); + + vm.warp((block.timestamp / 1 days + 1) * 1 days); // next UTC day + vault.pay(50e6, REFERENCE_OUT); + assertEq(vault.spentToday(), 50e6); + vm.stopPrank(); + } + + function test_pay_revertsWhenPausedOrUnderfunded() public { + vault.setPaused(true); + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.VaultPaused.selector); + vault.pay(1e6, REFERENCE_OUT); + vault.setPaused(false); + + vault.withdraw(1_000e6); // drain to treasury + assertEq(usdc.balanceOf(treasury), 1_000e6); + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.TransferFailed.selector); + vault.pay(1e6, REFERENCE_OUT); + } + + function test_guardianAuthority_gated() public { + vm.startPrank(rando); + vm.expectRevert(VortexSubsidyVault.NotGuardian.selector); + vault.setMaxSubsidyPpm(1); + vm.expectRevert(VortexSubsidyVault.NotGuardian.selector); + vault.setDailyBudget(1); + vm.expectRevert(VortexSubsidyVault.NotGuardian.selector); + vault.setPaused(true); + vm.expectRevert(VortexSubsidyVault.NotGuardian.selector); + vault.withdraw(1); + vm.stopPrank(); + + vault.setMaxSubsidyPpm(1_000); + vault.setDailyBudget(1e6); + assertEq(vault.maxSubsidyPpm(), 1_000); + assertEq(vault.dailyBudget(), 1e6); + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); + vault.pay(10e6 + 1, REFERENCE_OUT); // new cap: 10 USDC + } + + function test_withdraw_onlyEverReachesTreasury() public { + vault.withdraw(400e6); + assertEq(usdc.balanceOf(treasury), 400e6); + assertEq(usdc.balanceOf(address(vault)), 600e6); + // There is no withdrawal signature that takes a recipient. + (bool ok,) = address(vault).call(abi.encodeWithSignature("withdraw(address,uint256)", rando, 1)); + assertFalse(ok); + } +} diff --git a/docs/README.md b/docs/README.md index a956d2bda..1928eee29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`operations-monerium-interface.md`](operations-monerium-interface.md) | Monerium onramp boundary across the OAuth and white-label apps plus the profile, address, IBAN, and payment interface reference | | [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | +| [`product-monerium-b2b-flow.md`](product-monerium-b2b-flow.md) | High-level end-to-end flow of the Monerium B2B onramp for Vortex, SulPayments and Monerium, with the open questions per party | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | | [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md) | Phase-2 proposal for the consumer (Safe + passkey) Monerium onramp; the B2B variant shipped | | [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index b582acc8f..d991a84cd 100644 --- a/docs/adr-0005-monerium-b2b-onramp.md +++ b/docs/adr-0005-monerium-b2b-onramp.md @@ -1,7 +1,12 @@ # 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 +2026-08-26; amended 2026-09-15 with reference-priced fee bands, the subsidy vault, the +route whitelist and the 7 day sweep; amended 2026-09-17 with whole-deposit settlement, the +Vortex-held recovery path and the removal of the client fallback role; amended 2026-09-18 +with the keeper's subsidy ladder, the per-swap subsidy cap and the spot reference — see the +amendment sections). 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 @@ -39,28 +44,39 @@ a fiat theft path and unambiguous custody. The whole design follows from closing 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 +- **Conversion policy** (amended 2026-09-15): the swap runs over one of the factory's + whitelisted Uniswap v3 routes — validated on chain to touch only EURe, EURC and USDC + on the immutable router — chosen by the caller through a route index; the caller also + supplies the partner reference rate, which the contract bounds to a band around + Chainlink EUR/USD (staleness ceiling kept). The fill is settled into fee bands + (amendment) and the Chainlink floor is enforced on the client's net after fee and + subsidy; exact approvals and atomic delta checks stay; the fee goes to an immutable treasury. -- **No upgradeability, ever.** Immutable-and-migratable: evolution (new pools, new - routes, contract fixes) happens by deploying a new implementation + factory and +- **No upgradeability, ever.** Immutable-and-migratable: evolution (new tokens, a new + router, contract fixes) happens by deploying a new implementation + factory and migrating clients clone-by-clone — never by mutating deployed code. The custody - argument depends on it. -- **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). + argument depends on it. Routes, the fee policy and the vault limits are bounded + *data* the guardian may change within immutable validation, not code. +- **No client key on the clone** (amended 2026-09-17; superseded the mandatory + self-custodied `fallbackAddress` of 2026-07-17 and its `sweep`/config functions and + dead-man sweep). The only exits are the client's fixed `destination` and, for a payment + the promised window was missed on, the client's refund wallet held by Vortex — see + the amendments of 2026-09-17 and 2026-10-01. A destination change means a new clone (runbook §5). +- **Never send raw EURe to a CEX destination** — EURe leaves a clone only to the router + or the client's refund wallet. +- **No on-contract redeem validator.** The forwarder's EIP-1271 still validates only the + link message. Returning a deposit to its sender happens off the clone: the keeper moves + the payment to the client's refund wallet and Vortex redeems from there (amendments + 2026-09-17 and 2026-10-01), so the whitelabel credentials plus the attestor key still + cannot drain a clone to an arbitrary IBAN. Monerium's issuer recovery stays 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). +- **Three distinct Vortex keys** (attestor / keeper / guardian), none able to redirect + funds; the keeper can move a payment only to the clone's fixed refund wallet and only + once the clone's batch has been open for `RECOVERY_DELAY` (amendments 2026-09-17 and + 2026-10-01); 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 @@ -68,32 +84,238 @@ Supporting decisions, all in force: 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, + `DEPOSIT_CONVERTED` / `DEPOSIT_RETURNED`, the last added 2026-09-17 for the refund + path) 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. +## Amendment 2026-09-15: reference-priced fee bands and the subsidy vault + +The partner agreement fixes the client's rate against a reference: the client receives +the Coinbase EURC-USDC reference minus 12.5 bps, and never worse than 15 bps below it. +A flat skim on whatever the DEX returns cannot express that, so the contract now settles +every fill into bands against a reference rate (decided with the partner; contracts were +not yet deployed, so this replaced the flat fee before launch with no migration): + +- **Reference rate** (superseded 2026-09-18 by the bid/ask midpoint, see the third + amendment). Before each swap the keeper computes a five-minute + volume-weighted average of Coinbase Exchange EURC-USDC one-minute candles (typical + price × volume), widened to an hour when the five minutes carry no volume, so a + single thin print on a weekend or outside business hours never becomes the reference + (suggested in review, 2026-09-15). It records price, window and time on the + execution row and passes the rate into `swap`. The contract rejects a reference outside + `MAX_REFERENCE_DEVIATION_BPS` of Chainlink; on the permissionless path the argument is + ignored and Chainlink is the reference. Reading the price from Vortex's own oracle on + Base was rejected: it blends a forex rate on weekdays and lives on another chain. +- **Fee bands** (per clone, ppm below the reference, `targetPpm` ≤ `floorPpm` ≤ + `MAX_FEE_PPM`, increases timelocked as before): a fill above `reference × (1 − + target)` gives the surplus to the treasury as fee, capped at `MAX_FEE_PPM`; a fill + between floor and target is passed through untouched; a fill below `reference × (1 − + floor)` is topped up to the floor from the vault. The 2.5 bps dead band is intended. +- **Subsidy vault.** One `VortexSubsidyVault` shared by every clone, treasury-funded, + pays only when called by a factory-registered clone, to that clone itself, within a + guardian-settable per-swap cap and UTC-daily + budget, can be paused, and withdraws only to the treasury. A vault that cannot cover + reverts the whole swap, and the clone reverts unless exactly the shortfall arrived at + its destination — a swap is never partially subsidized and a guardian-set vault cannot + harm the client. The vault holds Vortex money only, so its limits bound Vortex's + exposure, never the client's. +- **Floor on the net.** `SLIPPAGE_BPS` (60 bps since the 2026-09-17 amendment; 40 at this amendment) is enforced on fill − fee + subsidy, + not on the raw fill; the router minimum is zero and the forwarder's post-condition is + the guard, so a subsidy can never paper over a depegged reference. +- **Route whitelist.** The factory holds guardian-managed routes, validated on chain to + EURe/EURC/USDC only, Uniswap's four tiers, at most two hops, the immutable router; + entries are disabled, never removed. The keeper quotes every enabled route and passes + the best index; a poor pick costs Vortex fee or subsidy, never the client, because the + floor applies whichever route runs. On-chain best-of was rejected (quoter gas). +- **Keeper deferral.** The keeper mirrors the settlement off-chain and, when the vault + could not cover, the net would breach the floor, the reference is unavailable or out + of band, or no route quotes, it defers: no execution row, funds wait, marker armed. +- **Accepted limitation.** After the 24 h trigger anyone may execute the swap, priced + against Chainlink and unsubsidized, so a forced swap after a deliberate deferral can + land below the 15 bps floor. Accepted: the trigger exists so no Vortex outage can trap + funds; the rate guarantee applies to keeper-executed swaps and the terms say so. + Pausing instead of deferring was rejected as turning every market dip into an + operator incident. +- **Accepted exposure.** The subsidy widens the sandwich-exploitable band from the + floor to floor plus the per-swap cap, paid by the vault; private orderflow and a + modest cap are the mitigation, and the permissionless path keeps the plain floor. + +## Amendment 2026-09-17: whole-deposit settlement and the refund path + +Product requirements from the partner (SulPayments): one USDC transfer per bank +payment, and an automatic refund of the exact EUR amount to the payer's bank account +when a payment cannot be converted inside the promised window. Vortex holding the funds +for that refund is agreed commercially. Decisions (the proposal that led here, +`proposal-monerium-b2b-settlement-and-recovery.md`, is in git history): + +- **Chunks accumulate on the clone; one forward per payment.** `swap(reference, route, + amountIn)` converts an explicit chunk and keeps the USDC (subsidy included) on the + clone; `forward(amount)` pushes the whole converted payment to `destination`. The + keeper serves one deposit at a time (1 deposit : N swap executions), so deposits never + share a swap and the N:M attribution of 2026-08 is gone. Chosen over a shared + settlement escrow contract: smallest audit delta, per-client blast radius, USDC never + leaves the client's clone until it goes to the destination. +- **Vortex-held recovery wallet, on-chain delay** (the single company-profile wallet was + replaced by per-client refund wallets, amendment 2026-10-01). `recover(eure, usdc)` is keeper-only, + pays only the immutable `RECOVERY_WALLET` — one wallet linked to a Vortex/SatoshiPay + company profile at Monerium — and only once the clone's batch marker has been open for + `RECOVERY_DELAY` = **2 hours** (immutable, P3). The clock starts when funds first + arrive on the clone; a chunk swap never re-times it; a forward or recovery re-times + whatever remains. The refund then leaves the Monerium profile that wallet belongs to: + the recovered USDC is swapped back to EURe, a separate float wallet covers the + slippage residue (the loss ledger), and a redeem order returns the exact issue amount + to the payer's IBAN (payer IBAN and name come from the issue order). Manual per the + runbook until automated. +- **Client fallback role removed.** `fallbackAddress`, `sweep`, `setDestination`, + `setClientPaused` and the dead-man sweep are gone (the client never held a key in + the pilot; the partner warrants the destination, B5). A destination change means a new + clone (runbook §5). The permissionless `swap`/`forwardAll` path after `TRIGGER_DELAY` + stays as the liveness guarantee, so a Vortex outage never traps converted funds. +- **Trust statement (replaces "Vortex keys cannot move client funds").** Vortex keys can + move a client's funds only to the Vortex recovery wallet, only after `RECOVERY_DELAY`, + and the contract can never send anywhere else. Consequences carried to G1 (Monerium + re-approval of the Vortex-held fallback and of one company profile refunding many + client corporates), G2 (custody scoping) and the partner terms (rollout §Terms 6). +- **Refund triggers.** Missed window (automated in a later phase; operator-triggered via + the admin endpoint until then), operator intervention, and remainders below + `minSwapAmount` (they cannot be swapped and are refunded). Chunk fees already taken on + a refunded payment stay in the treasury and are netted in the ledger. +- **`SLIPPAGE_BPS` 40 → 60.** With a 2 h promise a weekend Chainlink gap that defers a + swap turns into a refund, so the operating tolerance to a stale round (`SLIPPAGE_BPS − + floorPpm`) moves from ~25 to ~45 bps; the twelve-month replay of the Coinbase + EURC-USDC market against Chainlink shows ~80 h/year of floor-cause deferral at 40 bps + over ten weekends and two five-minute blips at 60. The keeper's worst-case pricing + power on the client widens by 20 bps in exchange. A genuine depeg beyond the 100 bps + band still defers and, past the window, refunds. +- **Dormancy.** A dormant or suspended account still recovers its marked deposits (the + refund path is for payments nobody converts), so a deposit into a dormant account is + refunded rather than parked. + +## Amendment 2026-09-18: subsidy ladder, per-swap subsidy cap, spot reference + +- **The subsidy escalates with the time a chunk has waited.** Product wants the keeper + to wait for the market before Vortex pays a shortfall, and to pay more the longer a + chunk waits. The ladder is a Vortex spending policy, not a client protection (the + client's floor never moves), so it lives in the keeper (`MONERIUM_B2B_SUBSIDY_LADDER`, + seconds waited → max bps of the reference value; launch: 0 bps for six minutes, then + 10/20/30/40/50 bps in two-minute steps, 100 bps from minute sixteen, held until the + refund deadline). The clock runs per chunk, from the mint or the previous chunk's + confirmation, and the keeper re-quotes every cycle (`MONERIUM_B2B_KEEPER_CYCLE_SECONDS`, + 20 s). Deferred attempts log the shortfall so the ladder is tuned from data. A + contract-side ladder was considered and rejected: the contract cannot observe a try, + a keeper-supplied tier index would be unverifiable, and time-tiered vault caps would + buy enforcement against a keeper the design already bounds by the vault's cap and + budget. +- **The tier binds on chain anyway (`swap(..., maxSubsidy)`).** The keeper passes its + tier as a per-swap cap and the forwarder refuses a top-up above it, so a fill that + moved between the quote and the swap cannot draw more than the tier. The contract + learns nothing about time or ladders; the vault's cap (`maxSubsidyPpm`, to be raised to + the ladder's top, 100 bps) and daily budget remain the hard bounds (P13 note below). +- **Spot reference instead of the VWAP.** An average lags a moving market, and in a + falling one the lag turns into subsidy. The reference is now the Coinbase Exchange + EURC-USDC bid/ask midpoint read just before the swap (P12): no averaging, no lag; the + midpoint rather than the last trade because a last print can be one-sided or stale on a + quiet weekend, and a spread above 50 bps makes the keeper defer rather than price + against a thin book. +- **Weekend drift is paid, not refunded.** The Chainlink floor (`SLIPPAGE_BPS`) now + bounds the fee target and the subsidy floor from below: when the reference sits more + than ~45 bps under a stale Chainlink round, the fee gives way first and then the + keeper's tier-bounded subsidy lifts the client's net to Chainlink − 60 bps, within the + vault's cap, instead of the swap reverting and the payment refunding after the window. + The client never gets less than the floor, occasionally more than the reference deal; + Vortex pays the difference, bounded by the ladder's tier and the vault. A depeg beyond + what the tier and the vault cover (the 2025-10 weekend needed ~440 bps) still reverts + and refunds; the permissionless path still pays nothing. `SLIPPAGE_BPS` thus stays the + hard line for what a compromised keeper can do to the client, and the ladder's top tier + becomes the runtime knob for how much drift Vortex absorbs. The drift replay that sized `SLIPPAGE_BPS` was rerun on spot + (2026-09-18, one-minute closes of Coinbase EURC-USDC as the midpoint's proxy vs the + Chainlink rounds, 2025-09-18 to 2026-09-18, 88% of minutes traded; historical bid/ask + is not public): weekend median −5.3 bps, p5 −26.5. Time a floor fill would breach the + oracle floor: at 40 bps 122 h/year over 12 weekends with ten weekend episodes longer + than the 2 h window; at 60 bps 48 h/year of which 47.8 h are the 2025-10-11/12 depeg + weekend (out of the 100 bps band anyway) and the rest six blips of one to five + minutes on three weekends. Spot is noisier than the VWAP at 40 bps and identical at + 60; the 60 bps decision stands. + +## Amendment 2026-09-29: pilot parameter changes + +- **No practical swap minimum.** The team decided not to enforce a meaningful minimum + amount. The immutable `MIN_SWAP_FLOOR` and the operational `minSwapAmount` are both + €1 (P6; €25 and €250 before). Almost every payment now converts instead of waiting + for the refund path as sub-minimum. Accepted consequences: a very small payment costs + Vortex more gas than it earns; a small payment forwarded to an exchange address can + land below that exchange's minimum deposit, which now sits with the client under B5 + instead of Vortex's minimum-forward diligence; and unsolicited EURe from €1 upward arms + a clone's batch timers (from €25 before), which the stranded-balance monitor still + reports. The guardian can raise the operational minimum at any time without a + redeploy; it can never go below €1. +- **€10k chunks.** `perSwapCap` is €10k (P7; €25k before). The €25k was an operational + choice from the pre-deploy liquidity baseline, not a limit. Smaller chunks mean less + price impact per swap and a smaller top-up per chunk at the ladder's top (about + 115 USDC instead of 285), at the cost of more transactions and more time for large + payments. When the market needs the ladder's top tier, each chunk can wait up to + 16 minutes, so a payment above roughly €70k could reach the two-hour window; the + €50k per client per day pilot limit (B4) keeps a payment at five chunks or fewer. The + guardian can change the cap at any time up to the €50k ceiling. +- **Penny test optional.** The penny test (B2) was never agreed as a mandatory step. It + is an optional check, recommended for exchange destinations, and not an activation + requirement. A wrong or rotated destination is now caught by the penny test only when + one is run, otherwise by the dormancy gate; the loss allocation under B5 is unchanged. + +## Amendment 2026-10-01: per-client refund wallets + +- **One refund wallet per client, fixed in its forwarder.** The implementation-wide + `RECOVERY_WALLET` is gone. Each clone takes a `recoveryAddress` at deployment + (`deployForwarder(destination, recoveryAddress, targetPpm, floorPpm, salt)`), with no + setter, rejected when zero, a token, the router, the clone itself or equal to the + destination. `recover` pays only that address, still keeper-only and only after + `RECOVERY_DELAY`. +- **A plain wallet, derived.** The refund wallet is an EOA whose key is + `keccak256(MONERIUM_B2B_REFUND_SEED ++ "vortex-b2b-refund:" ++ moneriumProfileId)`, so one + secret covers every client, the address is known before the clone is deployed (admin + `GET /v1/admin/monerium-b2b/refund-address`), and a replacement clone for the same client + keeps the same wallet. Account mapping refuses a clone whose `recoveryAddress` is not + the derived wallet. A refund contract was considered and deferred: while refunds go to + whichever IBAN paid in, a contract cannot know the payer and would sign whatever the + keeper supplies, the same trust as a held key, with more audit surface. It becomes + worthwhile with a fixed refund IBAN per client, when it can refuse every other IBAN; + switching an existing client then means a new clone (runbook §5). +- **Linked to the client's profile, refunded from the client's IBAN.** Onboarding links + the refund wallet to the client's Monerium profile next to the forwarder (an address + belongs to exactly one profile, confirmed by Monerium 2026-09-30). The refund runs on + that wallet as before: reverse swap, float top-up to the exact amount, redeem to the + payer, which Monerium pays out of the client's own IBAN. The float also tops up the + wallet's ETH for its own transactions, sized from the current gas price. No Vortex + company profile at Monerium is needed, and recovered funds of different clients never + share a wallet. `MONERIUM_B2B_RECOVERY_PRIVATE_KEY` is replaced by + `MONERIUM_B2B_REFUND_SEED`, required whenever the module is enabled. + ## 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 | +| B1 | Fee policy | **target 1250 ppm (12.5 bps), floor 1500 ppm (15 bps) below the reference**, per client, guardian-adjustable (amended 2026-09-15; replaces the flat 0 / 15 bps skim) | +| B2 | Penny-test amount | 5 USDC, **optional** (amended 2026-09-29): recommended for exchange destinations, not an activation requirement | | B3 | Processing SLA wording | **Same business day**; weekend mints execute within the 52 h oracle window at possibly wider spreads | | B4 | Pilot volume limits | **€50k/client/day, paper/contractual only** (no backend enforcement in the pilot; GA revisit) | | B5 | Partner liability | Tier A defaults: partner warrants destination correctness; rotation loss borne by the client; dormancy re-activation on written partner confirmation | | B6 | Redemption-limitation disclosure | Mandatory in client terms (committed to Monerium); draft in the rollout doc | -| P1 | `SLIPPAGE_BPS` | 100 (1%) | -| P2 | `MAX_FEE_BPS` | 100 (1%), immutable | -| P3 | Dead-man sweep delay | 60 days | +| P1 | `SLIPPAGE_BPS` | **60 bps on the client's net after fee and subsidy** (amended 2026-09-17; 40 from 2026-09-15, 100 on the raw fill before) | +| P2 | `MAX_FEE_PPM` | 10000 ppm (1%), immutable; caps both the fee and the floor policy (amended 2026-09-15; was `MAX_FEE_BPS` 100) | +| P3 | `RECOVERY_DELAY` | **2 hours** (amended 2026-09-17): the promised conversion window, enforced on chain as the earliest a payment may move to the recovery wallet. Replaces the dead-man sweep delay (7 days on 2026-09-15, 60 before), which had no target left once the fallback role was removed | | 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) | +| P6 | `minSwapAmount` | floor **€1** (immutable) / operational **€1**: no practical minimum (amended 2026-09-29; €25 / €250 before) | +| P7 | `perSwapCap` | operational **€10k** / ceiling €50k (amended 2026-09-29; €25k before; 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) | +| P10 | Router pin and routes | SwapRouter02 immutable; routes are a guardian-managed, on-chain validated whitelist (EURe/EURC/USDC, four tiers, ≤ 2 hops); initial route EURe→EURC→USDC at the 5 bps tiers, re-verify at the deploy block (amended 2026-09-15) | +| P11 | Fee adjustability | Guardian `setFeePolicy(target, floor)` within `MAX_FEE_PPM`; raising either value is announced and applies after 24 h, lowering is immediate (amended 2026-09-15) | +| P12 | Reference rate | **Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap, deferring on a spread above 50 bps** (amended 2026-09-18; from 2026-09-15 a five-minute VWAP over one-minute candles widened to 60 min on no volume), keeper-computed per swap; `MAX_REFERENCE_DEVIATION_BPS` **100** (immutable, to confirm before deploy: must tolerate a weekend Chainlink gap); permissionless path uses Chainlink (2026-09-15). The floor on the net binds first: with `floorPpm` 15 bps and `SLIPPAGE_BPS` 40 bps, a reference more than `SLIPPAGE_BPS − floorPpm` ≈ 25 bps below Chainlink makes every normal fill (fee band or subsidized) revert on chain and defer off chain, so ~25 bps is the working downside margin against a stale round; the 100 bps band is the outlier ceiling for a keeper-supplied value, not the operating tolerance (2026-09-16) | +| P14 | Subsidy ladder | **`MONERIUM_B2B_SUBSIDY_LADDER` = `0:0,360:10,480:20,600:30,720:40,840:50,960:100`** (2026-09-18; keeper policy, tunable from deferral logs); per-chunk clock; the vault's per-swap cap must be at least the ladder's top | +| P13 | Subsidy vault limits | One shared vault; **50 bps of the reference value per swap, 200 USDC per UTC day** at launch, guardian-settable; withdraw to treasury only (2026-09-15) | | T2 | Whitelabel MSA terms | Open — G1 negotiation (rollout doc), includes the per-IBAN suspension ask | | T3 | KYB submission mechanism | Open, deliberately unbuilt — pilot corporates are approved by Monerium under partner KYC reliance and imported via the admin mapping; no identity-data submission path may exist until this settles (security-spec invariant 11) | | 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 | @@ -127,16 +349,32 @@ example (oversized-deposit allocation). 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 + with the dormancy gate and an optional penny test. 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). +- **Vortex custody on the refund path** (amendment 2026-09-17). A recovered payment + sits in Vortex's own wallet until the bank refund goes out; a compromised keeper plus + recovery key could divert a payment the window was missed on. Bounded by the immutable + wallet, the on-chain delay, explicit amounts, a dedicated linked address holding + nothing else, and the association monitor; accepted commercially by the partner and + carried to G1/G2. +- **Broken destination** — with no client key on the clone, a wrong destination is + caught by an optional penny test or by the dormancy gate; a rotation loss is borne by the + client/partner (B5); a destination change is a new clone. - **Non-custody ≠ out of MiCA scope.** The constrained-attestor construction defeats the custody definition, but exchange/transfer-service scoping is a separate G2 question. Never present "no custody" as "no licence needed". - **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. + destination, reference feed outage, exhausted subsidy budget): all fail-safe — swaps + revert or the keeper defers, funds accumulate as EURe; past the promised window the + payment is refunded through the client's refund wallet, past 24 h anyone may convert and + forward permissionlessly; the issuer backstop remains. Accepted. +- **Bounded keeper pricing power.** A compromised keeper can pick any whitelisted route + and any reference inside the Chainlink band: worst case the fee reaches `MAX_FEE_PPM` + or the vault pays up to its caps. Bounded by the band, the fee cap, the vault limits + and the floor on the net; it can still never redirect funds — only, after the on-chain + delay, move them to the client's refund wallet. Accepted. +- **Subsidy exposure.** Up to the per-swap cap per swap and the daily budget per day, + plus the widened sandwich band (amendment). Accepted; both limits are live-tunable. - **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 @@ -145,8 +383,11 @@ example (oversized-deposit allocation). ## 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. +accepted, IBAN issued, no client interaction). A Vortex outage can never trap converted +funds (the permissionless path), and a payment the promised window was missed on is +refunded rather than parked, at the price of Vortex custody on that path. The cost: every rescue path must be designed in upfront +(no universal owner key), fee-policy increases are timelocked and venue changes are +bounded by on-chain route validation rather than admin switches, the partner's rate +guarantee is enforced by the contract at the cost of a treasury-funded subsidy budget, +and Vortex accepts elevated provisioning trust plus a control-plane risk at Monerium +that only contract terms and monitoring can bound. diff --git a/docs/api/apidog/page-manifest.json b/docs/api/apidog/page-manifest.json index bdd7a412a..7b355561c 100644 --- a/docs/api/apidog/page-manifest.json +++ b/docs/api/apidog/page-manifest.json @@ -47,6 +47,7 @@ "/v1/managed-profiles/{profileId}/api-credentials", "/v1/managed-profiles/{profileId}/api-credentials/{credentialId}", "/v1/monerium-b2b/account", + "/v1/monerium-b2b/accounts", "/v1/monerium-b2b/deposits", "/v1/onboarding/active-entity", "/v1/onboarding/requirements", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index c5fa4d9e4..e82ddab72 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -970,6 +970,28 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/monerium-b2b/accounts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the manager's EUR onramp accounts + * @description Available in sandbox; production activation is pending. Lists the business EUR onramp accounts of every active managed profile under the calling manager, newest first, each with its IBAN and status. Filter by `moneriumProfileId` to find one client by the EUR provider's profile ID. Manager credential only: the `X-Managed-Profile-Id` header is rejected with 400 and a child's own credential with 403. + * + * **Auth:** `X-API-Key` or Supabase Bearer. + */ + get: operations["listMoneriumB2bAccounts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/monerium-b2b/deposits": { parameters: { query?: never; @@ -979,7 +1001,7 @@ export interface paths { }; /** * List the acting profile's EUR deposits - * @description Available in sandbox; production activation is pending. 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. + * @description Available in sandbox; production activation is pending. Returns the acting profile's EUR deposits newest first, each as its full lifecycle snapshot: IDs, amounts, timestamps, waiting and refund reasons, and every conversion chunk. A per-swap cap can split one deposit into several chunks, delivered together in one transfer. This is the polling surface; the `DEPOSIT_UPDATED` webhook pushes the same snapshot on every change. 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. */ @@ -2869,50 +2891,145 @@ export interface components { * @description Set while the account is dormancy-paused. */ dormantSince: string | null; - /** @description The client's self-custodied recovery address. */ - fallbackAddress: string; - feeBps: number; + /** @description Your own client reference for this managed profile. */ + externalSubjectId: string | null; + /** @description Fee policy floor in parts per million below the reference rate: the least the client receives on a keeper-executed swap. */ + floorPpm: number; /** @description The account's on-chain forwarding contract. */ forwarderAddress: string; /** @description The account's dedicated IBAN; null until issuance completes. */ iban: string | null; + /** @description The EUR provider's profile ID for this client. */ + moneriumProfileId: string; + /** @description The client's managed profile ID: the `X-Managed-Profile-Id` value for delegated calls. */ + profileId: string | null; /** @enum {string} */ status: "onboarding" | "active" | "suspended" | "closed"; + /** @description Fee policy target in parts per million below the reference rate: what the client receives whenever the swap allows it. */ + targetPpm: number; }; MoneriumB2bAccountResponse: { account: components["schemas"]["MoneriumB2bAccount"]; }; + MoneriumB2bAccountsResponse: { + accounts: components["schemas"]["MoneriumB2bAccount"][]; + pagination: { + limit: number; + offset: number; + total: number; + }; + }; MoneriumB2bDeposit: { + accountId: string; + /** @description EUR amount to the cent, for example "1234.56". */ + amount: string; /** @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. */ + /** @description The chunk swaps of this deposit, oldest first. Empty while the deposit awaits conversion; a deposit larger than the per-swap cap is converted in several chunks that accumulate on the forwarding contract until one transfer delivers them all. Chunks are never shared between deposits. */ conversions: { - /** @description EURe from this deposit consumed by the execution in 18-decimal base units. */ + /** + * Format: date-time + * @description When the chunk's swap confirmed; null while pending. + */ + confirmedAt: string | null; + /** @description EURe of this deposit consumed by the chunk in 18-decimal base units. */ eureInRaw: string; + /** @description The chunk's pricing: the reference rate it was settled against, the fee taken above the target band, and the subsidy paid to reach the floor. Null values while the chunk is not yet confirmed. */ + execution: { + /** @description Fee taken on the chunk in 6-decimal base units. */ + feeRaw: string | null; + /** @description Reference EUR/USD rate the execution was priced against: the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap, in the oracle's decimals (8). */ + referenceRateRaw: string | null; + /** @description Subsidy paid by the vault onto the forwarding contract for the chunk, delivered with the deposit's transfer, in 6-decimal base units. */ + subsidyRaw: string | null; + }; executionId: string; /** - * @description Execution status. + * Format: date-time + * @description When the chunk's swap was sent. + */ + sentAt: string; + /** + * @description Chunk status. Failed attempts are retried and not listed. * @enum {string} */ - status: "pending" | "confirmed" | "failed"; - /** @description The swap-and-forward transaction hash. */ + status: "pending" | "confirmed"; + /** @description The chunk swap transaction hash. */ txHash: string | null; - /** @description Net USDC from this execution attributed to this deposit in 6-decimal base units. */ + /** @description Net USDC of the chunk (fill minus fee plus subsidy) in 6-decimal base units. */ usdcNetRaw: string; }[]; - /** Format: date-time */ - createdAt: string; currency: string; + /** + * Format: date-time + * @description When the single transfer to the destination confirmed; null until forwarded. + */ + deliveredAt: string | null; depositId: string; + /** @description Your own client reference for this managed profile. */ + externalSubjectId: string | null; + /** @description The single transaction that delivered the whole converted deposit to the destination; null until the deposit is forwarded. */ + forwardTxHash: string | null; /** - * @description Deposit status (forward-only). + * Format: date-time + * @description When the EUR arrived on chain as EURe; the conversion window counts from here. + */ + mintedAt: string | null; + /** @description The EUR provider's order ID for the incoming payment. */ + moneriumOrderId: string; + /** @description The EUR provider's profile ID for this client. */ + moneriumProfileId: string; + /** @description The client's managed profile ID: the `X-Managed-Profile-Id` value for delegated calls. */ + profileId: string; + /** + * Format: date-time + * @description When Vortex first saw the payment. + */ + receivedAt: string; + /** @description Present once the deposit entered the refund path: why, when it started, the EUR amount refunded, the masked IBAN it goes to, the provider's redemption order ID, and the transaction that moved the deposit off the forwarding contract. Null otherwise. */ + refund: { + /** @description The EUR amount refunded, to the cent: always the full issue amount. */ + amount: string | null; + /** @description The payer's IBAN the refund goes to, masked to its first and last four characters. */ + payerIbanMasked: string | null; + /** + * @description Why the deposit is refunded: `window_missed` (not converted within the promised window), `compliance`, `incident`, or `operator` for another operator decision. + * @enum {string|null} + */ + reason: "window_missed" | "compliance" | "incident" | "operator" | null; + /** @description The transaction that moved the deposit off the forwarding contract. */ + recoverTxHash: string | null; + /** @description The provider's order ID for the outgoing SEPA refund. */ + redeemOrderId: string | null; + /** + * Format: date-time + * @description When the provider processed the refund; null until then. + */ + refundedAt: string | null; + /** + * Format: date-time + * @description When the deposit entered the refund path. + */ + startedAt: string | null; + } | null; + /** @description The provider's reason when it returned the payment before minting. */ + rejectedReason: string | null; + /** + * @description Deposit status (forward-only): the provider states, then `converting` and `forwarded`, or - when the deposit could not be converted within the promised window - `recovering`, `refunded` and `recovery_failed`. * @enum {string} */ - status: "pending" | "minted" | "held" | "returned"; + status: "pending" | "minted" | "held" | "returned" | "converting" | "forwarded" | "recovering" | "refunded" | "recovery_failed"; /** @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. */ + /** @description Sum of the confirmed chunks' net USDC in 6-decimal base units: what the deposit's single transfer delivers once forwarded. */ usdcNetRaw: string; + /** @description Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. Afterwards, the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise. */ + waiting: { + /** @enum {string} */ + reason: "monerium_pending" | "oracle_unavailable" | "reference_unavailable" | "reference_out_of_band" | "no_route" | "below_floor"; + /** Format: date-time */ + since: string; + } | null; }; MoneriumB2bDepositsResponse: { deposits: components["schemas"]["MoneriumB2bDeposit"][]; @@ -6605,6 +6722,54 @@ export interface operations { }; }; }; + listMoneriumB2bAccounts: { + parameters: { + query?: { + /** @description Only the account of this EUR provider profile. */ + moneriumProfileId?: string; + /** @description Page size (default 20, max 100). */ + limit?: number; + /** @description Rows to skip (default 0). */ + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The manager's onramp accounts. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MoneriumB2bAccountsResponse"]; + }; + }; + /** @description Invalid `moneriumProfileId`, or a `X-Managed-Profile-Id` header was sent. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid credentials. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The caller is not an active manager for business EUR customers, or used a child's own credential. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listMoneriumB2bDeposits: { parameters: { query?: { diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 91c83534a..297e5afe9 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2522,11 +2522,12 @@ "format": "date-time", "type": ["string", "null"] }, - "fallbackAddress": { - "description": "The client's self-custodied recovery address.", - "type": "string" + "externalSubjectId": { + "description": "Your own client reference for this managed profile.", + "type": ["string", "null"] }, - "feeBps": { + "floorPpm": { + "description": "Fee policy floor in parts per million below the reference rate: the least the client receives on a keeper-executed swap.", "type": "integer" }, "forwarderAddress": { @@ -2537,9 +2538,21 @@ "description": "The account's dedicated IBAN; null until issuance completes.", "type": ["string", "null"] }, + "moneriumProfileId": { + "description": "The EUR provider's profile ID for this client.", + "type": "string" + }, + "profileId": { + "description": "The client's managed profile ID: the `X-Managed-Profile-Id` value for delegated calls.", + "type": ["string", "null"] + }, "status": { "enum": ["onboarding", "active", "suspended", "closed"], "type": "string" + }, + "targetPpm": { + "description": "Fee policy target in parts per million below the reference rate: what the client receives whenever the swap allows it.", + "type": "integer" } }, "required": [ @@ -2547,11 +2560,14 @@ "createdAt", "destination", "dormantSince", - "fallbackAddress", - "feeBps", + "externalSubjectId", + "floorPpm", "forwarderAddress", "iban", - "status" + "moneriumProfileId", + "profileId", + "status", + "targetPpm" ], "type": "object" }, @@ -2564,55 +2580,201 @@ "required": ["account"], "type": "object" }, + "MoneriumB2bAccountsResponse": { + "properties": { + "accounts": { + "items": { + "$ref": "#/components/schemas/MoneriumB2bAccount" + }, + "type": "array" + }, + "pagination": { + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": ["limit", "offset", "total"], + "type": "object" + } + }, + "required": ["accounts", "pagination"], + "type": "object" + }, "MoneriumB2bDeposit": { "properties": { + "accountId": { + "type": "string" + }, + "amount": { + "description": "EUR amount to the cent, for example \"1234.56\".", + "type": "string" + }, "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.", + "description": "The chunk swaps of this deposit, oldest first. Empty while the deposit awaits conversion; a deposit larger than the per-swap cap is converted in several chunks that accumulate on the forwarding contract until one transfer delivers them all. Chunks are never shared between deposits.", "items": { "properties": { + "confirmedAt": { + "description": "When the chunk's swap confirmed; null while pending.", + "format": "date-time", + "type": ["string", "null"] + }, "eureInRaw": { - "description": "EURe from this deposit consumed by the execution in 18-decimal base units.", + "description": "EURe of this deposit consumed by the chunk in 18-decimal base units.", "type": "string" }, + "execution": { + "description": "The chunk's pricing: the reference rate it was settled against, the fee taken above the target band, and the subsidy paid to reach the floor. Null values while the chunk is not yet confirmed.", + "properties": { + "feeRaw": { + "description": "Fee taken on the chunk in 6-decimal base units.", + "type": ["string", "null"] + }, + "referenceRateRaw": { + "description": "Reference EUR/USD rate the execution was priced against: the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap, in the oracle's decimals (8).", + "type": ["string", "null"] + }, + "subsidyRaw": { + "description": "Subsidy paid by the vault onto the forwarding contract for the chunk, delivered with the deposit's transfer, in 6-decimal base units.", + "type": ["string", "null"] + } + }, + "required": ["feeRaw", "referenceRateRaw", "subsidyRaw"], + "type": "object" + }, "executionId": { "type": "string" }, + "sentAt": { + "description": "When the chunk's swap was sent.", + "format": "date-time", + "type": "string" + }, "status": { - "description": "Execution status.", - "enum": ["pending", "confirmed", "failed"], + "description": "Chunk status. Failed attempts are retried and not listed.", + "enum": ["pending", "confirmed"], "type": "string" }, "txHash": { - "description": "The swap-and-forward transaction hash.", + "description": "The chunk swap transaction hash.", "type": ["string", "null"] }, "usdcNetRaw": { - "description": "Net USDC from this execution attributed to this deposit in 6-decimal base units.", + "description": "Net USDC of the chunk (fill minus fee plus subsidy) in 6-decimal base units.", "type": "string" } }, - "required": ["eureInRaw", "executionId", "status", "txHash", "usdcNetRaw"], + "required": ["confirmedAt", "eureInRaw", "execution", "executionId", "sentAt", "status", "txHash", "usdcNetRaw"], "type": "object" }, "type": "array" }, - "createdAt": { + "currency": { + "type": "string" + }, + "deliveredAt": { + "description": "When the single transfer to the destination confirmed; null until forwarded.", "format": "date-time", + "type": ["string", "null"] + }, + "depositId": { "type": "string" }, - "currency": { + "externalSubjectId": { + "description": "Your own client reference for this managed profile.", + "type": ["string", "null"] + }, + "forwardTxHash": { + "description": "The single transaction that delivered the whole converted deposit to the destination; null until the deposit is forwarded.", + "type": ["string", "null"] + }, + "mintedAt": { + "description": "When the EUR arrived on chain as EURe; the conversion window counts from here.", + "format": "date-time", + "type": ["string", "null"] + }, + "moneriumOrderId": { + "description": "The EUR provider's order ID for the incoming payment.", "type": "string" }, - "depositId": { + "moneriumProfileId": { + "description": "The EUR provider's profile ID for this client.", "type": "string" }, + "profileId": { + "description": "The client's managed profile ID: the `X-Managed-Profile-Id` value for delegated calls.", + "type": "string" + }, + "receivedAt": { + "description": "When Vortex first saw the payment.", + "format": "date-time", + "type": "string" + }, + "refund": { + "description": "Present once the deposit entered the refund path: why, when it started, the EUR amount refunded, the masked IBAN it goes to, the provider's redemption order ID, and the transaction that moved the deposit off the forwarding contract. Null otherwise.", + "properties": { + "amount": { + "description": "The EUR amount refunded, to the cent: always the full issue amount.", + "type": ["string", "null"] + }, + "payerIbanMasked": { + "description": "The payer's IBAN the refund goes to, masked to its first and last four characters.", + "type": ["string", "null"] + }, + "reason": { + "description": "Why the deposit is refunded: `window_missed` (not converted within the promised window), `compliance`, `incident`, or `operator` for another operator decision.", + "enum": ["window_missed", "compliance", "incident", "operator", null], + "type": ["string", "null"] + }, + "recoverTxHash": { + "description": "The transaction that moved the deposit off the forwarding contract.", + "type": ["string", "null"] + }, + "redeemOrderId": { + "description": "The provider's order ID for the outgoing SEPA refund.", + "type": ["string", "null"] + }, + "refundedAt": { + "description": "When the provider processed the refund; null until then.", + "format": "date-time", + "type": ["string", "null"] + }, + "startedAt": { + "description": "When the deposit entered the refund path.", + "format": "date-time", + "type": ["string", "null"] + } + }, + "required": ["amount", "payerIbanMasked", "reason", "recoverTxHash", "redeemOrderId", "refundedAt", "startedAt"], + "type": ["object", "null"] + }, + "rejectedReason": { + "description": "The provider's reason when it returned the payment before minting.", + "type": ["string", "null"] + }, "status": { - "description": "Deposit status (forward-only).", - "enum": ["pending", "minted", "held", "returned"], + "description": "Deposit status (forward-only): the provider states, then `converting` and `forwarded`, or - when the deposit could not be converted within the promised window - `recovering`, `refunded` and `recovery_failed`.", + "enum": [ + "pending", + "minted", + "held", + "returned", + "converting", + "forwarded", + "recovering", + "refunded", + "recovery_failed" + ], "type": "string" }, "txHash": { @@ -2620,11 +2782,54 @@ "type": ["string", "null"] }, "usdcNetRaw": { - "description": "Aggregate net USDC attributed to this deposit so far in 6-decimal base units.", + "description": "Sum of the confirmed chunks' net USDC in 6-decimal base units: what the deposit's single transfer delivers once forwarded.", "type": "string" + }, + "waiting": { + "description": "Present while the deposit waits. `monerium_pending` until the EUR provider mints it: minting or a compliance review, which the provider does not tell apart. Afterwards, the reason Vortex is holding the next conversion chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor` (the market is below the client's floor by more than the subsidy currently allows). Null otherwise.", + "properties": { + "reason": { + "enum": [ + "monerium_pending", + "oracle_unavailable", + "reference_unavailable", + "reference_out_of_band", + "no_route", + "below_floor" + ], + "type": "string" + }, + "since": { + "format": "date-time", + "type": "string" + } + }, + "required": ["reason", "since"], + "type": ["object", "null"] } }, - "required": ["amountRaw", "conversions", "createdAt", "currency", "depositId", "status", "txHash", "usdcNetRaw"], + "required": [ + "accountId", + "amount", + "amountRaw", + "conversions", + "currency", + "deliveredAt", + "depositId", + "externalSubjectId", + "forwardTxHash", + "mintedAt", + "moneriumOrderId", + "moneriumProfileId", + "profileId", + "receivedAt", + "refund", + "rejectedReason", + "status", + "txHash", + "usdcNetRaw", + "waiting" + ], "type": "object" }, "MoneriumB2bDepositsResponse": { @@ -8483,10 +8688,78 @@ "tags": ["Account Management"] } }, + "/v1/monerium-b2b/accounts": { + "get": { + "deprecated": false, + "description": "Available in sandbox; production activation is pending. Lists the business EUR onramp accounts of every active managed profile under the calling manager, newest first, each with its IBAN and status. Filter by `moneriumProfileId` to find one client by the EUR provider's profile ID. Manager credential only: the `X-Managed-Profile-Id` header is rejected with 400 and a child's own credential with 403.\n\n**Auth:** `X-API-Key` or Supabase Bearer.", + "operationId": "listMoneriumB2bAccounts", + "parameters": [ + { + "description": "Only the account of this EUR provider profile.", + "in": "query", + "name": "moneriumProfileId", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "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/MoneriumB2bAccountsResponse" + } + } + }, + "description": "The manager's onramp accounts." + }, + "400": { + "description": "Invalid `moneriumProfileId`, or a `X-Managed-Profile-Id` header was sent." + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "The caller is not an active manager for business EUR customers, or used a child's own credential." + } + }, + "security": [ + { + "SecretApiKey": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "List the manager's EUR onramp accounts", + "tags": ["Account Management"] + } + }, "/v1/monerium-b2b/deposits": { "get": { "deprecated": false, - "description": "Available in sandbox; production activation is pending. 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.", + "description": "Available in sandbox; production activation is pending. Returns the acting profile's EUR deposits newest first, each as its full lifecycle snapshot: IDs, amounts, timestamps, waiting and refund reasons, and every conversion chunk. A per-swap cap can split one deposit into several chunks, delivered together in one transfer. This is the polling surface; the `DEPOSIT_UPDATED` webhook pushes the same snapshot on every change. 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": [ { @@ -11024,7 +11297,7 @@ "properties": { "events": { "items": { - "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. Deposit events are available in sandbox; production activation is pending.", + "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\", \"DEPOSIT_RETURNED\", \"DEPOSIT_UPDATED\", \"ACCOUNT_UPDATED\"] must be requested explicitly, cannot be mixed with transaction events, require a profile-scoped secret credential, and take no quoteId/sessionId. Deposit events are available in sandbox; production activation is pending.", "type": "string" }, "type": "array" diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index a7931e091..c3d1ba433 100644 --- a/docs/api/pages/07-webhooks.md +++ b/docs/api/pages/07-webhooks.md @@ -6,7 +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. Business EUR onramp accounts are available in sandbox; production activation is pending. +- **Deposit events** — for partner managers with business EUR onramp accounts: a client's EUR deposit was received (`DEPOSIT_RECEIVED`), converted and forwarded (`DEPOSIT_CONVERTED`), or refunded because it could not be converted within the promised window (`DEPOSIT_RETURNED`). See [Deposit Events](#deposit-events) — they follow account-scoped rules and durable delivery. Business EUR onramp accounts are available in sandbox; production activation is pending. ## Security Model @@ -111,11 +111,12 @@ Managers whose business clients hold EUR onramp accounts can subscribe to deposi - 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`. +- For the full lifecycle, subscribe to `DEPOSIT_UPDATED` and `ACCOUNT_UPDATED`. The milestone events `DEPOSIT_RECEIVED`, `DEPOSIT_CONVERTED` and `DEPOSIT_RETURNED` remain for integrations that only need those three moments. ```json { "url": "https://manager.example.com/vortex/deposits", - "events": ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"] + "events": ["DEPOSIT_UPDATED", "ACCOUNT_UPDATED"] } ``` @@ -144,7 +145,7 @@ Fired once when a client's EUR deposit has been matched to the corresponding on- ### `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. +Fired once per deposit after the whole deposit has been converted and forwarded to the destination in a single transfer, and that transfer has reached a safe confirmation depth on chain. A deposit larger than the per-swap cap is converted in several chunks that accumulate on the forwarding contract; the destination still receives one transfer and you receive one event. ```json { @@ -157,28 +158,143 @@ Fired once per deposit after the full deposit has been converted and every contr "depositId": "9f6f6a7e-...", "amountRaw": "100000000000000000000", "currency": "eur", - "status": "minted", + "status": "forwarded", "txHash": "0x...", "conversions": [ { "eureInRaw": "60000000000000000000", + "execution": { "feeRaw": "81000", "referenceRateRaw": "108140000", "subsidyRaw": "0" }, "executionId": "e77a...", "txHash": "0x...", "usdcNetRaw": "64800000" }, { "eureInRaw": "40000000000000000000", + "execution": { "feeRaw": "0", "referenceRateRaw": "108120000", "subsidyRaw": "120000" }, "executionId": "f88b...", "txHash": "0x...", "usdcNetRaw": "43200000" } ], + "forwardTxHash": "0x...", "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. +Each `conversions[]` entry is one chunk swap of this deposit: the EURe it consumed and its net USDC. Chunks are never shared between deposits. `forwardTxHash` is the transaction that pushed the whole converted deposit to the destination, and the payload-level `usdcNetRaw` is the amount that single transfer carried (the sum of the chunks' nets). + +Deposit `status` values: `pending`, `minted`, `held`, `returned` (provider states), then `converting`, `forwarded`, or — when a payment cannot be converted within the promised window — `recovering`, `refunded`, `recovery_failed`. `DEPOSIT_RECEIVED` may already report `converting` when conversion started within the same minute. + +The nested `execution` object is the chunk's pricing: `referenceRateRaw` is the EUR/USD reference the swap was settled against, the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap (8 decimals), `feeRaw` the fee taken above the agreed target, and `subsidyRaw` the top-up paid to reach the agreed floor (both 6-decimal USDC base units). The chunk's `usdcNetRaw` already includes both. + +### `DEPOSIT_RETURNED` + +Fired once per deposit that could not be converted within the promised window (or that an operator withdrew from conversion), after Vortex refunded the full EUR amount to the bank account the payment came from. Chunks already converted are swapped back and any shortfall is covered by Vortex; the payer always receives the exact issue amount. + +```json +{ + "eventId": "deposit-returned:9f6f6a7e-...", + "eventType": "DEPOSIT_RETURNED", + "timestamp": "2025-01-15T13:05:00.000Z", + "payload": { + "accountId": "c2a5...", + "profileId": "7d1b...", + "depositId": "9f6f6a7e-...", + "amountRaw": "100000000000000000000", + "currency": "eur", + "status": "refunded", + "txHash": "0x...", + "refund": { + "amount": "100.00", + "payerIbanMasked": "DE89…3000", + "redeemOrderId": "8c0fd7b1-...", + "recoverTxHash": "0x..." + } + } +} +``` + +`refund.amount` is the EUR amount refunded, to the cent — always the full issue amount. `payerIbanMasked` identifies the receiving account by its first and last four characters, `redeemOrderId` is the EUR provider's order for the outgoing SEPA transfer, and `recoverTxHash` the transaction that moved the deposit off the forwarding contract. + +### `DEPOSIT_UPDATED` + +Fired whenever anything about a deposit changes: the payment arrives at the EUR provider, EURe is minted, a conversion chunk is sent or confirmed, the deposit starts or stops waiting, the converted USDC is delivered, or the deposit enters and completes the refund path. The payload is the deposit's full snapshot, the same object `GET /v1/monerium-b2b/deposits` returns, so you can upsert it by `depositId` and never miss a stage. A deposit is reported as `forwarded` once its transfer reached the same safe confirmation depth as `DEPOSIT_CONVERTED`. + +```json +{ + "eventId": "deposit-updated:9f6f6a7e-...:3b1c9e0f2a7d4e61", + "eventType": "DEPOSIT_UPDATED", + "timestamp": "2025-01-15T10:36:20.000Z", + "payload": { + "depositId": "9f6f6a7e-...", + "accountId": "c2a5...", + "profileId": "7d1b...", + "externalSubjectId": "client-1", + "moneriumProfileId": "0b8e...", + "moneriumOrderId": "5a2c...", + "status": "converting", + "currency": "eur", + "amount": "15000.00", + "amountRaw": "15000000000000000000000", + "txHash": "0x...", + "receivedAt": "2025-01-15T10:34:58.000Z", + "mintedAt": "2025-01-15T10:35:00.000Z", + "waiting": { "reason": "below_floor", "since": "2025-01-15T10:36:20.000Z" }, + "rejectedReason": null, + "conversions": [ + { + "executionId": "e77a...", + "status": "confirmed", + "eureInRaw": "10000000000000000000000", + "execution": { "feeRaw": "81000", "referenceRateRaw": "108140000", "subsidyRaw": "0" }, + "usdcNetRaw": "10800000000", + "txHash": "0x...", + "sentAt": "2025-01-15T10:35:20.000Z", + "confirmedAt": "2025-01-15T10:35:44.000Z" + } + ], + "usdcNetRaw": "10800000000", + "forwardTxHash": null, + "deliveredAt": null, + "refund": null + } +} +``` + +- **IDs:** `depositId`, `accountId`, `profileId` (your managed child, the `X-Managed-Profile-Id` value), `externalSubjectId` (your own client reference), and `moneriumProfileId` and `moneriumOrderId` (the EUR provider's IDs for the client and for the incoming payment). Every transaction hash is included: the mint (`txHash`), each chunk, the forward, and on a refund the recovery transaction and the provider's redemption order. +- **Amounts:** `amount` is the EUR amount to the cent and `amountRaw` the same in 18-decimal base units. Each chunk carries the EURe it converted and its net USDC; the top-level `usdcNetRaw` is the sum of the confirmed chunks, which the single transfer delivers. +- **Timestamps:** `receivedAt`, `mintedAt` (the conversion window counts from here), each chunk's `sentAt` and `confirmedAt`, `deliveredAt`, and on a refund `refund.startedAt` and `refund.refundedAt`. +- **Hold status:** `waiting` is set while the deposit waits. `monerium_pending` means the EUR provider has not minted it yet: it is minting or under a compliance review, which the provider does not tell apart. After the mint, the reason Vortex is holding the next chunk: `oracle_unavailable`, `reference_unavailable`, `reference_out_of_band`, `no_route`, or `below_floor`, when the market is below the client's floor by more than the subsidy currently allows. +- **Failure status:** `rejectedReason` holds the provider's reason when it returned the payment before minting (`status` is then `returned`). `refund.reason` says why a deposit is refunded: `window_missed`, `compliance`, `incident`, or `operator`. + +`eventId` is unique per snapshot. If retries deliver an older snapshot after a newer one, keep the one with the later `timestamp`. + +### `ACCOUNT_UPDATED` + +Fired whenever an onramp account's snapshot changes: when it is set up, when its IBAN is issued, and when its status or dormancy changes. Use it to learn when a client's IBAN is ready. The payload is the account object returned by `GET /v1/monerium-b2b/account` and `GET /v1/monerium-b2b/accounts`. + +```json +{ + "eventId": "account-updated:c2a5...:9d04b7e2c1aa5f30", + "eventType": "ACCOUNT_UPDATED", + "timestamp": "2025-01-14T09:12:00.000Z", + "payload": { + "accountId": "c2a5...", + "profileId": "7d1b...", + "externalSubjectId": "client-1", + "moneriumProfileId": "0b8e...", + "status": "active", + "iban": "EE12 3456 7890 1234 5678", + "destination": "0x...", + "forwarderAddress": "0x...", + "targetPpm": 1250, + "floorPpm": 1500, + "dormantSince": null, + "createdAt": "2025-01-14T09:00:00.000Z" + } +} +``` ### Delivery Semantics diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index f0a5bda57..f649a9e08 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -15,7 +15,7 @@ Manager status is granted by Vortex, not self-service. During partner onboarding - **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). Automated EUR onboarding and provider binding are not available for managed children. A non-technical child that operations has already provisioned with an approved EUR provider binding, Polygon EOA, and IBAN may use the direct-API EUR BUY flow when the manager policy allows that corridor. The `EU` corridor also 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. EUR, including this business account surface, is available in sandbox; production activation is pending. +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). Automated EUR onboarding and provider binding are not available for managed children. A non-technical child that operations has already provisioned with an approved EUR provider binding, Polygon EOA, and IBAN may use the direct-API EUR BUY flow when the manager policy allows that corridor. The `EU` corridor also 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. The account response carries the child's IBAN, status and fee policy (`targetPpm` and `floorPpm`, parts per million below the reference rate), and each deposit is returned as its full lifecycle snapshot, the same object the [`DEPOSIT_UPDATED` webhook](https://api-docs.vortexfinance.co/webhooks) pushes. To read every child's account in one call, use `GET /v1/monerium-b2b/accounts` with your manager key and no `X-Managed-Profile-Id` header; filter by `moneriumProfileId` to find one client by the EUR provider's profile ID. EUR, including this business account surface, is available in sandbox; production activation is pending. ## 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. -- **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. +- **Transaction webhooks are not supported for managed subjects** — registration returns `400 MANAGED_PROFILE_UNSUPPORTED` with the header and `403` with a child credential. Poll the child-scoped ramp status and history endpoints instead. The exception is the deposit-event family for EUR onramp accounts: the **manager** subscribes with their own credential (no header) and receives the lifecycle events `DEPOSIT_UPDATED`/`ACCOUNT_UPDATED` and the milestones `DEPOSIT_RECEIVED`/`DEPOSIT_CONVERTED`/`DEPOSIT_RETURNED` for all their children's accounts — see the Webhooks page. ## Common Errors diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index a9437d98f..51d4dd5a1 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -13,7 +13,7 @@ and the SDK error mappings in sync. ## packages/shared — partner wire contract (`src/endpoints`) ```text -ACCOUNT_WEBHOOK_EVENT_TYPES: readonly [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED] +ACCOUNT_WEBHOOK_EVENT_TYPES: readonly [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED, WebhookEventType.DEPOSIT_RETURNED, WebhookEventType.DEPOSIT_UPDATED, WebhookEventType.ACCOUNT_UPDATED] AcceptedRecipientInvite: { id: string; @@ -32,6 +32,41 @@ AccountMeta: { type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; } +AccountSnapshot: { + accountId: string; + createdAt: string; + destination: string; + dormantSince: null | string; + externalSubjectId: null | string; + floorPpm: number; + forwarderAddress: string; + iban: null | string; + moneriumProfileId: string; + profileId: null | string; + status: string; + targetPpm: number; +} + +AccountUpdatedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.ACCOUNT_UPDATED; + payload: { + accountId: string; + createdAt: string; + destination: string; + dormantSince: null | string; + externalSubjectId: null | string; + floorPpm: number; + forwarderAddress: string; + iban: null | string; + moneriumProfileId: string; + profileId: null | string; + status: string; + targetPpm: number; + }; + timestamp: string; +} + AlchemyPayPriceResponse: { direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; provider: "alchemypay"; @@ -383,6 +418,12 @@ BundledPriceResult: { CleanupPhase: "assetHubCleanup" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "ethereumCleanupUsdc" | "hydrationCleanup" | "moonbeamCleanup" | "pendulumCleanup" | "polygonCleanup" | "polygonCleanupAxlUsdc" +ConversionExecutionPricing: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; +} + CreateBestQuoteRequest: { api?: boolean; apiKey?: string; @@ -434,6 +475,21 @@ DeleteWebhookResponse: { success: boolean; } +DepositConversionSnapshot: { + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; +} + DepositConvertedWebhookPayload: { eventId: string; eventType: WebhookEventType.DEPOSIT_CONVERTED; @@ -443,15 +499,21 @@ DepositConvertedWebhookPayload: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; } & { conversions: Array<{ eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; executionId: string; txHash: null | string; usdcNetRaw: string; }>; + forwardTxHash: null | string; usdcNetRaw: string; }; timestamp: string; @@ -466,13 +528,138 @@ DepositReceivedWebhookPayload: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + }; + timestamp: string; +} + +DepositRefundReason: "compliance" | "incident" | "operator" | "window_missed" + +DepositReturnedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RETURNED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + } & { + refund: { + amount: string; + payerIbanMasked: string; + recoverTxHash: null | string; + redeemOrderId: null | string; + }; + }; + timestamp: string; +} + +DepositSnapshot: { + accountId: string; + amount: string; + amountRaw: string; + conversions: Array<{ + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; + }>; + currency: string; + deliveredAt: null | string; + depositId: string; + externalSubjectId: null | string; + forwardTxHash: null | string; + mintedAt: null | string; + moneriumOrderId: string; + moneriumProfileId: string; + profileId: string; + receivedAt: string; + refund: null | { + amount: null | string; + payerIbanMasked: null | string; + reason: "compliance" | "incident" | "operator" | "window_missed" | null; + recoverTxHash: null | string; + redeemOrderId: null | string; + refundedAt: null | string; + startedAt: null | string; + }; + rejectedReason: null | string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + usdcNetRaw: string; + waiting: null | { + reason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + since: string; + }; +} + +DepositStatus: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" } + +DepositUpdatedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.DEPOSIT_UPDATED; + payload: { + accountId: string; + amount: string; + amountRaw: string; + conversions: Array<{ + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; + }>; + currency: string; + deliveredAt: null | string; + depositId: string; + externalSubjectId: null | string; + forwardTxHash: null | string; + mintedAt: null | string; + moneriumOrderId: string; + moneriumProfileId: string; + profileId: string; + receivedAt: string; + refund: null | { + amount: null | string; + payerIbanMasked: null | string; + reason: "compliance" | "incident" | "operator" | "window_missed" | null; + recoverTxHash: null | string; + redeemOrderId: null | string; + refundedAt: null | string; + startedAt: null | string; + }; + rejectedReason: null | string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; + usdcNetRaw: string; + waiting: null | { + reason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + since: string; + }; }; timestamp: string; } -DepositStatus: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" } +DepositWaitingReason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable" DepositWebhookPayloadBase: { accountId: string; @@ -480,7 +667,7 @@ DepositWebhookPayloadBase: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; } @@ -1640,7 +1827,7 @@ RegisterRampResponse: { } RegisterWebhookRequest: { - events?: Array; + events?: Array; quoteId?: string; sessionId?: string; url: string; @@ -1648,7 +1835,7 @@ RegisterWebhookRequest: { RegisterWebhookResponse: { createdAt: string; - events: Array; + events: Array; id: string; isActive: boolean; quoteId: null | string; @@ -2375,6 +2562,24 @@ WebhookDeliveryAttempt: { maxAttempts: number; nextRetryAt?: Date; payload: { + eventId: string; + eventType: WebhookEventType.ACCOUNT_UPDATED; + payload: { + accountId: string; + createdAt: string; + destination: string; + dormantSince: null | string; + externalSubjectId: null | string; + floorPpm: number; + forwarderAddress: string; + iban: null | string; + moneriumProfileId: string; + profileId: null | string; + status: string; + targetPpm: number; + }; + timestamp: string; + } | { eventId: string; eventType: WebhookEventType.DEPOSIT_CONVERTED; payload: { @@ -2383,15 +2588,21 @@ WebhookDeliveryAttempt: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; } & { conversions: Array<{ eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; executionId: string; txHash: null | string; usdcNetRaw: string; }>; + forwardTxHash: null | string; usdcNetRaw: string; }; timestamp: string; @@ -2404,8 +2615,78 @@ WebhookDeliveryAttempt: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + }; + timestamp: string; + } | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RETURNED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + } & { + refund: { + amount: string; + payerIbanMasked: string; + recoverTxHash: null | string; + redeemOrderId: null | string; + }; + }; + timestamp: string; + } | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_UPDATED; + payload: { + accountId: string; + amount: string; + amountRaw: string; + conversions: Array<{ + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; + }>; + currency: string; + deliveredAt: null | string; + depositId: string; + externalSubjectId: null | string; + forwardTxHash: null | string; + mintedAt: null | string; + moneriumOrderId: string; + moneriumProfileId: string; + profileId: string; + receivedAt: string; + refund: null | { + amount: null | string; + payerIbanMasked: null | string; + reason: "compliance" | "incident" | "operator" | "window_missed" | null; + recoverTxHash: null | string; + redeemOrderId: null | string; + refundedAt: null | string; + startedAt: null | string; + }; + rejectedReason: null | string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; + usdcNetRaw: string; + waiting: null | { + reason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + since: string; + }; }; timestamp: string; } | { @@ -2435,9 +2716,27 @@ WebhookDeliveryAttempt: { webhookId: string; } -WebhookEventType: enum WebhookEventType { DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } +WebhookEventType: enum WebhookEventType { ACCOUNT_UPDATED = "ACCOUNT_UPDATED", DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", DEPOSIT_RETURNED = "DEPOSIT_RETURNED", DEPOSIT_UPDATED = "DEPOSIT_UPDATED", STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } WebhookPayload: { + eventId: string; + eventType: WebhookEventType.ACCOUNT_UPDATED; + payload: { + accountId: string; + createdAt: string; + destination: string; + dormantSince: null | string; + externalSubjectId: null | string; + floorPpm: number; + forwarderAddress: string; + iban: null | string; + moneriumProfileId: string; + profileId: null | string; + status: string; + targetPpm: number; + }; + timestamp: string; +} | { eventId: string; eventType: WebhookEventType.DEPOSIT_CONVERTED; payload: { @@ -2446,15 +2745,21 @@ WebhookPayload: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; } & { conversions: Array<{ eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; executionId: string; txHash: null | string; usdcNetRaw: string; }>; + forwardTxHash: null | string; usdcNetRaw: string; }; timestamp: string; @@ -2467,8 +2772,78 @@ WebhookPayload: { currency: string; depositId: string; profileId: string; - status: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" }; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + }; + timestamp: string; +} | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_RETURNED; + payload: { + accountId: string; + amountRaw: string; + currency: string; + depositId: string; + profileId: string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; + txHash: null | string; + } & { + refund: { + amount: string; + payerIbanMasked: string; + recoverTxHash: null | string; + redeemOrderId: null | string; + }; + }; + timestamp: string; +} | { + eventId: string; + eventType: WebhookEventType.DEPOSIT_UPDATED; + payload: { + accountId: string; + amount: string; + amountRaw: string; + conversions: Array<{ + confirmedAt: null | string; + eureInRaw: string; + execution: { + feeRaw: null | string; + referenceRateRaw: null | string; + subsidyRaw: null | string; + }; + executionId: string; + sentAt: string; + status: "confirmed" | "pending"; + txHash: null | string; + usdcNetRaw: string; + }>; + currency: string; + deliveredAt: null | string; + depositId: string; + externalSubjectId: null | string; + forwardTxHash: null | string; + mintedAt: null | string; + moneriumOrderId: string; + moneriumProfileId: string; + profileId: string; + receivedAt: string; + refund: null | { + amount: null | string; + payerIbanMasked: null | string; + reason: "compliance" | "incident" | "operator" | "window_missed" | null; + recoverTxHash: null | string; + redeemOrderId: null | string; + refundedAt: null | string; + startedAt: null | string; + }; + rejectedReason: null | string; + status: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" }; txHash: null | string; + usdcNetRaw: string; + waiting: null | { + reason: "below_floor" | "monerium_pending" | "no_route" | "oracle_unavailable" | "reference_out_of_band" | "reference_unavailable"; + since: string; + }; }; timestamp: string; } | { @@ -8643,6 +9018,9 @@ POST /v1/admin/managed-profile-managers/:profileId/managed-profiles POST /v1/admin/managed-profiles POST /v1/admin/monerium-b2b/accounts PATCH /v1/admin/monerium-b2b/accounts/:accountId/status +POST /v1/admin/monerium-b2b/deposits/:depositId/recover +PATCH /v1/admin/monerium-b2b/deposits/:depositId/status +GET /v1/admin/monerium-b2b/refund-address POST /v1/admin/partner-pricing-configs DELETE /v1/admin/partner-pricing-configs/:configId GET /v1/admin/partners/:partnerName/api-credentials @@ -8789,6 +9167,7 @@ POST /v1/managed-profiles/:profileId/api-credentials DELETE /v1/managed-profiles/:profileId/api-credentials/:credentialId GET /v1/metrics/volumes GET /v1/monerium-b2b/account +GET /v1/monerium-b2b/accounts GET /v1/monerium-b2b/deposits POST /v1/monerium-b2b/webhook POST /v1/monerium/iban/move diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index a8a922f8a..18edd766e 100644 --- a/docs/architecture-monerium-b2b-onramp.md +++ b/docs/architecture-monerium-b2b-onramp.md @@ -16,11 +16,17 @@ deploys one `VortexForwarder` contract clone per client, links it to that profil 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 +keeper converts each bank payment in `swap(reference, route, amountIn)` chunks on the +contract — each swaps EURe to USDC over a whitelisted Uniswap v3 route and settles the +fill against the partner reference rate (surplus above the target is the fee, shortfall +below the floor is topped up from the subsidy vault, Chainlink bounds the net) — the USDC +accumulates on the forwarder, and one `forward(amount)` pushes the whole converted +payment to the client's fixed destination wallet, so the client sees one transfer per +pay-in. A payment that cannot be converted inside the promised window is moved to the +client's refund wallet (`recover`, keeper-only, contract-gated by `RECOVERY_DELAY`) and +refunded to the payer's bank account out of the client's own IBAN — see "Chunking, forwarding and the refund path". +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. @@ -47,10 +53,16 @@ flowchart LR subgraph Chain["Ethereum mainnet"] FWD["VortexForwarder clone\n(one per client)"] FACT[Factory + implementation] - UNI[Uniswap v3\nEURe-EURC-USDC] + UNI[Uniswap v3\nwhitelisted routes] LINK[Chainlink EUR/USD] + VAULT["VortexSubsidyVault\n(shared, treasury-funded)"] DEST[Client wallet] TREAS[Treasury FEE_RECIPIENT] + RECOV["Client refund wallet\n(refund to the payer's IBAN)"] + end + + subgraph Reference["Reference rate"] + CB[Coinbase Exchange\nEURC-USDC ticker] end subgraph Vortex["Vortex API (keeper backend)"] @@ -60,7 +72,7 @@ flowchart LR MW[Mint watcher] CE[Conversion executor] ONB[Onboarding automation] - MONI[4 detection monitors] + MONI[5 detection monitors] OUTBOX[("webhook_deliveries\n(durable outbox)")] READ["Read API\n/v1/monerium-b2b/*"] end @@ -69,22 +81,33 @@ flowchart LR MWH -- "order.*, iban.updated (HMAC)" --> INBOX INBOX --> DP MW -- "EURe Transfer logs" --> FWD - CE -- "swapAndForward()" --> FWD + CB -- "price before each swap" --> CE + CE -- "quotes every route" --> UNI + CE -- "swap(reference, route, chunk) x N" --> FWD + CE -- "forward(whole payment)" --> FWD + CE -- "recover(eure, usdc) after RECOVERY_DELAY" --> FWD FWD --> UNI - FWD -- "minOut check" --> LINK - FWD -- "USDC - fee" --> DEST + FWD -- "band + floor on the net" --> LINK + FWD -- "one USDC transfer per payment" --> DEST FWD -- fee --> TREAS + FWD -- "pay(shortfall)" --> VAULT + VAULT -- "subsidy (stays on the clone)" --> FWD + FWD -- "stuck payment" --> RECOV ONB -- "link address + request IBAN" --> MAPI MONI -- "association / config reads" --> MAPI - OUTBOX -- "DEPOSIT_RECEIVED / DEPOSIT_CONVERTED" --> PAPI + OUTBOX -- "DEPOSIT_UPDATED / ACCOUNT_UPDATED\nand RECEIVED / CONVERTED / RETURNED" --> 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. +controls where funds can go** (fixed `destination`, fee to the immutable treasury, and +the client's refund wallet fixed in the clone, reachable only by the keeper and only once a batch +has been open for `RECOVERY_DELAY` — the keeper can trigger and, for a stuck payment, +recover, never redirect); **Vortex controls +timing, route choice and the reference within on-chain bounds** (a validated route set, +a Chainlink band, a fee cap, vault caps and a floor on the client's net), which can move +the price inside those bounds but never where funds go; and **accounting**. ## Onboarding sequence (per client) @@ -97,14 +120,16 @@ sequenceDiagram 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: GET /v1/admin/monerium-b2b/refund-address (derived refund wallet) + Op->>C: deployForwarder(destination, refundWallet, targetPpm, floorPpm) via factory Op->>Adm: POST /v1/admin/monerium-b2b/accounts 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 /addresses (refund wallet, EOA-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->>M: optional penny test (simulated/real small SEPA) Op->>Adm: PATCH .../accounts/:id/status "active" (refused without IBAN) ``` @@ -113,17 +138,21 @@ 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. +2. **Operator deploys the forwarder clone** with the client's `destination` (no setter: + a wallet change means a new clone, runbook §5), the client's refund wallet as + `recoveryAddress` (derived from `MONERIUM_B2B_REFUND_SEED` and the Monerium profile ID, + read from the admin refund-address endpoint; no setter either) and the initial fee + policy (`targetPpm`, `floorPpm`); 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 + configured trusted factory on chain (including that its `recoveryAddress` is the + client's derived refund wallet), 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 +4. **Keeper automation** links the forwarder (attestor signature) and the client's refund + wallet (its own signature), then requests the IBAN for the forwarder, 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. +5. **Optional penny test**, then activation via the admin status endpoint. ## Deposit-to-payout sequence @@ -132,7 +161,9 @@ sequenceDiagram participant B as Client's bank participant M as Monerium participant F as Forwarder (chain) + participant S as Subsidy vault (chain) participant V as Vortex keeper + participant CB as Coinbase participant P as Partner B->>M: SEPA transfer to the IBAN @@ -140,14 +171,20 @@ sequenceDiagram 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 + loop one chunk per keeper cycle (at most perSwapCap) until the deposit is converted + V->>CB: top of book -> bid/ask midpoint (reference, recorded on the execution row) + V->>V: quote every route, project fee/subsidy, defer above the subsidy tier for the chunk's wait or beyond the vault + V->>F: swap(reference, bestRoute, chunk, maxSubsidy = tier) [execution row bound to the deposit, committed first] + F->>F: swap the chunk on the route, fee above target (to treasury), floor on the net, USDC stays here + F->>S: pay(shortfall) when the fill is below the floor + S->>F: subsidy USDC onto the clone + V->>V: finalize from SwapExecuted (fee, subsidy, reference, route) + end + V->>F: forward(sum of the chunks' net) [execution row committed first] + F->>P: the whole payment's USDC to the client wallet in one transfer + V->>V: finalize from Forwarded (amount must equal the plan) -> deposit forwarded Note over V: 32 blocks later - V->>P: DEPOSIT_CONVERTED -> outbox -> partner webhook + V->>P: DEPOSIT_CONVERTED (chunks + forward tx) -> outbox -> partner webhook ``` Vortex learns about a deposit through two complementary channels, which converge on the @@ -204,8 +241,16 @@ stateDiagram-v2 pending --> returned held --> minted held --> returned - minted --> [*] + minted --> converting : first chunk sent + minted --> recovering + converting --> forwarded : forward confirmed + converting --> recovering + recovering --> refunded + recovering --> recovery_failed + recovery_failed --> recovering : operator retry + forwarded --> [*] returned --> [*] + refunded --> [*] } ``` @@ -213,8 +258,8 @@ stateDiagram-v2 stateDiagram-v2 direction LR state "Execution (monerium_conversion_executions)" as exe { - [*] --> pending2 : row committed BEFORE broadcast - pending2 --> confirmed : receipt + SwapExecuted + [*] --> pending2 : row committed BEFORE broadcast (kind swap / forward / recover, bound to its deposit) + pending2 --> confirmed : receipt + the kind's event (amounts must match the plan) pending2 --> failed : revert / never sent / stale confirmed --> [*] failed --> [*] : retried via a NEW row (backoff) @@ -226,7 +271,7 @@ stateDiagram-v2 direction LR state "Account (monerium_accounts)" as acc { [*] --> onboarding : admin mapping - onboarding --> active : penny test + admin PATCH (needs IBAN) + onboarding --> active : admin PATCH (needs IBAN) active --> suspended suspended --> active active --> closed @@ -235,68 +280,156 @@ stateDiagram-v2 ``` 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 +row): the provider states first, then the keeper's settlement branch or the refund branch +(`recovering` is entered by the operator through the admin endpoint until the missed +window triggers it automatically; `refunded` and `recovery_failed` are set by whoever +completes the bank refund). 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` +one transaction matching the keeper sender, nonce, forwarder target, the exact calldata +of its kind — `swap(reference, route, amountIn)`, `forward(amount)` or +`recover(eure, usdc)` — rebuilt from what was persisted before broadcast, and the kind's +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. +## Chunking, forwarding and the refund path + +The keeper serves **one deposit at a time** per account, oldest chain-indexed mint first, +and sends at most one transaction per account per cycle: + +- **A large deposit is chunked; the client still gets one transfer.** `swap` takes an + explicit `amountIn`: at most `perSwapCap`, and never leaving a sub-minimum dust + remainder when the last two chunks can share it (`planChunk`). A €45k deposit at a + €10k cap becomes five swap executions a few minutes apart, each bound to the deposit; + their USDC (fee already skimmed, subsidy already added) waits on the forwarder. Once + the chunks' EURe sum to the deposit, one `forward(amount)` execution pushes the sum of + their nets to the destination, and the deposit is `forwarded`. The cap is an + availability/price-impact parameter, not a safety bound — the oracle floor is. +- **Deposits never share a swap.** Two deposits sitting on the forwarder convert one + after the other; the second waits for the first's forward only if they compete for + the same cycle. There is no pro-rata attribution any more: an execution belongs to + exactly one deposit by construction. The partner receives one `DEPOSIT_CONVERTED` + per deposit after the forward is deep enough, with `conversions[]` per chunk and + `forwardTxHash`. +- **A remainder below `minSwapAmount`** (registry P6) cannot be swapped; it waits for + the refund path rather than merging with the next deposit. +- **The refund path.** A deposit marked `recovering` — by an operator through the admin + endpoint, or once automated by the missed window — is moved off the clone with + `recover(eureRemaining, usdcConverted)`: keeper-only, explicit amounts, only to the + clone's fixed `recoveryAddress` (the client's refund wallet), and only once the clone's `batchOpenedAt` marker is older + than `RECOVERY_DELAY` (2 h). The marker opens when funds first arrive, is never + re-timed by a chunk swap, and is re-timed for whatever remains after a forward or a + recovery, so a younger payment sharing the clone gets its own clock. The keeper + recovers before it converts anything else, and still does so on suspended or dormant + accounts (`recover` ignores the guardian pause). Off the clone, `recovery.ts` drives + the refund when `MONERIUM_B2B_AUTO_RECOVERY=auto` (`alert` only reports deposits past + the window; `off` leaves everything to runbook §2.7): once the `recover` is confirmed + a `monerium_recoveries` row walks `moved → swapping → swapped → topping_up → + topped_up → redeeming → redeemed` — the USDC is swapped back to EURe on the reversed + whitelisted route with a Chainlink-derived minimum, the EURe float tops the refund + wallet up to exactly the issue amount (or a surplus is swept back to the float), and a + Monerium redeem order from the refund wallet returns the exact amount to the payer's + IBAN out of the client's own IBAN (`payer_iban` / `payer_name`, captured from the issue + order's counterpart). The float also tops up the refund wallet's ETH for its own + transactions, at twice their cost at the current gas price. The + deposit becomes `refunded` when Monerium processes the order, and the partner receives + one `DEPOSIT_RETURNED` (refunded amount, masked payer IBAN, redeem order, recover + transaction). One refund runs at a + time: every step re-derives what is left to do from the client's refund wallet's + balances (so a lost transaction hash never repeats a send), and the keeper refuses a + second `recover` while one is in flight. A step that fails beyond its retries, a + missing payer, or an amount that needs a supporting document (EUR 15,000 and above) + parks the deposit in `recovery_failed` with the phase preserved; an operator retry + (deposit back to `recovering`) resumes there. The promised window is + `MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (120) counted from the mint block + (`minted_at`); the on-chain `RECOVERY_DELAY` is its floor. +- **Liveness without Vortex.** Past `TRIGGER_DELAY` (24 h) anyone may `swap` (Chainlink + reference, no subsidy) and `forwardAll` the clone's USDC to the destination; payments + may merge on that path, and the keeper reconciles what it did not send by hand. + +## Fees, reference rate and subsidy + +The partner agreement fixes the client's rate against a reference: the reference minus +12.5 bps whenever the market allows it, never worse than 15 bps below it. The contract +settles every fill into three bands against that reference (decisions: +[`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md), amendment). + +- **Reference rate.** Before each swap the keeper reads the Coinbase Exchange EURC-USDC + ticker and takes the bid/ask midpoint (`reference-rate.ts`): spot, so the reference + never lags a moving market; the midpoint rather than the last trade because a last + print can be one-sided or minutes stale on a quiet weekend; a spread above 50 bps is a + thin book and the keeper defers. It stores price, source and time on the execution + row and passes the rate into `swap`. The contract rejects a reference outside + `MAX_REFERENCE_DEVIATION_BPS` of Chainlink EUR/USD; a permissionless caller's value is + ignored and Chainlink is the reference. No reference means the keeper defers. +- **Fee policy (`targetPpm`, `floorPpm`)**: per clone, in ppm below the reference, + `target ≤ floor ≤ MAX_FEE_PPM`. A fill above `reference × (1 − target)` gives the + surplus to `FEE_RECIPIENT` as fee, capped at `MAX_FEE_PPM`; a fill between floor and + target is passed through untouched; a fill below `reference × (1 − floor)` is topped + up to the floor. Raising either value is announced on chain and applies + (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 worse policy; + lowering is immediate (registry P11). Swaps always use the currently applied policy. +- **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded + from the treasury. It pays only when called by a factory-registered clone, to the + clone itself (the subsidy is forwarded with the payment), within a guardian-settable + per-swap cap (ppm of the swap's reference value) and a UTC-daily budget; it can be + paused and withdraws only to the treasury. A vault that cannot cover the shortfall + reverts the whole swap, and the clone reverts unless exactly the shortfall arrived on + it — a swap is never partially subsidized, and the guardian cannot harm a swap by + pointing the factory at a bad vault. The vault holds Vortex money only. +- **Floor on the net**: `SLIPPAGE_BPS` (60 bps) bounds fill − fee + subsidy against + Chainlink, not the raw fill, and since 2026-09-18 it also bounds the fee target and the + subsidy floor from below: when the reference sits more than ~45 bps under a stale + Chainlink round (weekend drift), the fee gives way first and the tier-bounded subsidy + then lifts the net to Chainlink − 60 bps instead of the swap reverting. The router + minimum is zero and the forwarder's post-condition is the guard; a depeg beyond what + the tier and the vault cover still reverts (and, past the window, refunds), and the + permissionless path pays no subsidy and must clear the floor on its own. The 60 bps + is therefore the hard line for what a compromised keeper can do to the client, while + the ladder's top tier decides how much drift Vortex absorbs. +- **Routes**: the factory holds a guardian-managed whitelist of packed Uniswap v3 paths, + validated on chain to touch only EURe, EURC and USDC on the immutable router, with at + most two hops on Uniswap's four fee tiers; entries are disabled, never removed, so + indices stay stable. The keeper quotes every enabled route on the mainnet QuoterV2 + and passes the best index. A poor pick costs Vortex fee or subsidy, never the client. +- **Subsidy ladder and per-swap cap.** How much of a shortfall Vortex pays depends on + how long the chunk has waited: `MONERIUM_B2B_SUBSIDY_LADDER` maps seconds waited to a + maximum subsidy in bps of the reference value (launch: nothing for six minutes, then + 10 bps more every two minutes to 50, then 100 from minute sixteen, held until the + refund deadline). The clock runs per chunk, from the mint or the previous chunk's + confirmation, and the keeper re-quotes every `MONERIUM_B2B_KEEPER_CYCLE_SECONDS` + (20 s); quoting is free, so waiting costs nothing. The tier is passed into `swap` as + `maxSubsidy` and binds on chain: a fill that moved between the quote and the swap + cannot draw more than the tier. The ladder is Vortex's spending policy, not a client + protection — the client's floor never moves — which is why it lives in config and not + in the contract; the vault's cap and daily budget stay the hard bounds. +- **Keeper deferral**: before reserving an execution row the keeper mirrors the + settlement off-chain (`projectSwap`). It defers — nothing sent, no row, funds wait, + stranding marker armed — when the reference is unavailable, thin or out of band, no + route quotes, the projected subsidy exceeds the current tier, the vault's cap, the + remaining budget or the vault balance, or the projected net would breach the floor. + Every deferral logs the shortfall in bps against the tier, the data the ladder is + tuned from. After the 24 h trigger anyone + may execute the swap anyway, priced against Chainlink and unsubsidized (accepted + limitation, ADR). - **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. +- The database mirrors `target_ppm` / `floor_ppm` on the account row for accounting and + drift detection only; the contract values are authoritative, and the config monitor + reconciles guardian policy changes (warn + version bump) while alarming on anything + unauthorized. Each execution row records the reference, the route, the fee and the + subsidy; the client's net is `usdcOut − fee + subsidy` and flows into attribution + unchanged, and the partner sees the same three pricing facts on every conversion. ## Monitoring (detection-only) -Four monitors run from the keeper worker (rate-limited to one pass per ~30 minutes), +Five 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 @@ -306,15 +439,26 @@ read-only — no keys, no transactions: 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 +2. **Executable-depth monitor.** QuoterV2 quotes on every enabled route vs Chainlink; + the best route's impact past the floor is an alert before clients feel it. +3. **Stranded-balance monitor.** Forwarders holding EURe or USDC whose batch marker has + been open longer than `RECOVERY_DELAY` warn (the promised window was missed: forward + or recover) and longer than `TRIGGER_DELAY` error (the permissionless path is live — + a keeper-outage signal; funds are never at risk). +4. **Config reconciliation.** Re-reads per-clone config and bytecode: guardian-authorized + fee-policy changes (timelocked) are reconciled into the DB with a version bump; a + destination change (no setter exists), bytecode or registration drift is a should-be-impossible incident. +5. **Subsidy-vault monitor.** Balance, daily budget, spend and pause state of the shared + vault: paused or empty is an error (every below-floor swap defers), less than a day + of budget or an exhausted day is a refill warning. +6. **Reference-venue monitor.** Probes the Coinbase product the reference reads: a + delisted or halted product keeps answering its endpoints with stale data + and would make every keeper swap defer silently, so its status is an error line + rather than an assumption. +7. **Refund monitor** (automated refunds only). The one active recovery must not + linger (warn after an hour, error after four or on a failed step) and the EURe float + must not run dry. ## Data model — the Monerium B2B tables @@ -327,8 +471,7 @@ 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)" + monerium_fiat_deposits ||--o{ monerium_conversion_executions : "deposit_id (1 deposit : N executions)" webhooks ||--o{ webhook_deliveries : "webhook_id (deposit events)" monerium_accounts { @@ -337,8 +480,8 @@ erDiagram string iban string forwarder_address UK string destination - string fallback_address - int fee_bps + int target_ppm + int floor_ppm enum status } monerium_fiat_deposits { @@ -349,28 +492,27 @@ erDiagram int log_index } monerium_conversion_executions { + enum kind + uuid deposit_id FK decimal eure_in_raw decimal usdc_net_raw + decimal subsidy_raw + decimal reference_rate_raw + int route_index 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_accounts` (069, 071, 078, 080) | One row per client account: Monerium profile UUID, IBAN, forwarder and destination addresses, fee policy mirror (`target_ppm`, `floor_ppm`), lifecycle status, dormancy marker, and `vortex_profile_id` → the owning managed child profile | +| `monerium_fiat_deposits` (069, 070, 073, 076, 080, 081) | One row per Monerium issue order (or flagged `unattr:` inflow): amount in 18-dp base units, forward-only status through settlement (`converting`, `forwarded`) or refund (`recovering`, `refunded`, `recovery_failed`), on-chain mint identity and mint time, the payer's IBAN and name (the refund target), and two webhook-emission markers | +| `monerium_recoveries` (081) | One row per refunded deposit: the phase of the refund, the EURe and USDC the keeper recovered, the reverse-swap output, the float top-up (the refund's subsidy) or the surplus swept back, the redeem order and the EUR amount refunded, attempts and the last error | +| `monerium_conversion_executions` (069, 074, 075, 077, 079, 080) | One row per keeper transaction, bound to the deposit it serves (`deposit_id`) and typed by `kind`: a `swap` row is created before broadcast with the chunk, the reference (rate, source, time), the route and the subsidy tier cap (`max_subsidy_raw`), then filled from `SwapExecuted` (USDC gross, fee, subsidy, net `usdcOut - fee + subsidy`); a `forward` row carries the amount pushed to the destination; a `recover` row the EURe and USDC moved to the recovery wallet. All carry tx hash, planned nonce and pre-broadcast block (crash recovery), receipt block and event log index, status | | `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 | @@ -386,11 +528,16 @@ the exactly-once link/IBAN calls, and — registered by the partner — a user-o 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; +when that order row already exists, without duplicating chain identity or executions; 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: +from its persisted nonce/block plus an exact transaction-and-event match (the calldata +of its kind rebuilt from what was persisted) rather than re-sent; a swap the vault +could not cover, a reference that is unavailable or out of band, or a fill below the +floor is deferred by the keeper, never forced; all per-account writes serialize on one +advisory lock; a Vortex outage can never trap converted funds on chain (past the +trigger delay anyone may swap and forward permissionlessly); and a payment the promised +window was missed on leaves the clone only through the keeper's delay-gated recovery to +the immutable Vortex wallet, which the refund completes off chain. 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 index 8ba3cf968..1706c1e27 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -11,7 +11,10 @@ procedures in [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-run 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). + **re-approval needed (2026-09-17):** the fallback is now a Vortex-held refund wallet + per client, linked to the client's own profile, which refunds out of the client's + IBAN (2026-10-01; one address per profile and the reusable supporting document were + confirmed 2026-09-30) — mandatory by design, so the condition is met). 2. Redemption-limitation disclosure obligation (their request; our commitment — §Terms 1). 3. Issuer recovery backstop: burn from a linked address, payout only to the customer's own external bank account, no fees, re-verification possible — **including the @@ -32,38 +35,54 @@ verbal/Telegram statements; consolidate into the MSA or a side letter: **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). +bounded, pre-announced guardian fee-policy power (P11), the route whitelist (P10) and +the subsidy vault (P13). **G3 — external contract audit.** Parameters are final (ADR); the internal reviews and the invariant suite are done, but this moves client funds. **G4 — pilot.** SulPayments agreement signed (terms inputs below), reliance -attestations per customer, 3–5 clients at **€50k/client/day** (paper control), fee 0. +attestations per customer, 3–5 clients at **€50k/client/day** (paper control), launch +fee policy 12.5 bps target / 15 bps floor (B1). ## Deploy checklist (mainnet bring-up) +The Sepolia sandbox follows its own procedure in the runbook (§8). + 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. + forcing a rollback once conversions have been attributed. Migrations 078/079 add the + ppm fee policy and the pricing columns: before applying them, confirm no `Pending` + `monerium_conversion_executions` row has a NULL `reference_rate_raw` or `route_index` + (it would stay in flight forever and block its account) and no deposit-converted + outbox delivery is still pending (it would replay without the `execution` block). 2. **Treasury first (O2):** create the dedicated fee Safe multisig — `FEE_RECIPIENT` is immutable in the implementation. Confirm 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. +3. Re-verify the initial route's pools and fee tiers at the deploy block (P10) and re-run + the liquidity baseline quote methodology (T6); confirm `perSwapCap` €10k still + executes within floor plus the per-swap subsidy cap, and decide whether a second + route (direct EURe→USDC or other tiers) is worth whitelisting from day one. 4. Deploy implementation + factory with the final parameters (ADR table: 52 h oracle - age, 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. + age, 60 bps floor on the net, 1% fee cap, 100 bps reference band, 2 h recovery / 24 h + trigger delays, €1 floor/€50k ceiling, initial 5 bps/5 bps route; the recovery address + is per client, passed at each clone's deployment); set operational `minSwapAmount` + €1 and `perSwapCap` €10k; register the keeper key. +4a. Deploy `VortexSubsidyVault` (USDC, the fee Safe as treasury, the factory, 50 bps per + swap, 200 USDC per day — P13), point the factory at it (`setSubsidyVault`), and fund + it from the treasury with the first days of budget. Runbook §2.6 has the commands. 5. Verify factory + implementation source on the block explorer; generate, verify, and publish the manifest. 6. Production 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. + keeper funded), `MONERIUM_B2B_REFUND_SEED` (derives every client's refund wallet), + read RPC + private orderflow RPC, webhook secret, and + `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`; the backend needs outbound HTTPS to + `api.exchange.coinbase.com` for the reference rate (P12) — without it every swap + defers. Keep `MONERIUM_B2B_ENABLED=false` until every remaining gate is complete. 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 → @@ -79,17 +98,18 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control 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). +12. Per client: runbook §1 (deploy clone → map → automated link/IBAN → optional 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. + > EURe received at your dedicated forwarding address cannot be redeemed with Monerium + > from that address and cannot be withdrawn by you. It is converted to USDC and sent + > to your payout address as one transfer per payment; a payment that cannot be + > converted within the promised window is refunded by Vortex, in EUR and in full, to + > the bank account it was sent from. Monerium's recovery process, which pays out only + > to your own verified bank account, remains available as a backstop. (The recovery backstop is functional as built — T1 resolved — but keep it framed as Monerium's process, subject to their verification.) @@ -97,43 +117,80 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control 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. + attestation. Vortex's diligence consideration: an optional 5 USDC penny test before + activation, recommended for exchange destinations, the 60-day dormancy gate, and never sending unconverted EURe to the destination. + Vortex enforces no practical minimum (€1, ADR amendment 2026-09-29), so keeping + payments above an exchange destination's minimum deposit is the client's + responsibility. The destination is + fixed per account: a change means a new forwarding account (and IBAN move) set up by + Vortex on the partner's written instruction; Vortex cannot redirect funds. 3. **Dormancy re-confirmation (P5/B5).** Draft: > If no conversion completes for 60 days, forwarding pauses automatically and > resumes only after you (or the partner on your behalf, in writing) re-confirm your > payout address. Deposits made while paused remain in your forwarding account and - > convert after re-confirmation; 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 + > convert after re-confirmation. A payment received while paused that cannot be + > converted within the promised window is refunded in full to the bank account it + > came from. +4. **Rate, fee and subsidy (B1/P1/P2/P12/P13)** — disclose the guarantee, the fee and + the hard bound separately: + - Reference rate: the midpoint between the best bid and the best ask on the Coinbase + Exchange EURC-USDC market, read immediately before each conversion from the public + ticker and recorded with the conversion; a conversion waits while the spread is + wider than 0.5%. The agreement's "Coinbase EURC oracle" — align the wording; the + source is the exchange market, weekdays and weekends alike. + - Guarantee: each keeper-executed conversion delivers the reference rate minus + 12.5 bps whenever the market allows it, and never less than the reference minus + 15 bps. Vortex's fee is whatever the market delivers above the 12.5 bps target, + contractually capped at the on-chain 1%; below the 15 bps floor Vortex tops the + conversion up from its own subsidy budget. The 12.5 bps target and 15 bps floor are + per client; raising either requires a 24 h on-chain pre-announcement (P11), + lowering is immediate. + - Subsidy limits: top-ups are capped per conversion and per day (P13), and the + amount Vortex is willing to top up grows with the time a chunk has waited for the + market (P14: nothing for the first six minutes, then in steps up to the cap). A + chunk therefore executes as soon as the market delivers the floor on its own, or + once Vortex's willingness to pay meets the shortfall; when neither happens within + the promised window the payment is refunded. After a conversion has waited 24 hours, anyone may execute it at the + unsubsidized Chainlink-bounded terms below; the guarantee applies to conversions + Vortex's keeper executes. + - Hard bound (not a fee): no conversion ever delivers less than the Chainlink + EUR/USD rate minus 0.6% after fee and subsidy, or it does not execute. When the + Coinbase reference sits below that bound, Vortex makes up the difference from its + own budget within the disclosed limits, so the client receives the bound rather + than the reference deal; when the difference exceeds those limits the conversion + waits. Enforced by the contract assuming an honest oracle; not a principal guarantee under oracle failure or a stablecoin collapse beyond the bound. - - Batching never changes a client's effective rate: co-converted deposits split fee - and output pro-rata by amount. + - Each payment converts on its own, in chunks when it exceeds the per-conversion cap, + and reaches the payout address as a single transfer once every chunk is done; the + chunks' rates, fees and subsidies are reported per chunk. 5. **Processing SLA (B3 — decided: same business day).** Draft: - > 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. + > A payment is converted and delivered within two hours of its arrival under normal + > market conditions, on weekends as well. A payment that cannot be converted within + > that window — a market move beyond the conversion bound, a liquidity or subsidy + > shortfall, or an operational fault — is not held: Vortex refunds the full EUR amount + > to the bank account it was sent from. Payments below the minimum are refunded the + > same way. The EUR/USD reference rate updates less frequently outside FX market + > hours (staleness ceiling 52 h), so weekend conversions may execute at a rate up to + > that age — always within the conversion bound. + + Include: the window is enforced on chain (funds cannot move to Vortex's refund wallet + before it elapses); keeper outages beyond 24 h open a permissionless execution path, + so conversion does not depend on Vortex; the refund is automated in a later phase and + operator-run until then (runbook §2.7); a refund reverses the fee (none is kept on a + refunded payment's delivered amount — chunk fees already taken are Vortex's cost). +6. **Vortex powers & custody disclosure (amended 2026-09-17).** What Vortex can do: + deploy the account, run the conversion, pause it, tune bounded parameters, adjust the + fee policy within the disclosed cap and timelock, choose the swap route among an + on-chain validated set, fund or limit its own subsidy budget, and — for a payment + the promised window was missed on, and only then — move that payment to the client's + refund wallet, which Vortex holds, in order to refund it from the client's IBAN. What + Vortex cannot do: redirect funds. The contract can pay only the client's payout + address, Vortex's fee treasury, and the client's fixed refund wallet, and it refuses a recovery before the window has + elapsed. Vortex holds custody of a client's funds only on that refund path; the + client has no key of their own and no unilateral exit — the partner accepts this + (written confirmation, G1). Should Vortex disappear, anyone may complete conversions + permissionlessly after 24 hours. Vortex cannot prevent inbound SEPA to an issued IBAN; + deposits during a pause accumulate safely as EURe until converted or refunded. ## Open items ledger @@ -144,5 +201,12 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control | 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 | +| Fee Safe multisig creation | Ops | Before implementation deploy; also the subsidy vault's treasury | +| Reference wording in the partner agreement | Marcel ↔ partner | Agreement says "Coinbase EURC oracle"; implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint (spot, since 2026-09-18) — confirm that is what was meant | +| Subsidy ladder calibration | Ops ↔ product | Launch ladder in P14; retune from the `deferring conversion` shortfall lines and the vault spend after the first weeks; raise the vault's per-swap cap to the ladder's top before enabling | +| Reference band value (P12, 100 bps) | Engineering | Confirm against observed weekend Chainlink gaps before the implementation deploy (immutable). The effective downside margin is `SLIPPAGE_BPS − floorPpm` ≈ 45 bps after the 2026-09-17 move to 60 bps: the twelve-month replay on spot (2026-09-18, ADR amendment 3) shows six minute-long blips a year at that margin outside the 2025-10 depeg weekend, so ordinary weekends do not refund; the depeg weekend (39.7 h out of the 100 bps band) does, by design | +| Refund seed + float wallet | Ops | Generate `MONERIUM_B2B_REFUND_SEED` (32 random bytes; every client's refund wallet derives from it, and onboarding links each to its client's profile) and the EURe float key; fund the float with EURe and ETH (it pays the refund wallets' gas); both into the keeper's KMS. No Vortex company profile is needed | +| Refund automation | Ops | Implemented (`recovery.ts`): ship with `MONERIUM_B2B_AUTO_RECOVERY=alert`, observe one sandbox refund end to end, then `auto` with the float key set; refunds of EUR 15,000 or more stay manual until G1 settles the supporting-document question | +| Sandbox SEPA simulation: payer counterpart | Engineering (needs Marcel's sandbox login) | Capture one real issue-order webhook to confirm `counterpart.identifier.iban` / `details.name` arrive as the spec says (the refund target) | +| Subsidy vault funding and refill cadence | Ops | Before first activation; runbook §2.6 | | GA items | Engineering | Backend volume-limit enforcement (revisit), guardian key to hardware/multisig, O1 migration endpoint when first needed | diff --git a/docs/operations-monerium-b2b-runbook.md b/docs/operations-monerium-b2b-runbook.md index a8fdce910..be2112c79 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -1,7 +1,7 @@ # 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: +alert triage, dormancy, client migration, and the Sepolia sandbox bring-up (§8). Architecture: [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md); decisions and parameters: [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); security invariants: @@ -9,26 +9,35 @@ security invariants: 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. +- **Vortex powers are bounded, not custodial by default.** Guardian/keeper can pause, + execute the policy (chunk swaps, one forward per payment) and — only for a payment + whose batch has been open for `RECOVERY_DELAY` (2 h) — move that payment to the + client's refund wallet fixed in the clone for a bank refund (§2.7). Nothing else can + move funds, and nothing can redirect them: the clone pays the client's destination, + the fee treasury and the client's refund wallet, full stop. +- **Pauses block swaps and forwards, never a recovery.** Pause-then-recover is the + incident sequence. Past 24 h anyone may swap and forward permissionlessly, so a + pause plus a dead keeper still cannot trap converted funds. There is no client key + on the clone any more (ADR amendment 2026-09-17). +- **The subsidy vault holds Vortex money only.** It tops a chunk up to the floor on the + clone (the top-up is forwarded with the payment), within its caps, and withdraws only + to the treasury; funding, limits and pause are ordinary operations (§2.6), never a + client-funds question. +- **Never send raw EURe to a CEX destination.** EURe leaves a clone only to the router + or the client's refund wallet. +- **Run migrations from one deployment instance only.** Migration 080 refuses to run + while an execution from the former allocation model spans several deposits; reconcile + such rows by hand rather than guessing an attribution. ## 1. Client onboarding -Deploy → manifest → verify → map → (automated: link + IBAN) → penny test → activate. +Deploy → manifest → verify → map → (automated: link + IBAN) → optional 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 +webhook secret, and three keys); the factory's subsidy vault deployed, pointed at and +funded (§2.6); partner paperwork complete; the client company onboarded and KYB-approved on Monerium's side (partner KYC reliance) with its Monerium profile UUID at hand; the partner configured as a managed-profile manager (`PUT /v1/admin/managed-profile-managers/:profileId`, corridor `EU`, customer type @@ -40,23 +49,30 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag 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. +- (No client-held recovery address: the clone's `recoveryAddress` is the client's refund + wallet, derived by Vortex, step 1.2. The destination has no setter — a client wallet + change is a new clone, §5 — so get it right; a penny test is recommended for exchange + destinations.) +- `targetPpm` / `floorPpm` — the client's fee policy in ppm below the reference rate; + launch policy 1250 / 1500 (12.5 / 15 bps, ADR B1). Adjustable later via the + guardian's timelocked `setFeePolicy` (raising either value waits 24 h). - Signed terms including the redemption-limitation disclosure (rollout doc, Terms §1). ### 1.2 Deploy the forwarder clone ```bash +# the client's refund wallet, derived from MONERIUM_B2B_REFUND_SEED and the Monerium profile ID +curl -s -H "Authorization: Bearer $ADMIN_SECRET" \ + "$API/v1/admin/monerium-b2b/refund-address?moneriumProfileId=$MONERIUM_PROFILE_ID" # predict, then deploy (guardian-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 +cast send $FACTORY "deployForwarder(address,address,uint32,uint32,bytes32)" \ + $DESTINATION $REFUND_ADDRESS $TARGET_PPM $FLOOR_PPM $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY ``` -The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). +The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). The +refund wallet is fixed for the clone's lifetime; the account mapping (§1.4) refuses a +clone whose `recoveryAddress` is not this client's derived wallet. Record the forwarder address + deploy tx hash. ### 1.3 Manifest: generate, verify, publish @@ -89,8 +105,8 @@ POST /v1/admin/monerium-b2b/accounts (Authorization: Bearer $ADMIN_SECRET "moneriumProfileId": "", "forwarderAddress": "", "destination": "", - "fallbackAddress": "", - "feeBps": 0 + "targetPpm": 1250, + "floorPpm": 1500 } ``` @@ -102,24 +118,23 @@ overwrite. 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 +interaction), links the client's refund wallet with its own signature, then requests IBAN +issuance for the forwarder (`POST /ibans`, async 202). The IBAN lands on the account row via the `iban.updated` webhook; 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 +has its IBAN before activation, and check the logs if it stays empty for more than a few cycles. -### 1.6 Penny test +### 1.6 Penny test (optional) -Prove the destination actually credits contract-originated USDC transfers (CEXes can -rotate or mis-credit) before real volume flows: +Optional, and recommended for exchange destinations (ADR amendment 2026-09-29): prove +the destination actually credits contract-originated USDC transfers (CEXes can rotate +or mis-credit) before real volume flows. Skipping it does not block activation. 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). +2. The keeper converts it like any other payment (the minimum swap is €1). +3. **Partner/client confirms credit at the destination** in writing. ### 1.7 Activate @@ -148,9 +163,15 @@ cast send "setGuardianPaused(bool)" true --rpc-url $RPC --pri cast send $FACTORY "setGlobalPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY # Availability lever: reduce the per-swap cap (instant, bounded by immutables) cast send $FACTORY "setPerSwapCap(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY +# Route lever: disable a route whose pool went bad (indices are stable; the keeper re-quotes each cycle) +cast send $FACTORY "setRouteEnabled(uint256,bool)" false --rpc-url $RPC --private-key $GUARDIAN_KEY +# Subsidy lever: stop topping up (below-floor swaps then defer instead of executing) +cast send $VAULT "setPaused(bool)" true --rpc-url $RPC --private-key $GUARDIAN_KEY ``` -Both pauses block `swapAndForward` only; unpause = same call with `false`. +Both pauses block `swap`, `forward` and `forwardAll` only — never `recover`; unpause = +same call with `false`. Pausing the vault pauses nothing on the forwarders: swaps that +need no subsidy keep executing. ### 2.2 Monerium IBAN suspension ask @@ -165,8 +186,9 @@ negotiation record. While unsuspended, inbound SEPA keeps minting EURe to the fo 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. +further notice**; deposits already sent convert after resolution or are refunded to the +sending bank account through the recovery path — nothing is lost by pausing); status +page entry if global. ### 2.4 Critical-vulnerability sequence (the 02:00-UTC drill) @@ -178,14 +200,14 @@ Suspected vulnerability in `VortexForwarder`/factory: 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 +5. **If funds must move: the refund path.** Mark every open deposit for recovery + (§2.7); once each clone's batch is 2 h old the keeper moves the funds to that + client's refund wallet and the payments are refunded to the payers' bank accounts. The issuer recovery backstop (burn + payout to the client's own bank account; validates the already-whitelisted ownership message) is the last resort. 6. **Ship the fix as a migration** (§5): new implementation + factory (new audit), new - clones, re-link, move IBANs, penny-test, republish the manifest. Old clones stay - paused; residual balances leave via fallback or dead-man sweep. + clones, re-link, move IBANs, optionally penny-test, republish the manifest. Old clones stay + paused; residual balances leave through the refund path. 7. **Unpause / decommission** only contracts confirmed unaffected. ### 2.5 Whitelabel-credential compromise (S1) @@ -199,19 +221,121 @@ Monerium-side links/IBANs against the DB for every account, treating the associa monitor's history as the timeline. Blast radius = deposit flow between the unauthorized change and suspension. +### 2.6 Subsidy vault operations + +One `VortexSubsidyVault` per factory, deployed once (USDC, the fee Safe as treasury, the +factory, launch limits 50 bps per swap and 200 USDC per day — ADR P13), then pointed at +by the factory and funded from the treasury. All guardian-key calls are ordinary +operations: the vault never holds client funds. + +```bash +# once: point the factory at the vault +cast send $FACTORY "setSubsidyVault(address)" $VAULT --rpc-url $RPC --private-key $GUARDIAN_KEY +# fund (from the treasury Safe): plain USDC transfer to $VAULT +# tune limits (instant) +cast send $VAULT "setMaxSubsidyPpm(uint32)" 5000 --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $VAULT "setDailyBudget(uint256)" 200000000 --rpc-url $RPC --private-key $GUARDIAN_KEY +# read runway +cast call $VAULT "dailyBudget()(uint256)" --rpc-url $RPC +cast call $VAULT "spentToday()(uint256)" --rpc-url $RPC +cast call $USDC "balanceOf(address)(uint256)" $VAULT --rpc-url $RPC +# return funds (treasury only — there is no other target) +cast send $VAULT "withdraw(uint256)" --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Sizing: the vault's per-swap cap must be at least the subsidy ladder's top (100 bps, so +`setMaxSubsidyPpm(10000)` at launch), because the keeper's tier is the effective cap and +the vault's is the ceiling. At the €10k per-swap cap a top-up at the ladder's top is +about 115 USDC, so size the daily budget from the expected number of chunks that reach +the late tiers, not from one worst case; raise the budget or lower `perSwapCap` if +deferrals become routine; both are instant. The ladder itself +(`MONERIUM_B2B_SUBSIDY_LADDER`, seconds:bps steps) and the re-quote cadence +(`MONERIUM_B2B_KEEPER_CYCLE_SECONDS`) are backend settings; tune the ladder from the +`deferring conversion ... shortfall N bps, tier M bps` log lines. + +### 2.7 Refund (recovery) procedure + +Trigger: a deposit the promised window (2 h) was missed on, a remainder below +`minSwapAmount`, a compliance decision, or a critical incident (§2.4). Prerequisites: the +client's refund wallet (the clone's `recoveryAddress()`) is linked to the client's +Monerium profile (onboarding does this, §1.5), `MONERIUM_B2B_REFUND_SEED` and the EURe +float wallet's key are in the operator's custody, and the float holds EURe and some ETH +(it also pays the refund wallet's gas). + +**Automation.** `MONERIUM_B2B_AUTO_RECOVERY` selects the mode: `off` (default) leaves +every step below to the operator; `alert` logs `REFUND DUE` for deposits past +`MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES` (120, counted from the mint) and nothing else; +`auto` marks them, and — with `MONERIUM_B2B_FLOAT_PRIVATE_KEY` set and the seed deriving +the clone's `recoveryAddress` — runs steps 2–6 itself, one refund at a time, reporting +through the refund monitor (§3). +Start on `alert`, switch to `auto` once a sandbox refund has been observed end to end. +What stays manual in `auto`: refunds of EUR 15,000 or more (Monerium's supporting +document), deposits whose issue order carried no payer IBAN/name, orders Monerium +rejects, and any step that failed five times — all park the deposit as +`recovery_failed` with the phase preserved (`monerium_recoveries.phase`/`error`); +fix the cause, then `PATCH .../deposits//status {"status": "recovering"}` resumes +from that phase. While one refund is `recovery_failed` the queue waits (one refund at a +time). + +1. **Mark the deposit.** `POST /v1/admin/monerium-b2b/deposits//recover` + (`Authorization: Bearer $ADMIN_SECRET`). Refused (409) while a keeper transaction for + the deposit is pending — retry once it settled — or when the deposit is not + `minted`/`converting`. The deposit becomes `recovering`; the keeper stops chunking it. +2. **Wait for the keeper's `recover`.** It sends `recover(eureRemaining, usdcConverted)` + once the clone's `batchOpenedAt` is `RECOVERY_DELAY` old (the contract refuses + earlier; younger deposits keep converting meanwhile). Verify the `recover` execution + row is `confirmed` and the `Recovered(eure, usdc)` event amounts match: + + ```sql + SELECT kind, eure_in_raw, usdc_net_raw, tx_hash, status, error + FROM monerium_conversion_executions WHERE deposit_id = '' ORDER BY created_at; + ``` + +3. **Swap the USDC back** from the client's refund wallet (its key derived from the seed + and the Monerium profile ID; fund it with a little ETH first) over the reverse whitelisted route + (USDC → EURC → EURe on the same pools; `exactInput` on the router with a + Chainlink-derived minimum, 60 bps tolerance), or leave the USDC in the refund + wallet and let the float cover the whole difference when the market is thin. +4. **Top up from the float:** transfer `issueAmount − EURe on the refund wallet` EURe + from the float wallet to the refund wallet. Book that amount as the refund's + subsidy; book any EURe surplus from step 3 to the treasury. +5. **Redeem the exact amount.** `POST /orders` from the refund wallet: `kind: redeem`, + `amount` = the issue order's `amount` string, `counterpart.identifier.iban` = the issue + order's `counterpart.identifier.iban`, `details.companyName` = its `details.name` + (individual payers: `firstName`/`lastName`), `country` from the IBAN prefix, `memo` + naming the original payment, the message `Send EUR to at ` + signed by the refund wallet's key; attach `supportingDocumentId` above EUR 15,000 (the + same client agreement can be reused, Monerium 2026-09-30). Monerium pays the refund + out of the client's own IBAN. Watch `order.updated` for `processed`. +6. **Close the deposit.** `PATCH /v1/admin/monerium-b2b/deposits//status` + with `{"status": "refunded"}`; use `recovery_failed` when a step cannot complete (and + `recovering` again to retry later). Record deposit id, recover tx, reverse-swap tx, + float top-up, redeem order id and payer IBAN (masked) in the ops ledger. + ## 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 | +| `DEPTH BELOW FLOOR — raw quote impact at minSwapAmount exceeds SLIPPAGE_BPS` | Even minimum-size fills land below Chainlink − 60 bps on every route before settlement. The floor is enforced on the client's net, so the keeper still executes while the vault covers the shortfall (up to the per-swap cap; beyond it the keeper defers and logs `deferring conversion`), but every swap of that size now costs a subsidy and the unsubsidized permissionless path would revert | Investigate pool state (LP exit, depeg) and watch the vault spend (§2.6); whitelist a better route or lower `perSwapCap`; global pause (§2.1) if it is a depeg or the vault is being drained; re-run the liquidity-baseline methodology before trusting the route again | +| `raw quote impact at perSwapCap exceeds SLIPPAGE_BPS` | Cap-sized swaps would need a vault subsidy; availability and vault spend, not fund risk | Lower `perSwapCap`, add a route, or accept the subsidies; watch for escalation | +| `deferring conversion for account` | The keeper declined to swap this cycle; the reason follows: `reference rate unavailable` (Coinbase unreachable, a malformed ticker, or `spread of N bps exceeds 50 bps` — a thin book; check egress and the venue), `outside the ... band around Chainlink` (EURC/EUR basis or a stale Chainlink round), `exceeds the current tier` (normal while the chunk waits for the market; the line names the shortfall and the tier), `projected subsidy ... exceeds` cap/budget/balance (§2.6: fund, raise limits, or wait for the market), `below the oracle floor` (only on the permissionless path since 2026-09-18: keeper swaps are settled up to the Chainlink floor by fee and tier-bounded subsidy, or defer on the tier/cap lines above), `no enabled swap route could be quoted` or `the factory has no enabled swap route` (§2.1 route lever) | Funds wait with the batch marker open; a deferral that outlives the 2 h window means the payment is refunded (§2.7) rather than converted late — communicate; after 24 h the permissionless path can execute unsubsidized | +| `SUBSIDY VAULT —` (error) | Vault paused or empty: every below-floor swap defers | §2.6: fund or unpause; check why it emptied (budget too high for the market?) | +| `subsidy vault ... refill before below-floor swaps start deferring` | Less than a day of budget left, or today's budget spent | §2.6 refill; consider the budget vs. observed spreads | +| `no subsidy vault is configured on the factory` | `setSubsidyVault` never ran; below-floor swaps defer | §2.6 | +| `route ... could not be quoted` | One whitelisted route's pool is unquotable (drained, removed) | Disable it (§2.1) so the keeper stops trying; keep at least one healthy route | | `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 | +| `stranded funds on forwarder ... past RECOVERY_DELAY` (warn) | A batch has been open longer than the promised 2 h window and is neither forwarded nor recovering | Check worker liveness, RPC health, keeper gas, oracle staleness (`StalePrice` reverts), `deferring conversion` lines; if the payment cannot complete, mark it for recovery (§2.7) | +| `stranded funds ... past TRIGGER_DELAY` (error) | Permissionless path now live; SLA long broken (keeper outage or a persistent deferral) | Escalate; anyone may call `swap(reference, route, amountIn)` and `forwardAll()` — that path prices against Chainlink and pays no subsidy; communicate the delay | +| `REFERENCE VENUE —` (error) | The Coinbase product the reference reads is delisted or halted; every keeper swap defers silently | Change `COINBASE_REFERENCE_PRODUCT` (a live EURC market), redeploy the backend; the venue is an operational, not an on-chain, setting | +| `REFUND DUE — deposit ...` (error, `alert` mode) | A deposit outlived the promised window and the mode only reports | Mark it (§2.7 step 1) or switch to `auto` | +| `REFUND FAILED — deposit ... in phase ...` (error) | A refund step cannot complete automatically (large amount, missing payer, rejected order, five failed attempts) | §2.7: finish by hand from the named phase, or fix the cause and set the deposit back to `recovering` | +| `FLOAT UNDERFUNDED` / `FLOAT EMPTY` (error) | The EURe float cannot cover a top-up; the refund waits at `swapped` | Fund the float wallet named in the line; the step retries every cycle | +| `refund of deposit ... in phase ... since` (warn ≥1 h, error ≥4 h) | The active refund lingers | Check the recovery wallet's balances and pending transactions, RPC health, Monerium order state; escalate per §2.7 | | `untrusted factory` / `config violation` / `bytecode is not the EIP-1167 clone` / `not registered on trusted factory` | Should-be-impossible state | Full incident: global pause, verify `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS`, run the manifest verifier, compare against manifest history | -| `reconciled 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) | +| `reconciled guardian-authorized fee policy change` | A timelocked fee-policy change applied — expected, DB updated | No incident; confirm it matches the announced change | +| `config violation ... destination changed on chain` | Should be impossible: the clone has no destination setter | Full incident (see the row above) | | `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) | @@ -224,14 +348,18 @@ address the client no longer controls. The gate converts that silent loss into a **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. +`dormant_since` is recorded; the conversion executor stops swapping and forwarding for +it but still recovers deposits marked for the refund path (`recover` ignores the pause). +EURe arriving during dormancy accumulates safely; a deposit into a dormant account is +therefore refunded through §2.7 once the window is missed, unless re-confirmation +arrives first. **Re-confirmation (manual, via partner):** partner re-confirms in writing that the -destination is valid and client-controlled (ADR B5). If the destination changed, 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. +destination is valid and client-controlled (ADR B5). If the destination changed, deploy +a new clone with the new destination and migrate (§5) — the clone has no setter and +Vortex must never redirect — and re-running the penny test is recommended for CEX +destinations. Archive the +confirmation. **Un-pause (both steps, always):** @@ -256,13 +384,13 @@ 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). +2. Let the keeper drain the old clone (forward every open deposit; refund a remainder below the minimum via §2.7). 3. Link the new clone to the same Monerium profile (attestor flow — automated once the account row's forwarder is repointed, or manual `POST /addresses`). 4. Move the IBAN: `PATCH /ibans/{iban}` with the new address — this is the S1-sensitive operation; it must only ever happen inside an announced migration. -5. Update the `monerium_accounts` row (forwarder address), penny-test the new clone, - re-activate. +5. Update the `monerium_accounts` row (forwarder address), optionally 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. @@ -272,12 +400,12 @@ the IBAN's current default address; the old clone stays linked but inert. | 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 | +| Keeper | `poke`/`swap`/`forward`/`recover`: can pick any whitelisted route and any reference inside the Chainlink band — worst case the fee reaches the 1% cap or the vault pays up to its caps, plus gas theft — and can move a payment whose batch is 2 h old to the client's refund wallet (never anywhere else, never a redirect) | Rotate; `setKeeper(old,false)` + `setKeeper(new,true)`; pause the vault while rotating; reconcile executions against Coinbase history; audit `Recovered` events against marked deposits; refund gas | +| Refund seed (`MONERIUM_B2B_REFUND_SEED`) | Derives every client's refund wallet; each holds funds only between that client's `recover` and its bank refund, and can redeem them out of the client's IBAN to any IBAN | Set `MONERIUM_B2B_AUTO_RECOVERY=off`, finish or reconcile open refunds by hand, rotate the seed, then give every client a new clone with its new refund wallet and move the IBANs (§5); the old wallets hold nothing between refunds | +| Guardian | Pause/unpause, bounded params, timelocked fee policy, route whitelist (validated), vault limits and withdrawal to treasury — delay-only griefing plus Vortex-money exposure | Two-step `transferGuardian`/`acceptGuardian`; audit pause, pending-policy, route and vault 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 @@ -310,7 +438,7 @@ standard public Anvil development keys for the local roles. | Forwarder | `0xe06103c9E374a1CD78f17417d1eA3AE4eBaC7CFD` | | Forwarder deployment tx | `0x7d4f667d4de9b7a5d5aced2203a3f11dcd0b477e5d405d5b8a2317f2b56b7c4c` | | Destination | `0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc` | -| Fallback address | `0x976EA74026E726554dB657fA54763abd0C3a0aa9` | +| Recovery wallet | (reference run predates the recovery wallet; use any local EOA) | | Local account id | `5ebca15c-dadf-4eeb-aabb-e9c7462ff6b3` | | Mock Monerium profile id | `f436dbeb-6012-4688-ab3b-d2446980c835` | | Managed profile id | `c419d077-3e2b-488a-b228-359311c63324` | @@ -320,9 +448,11 @@ 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. +consumed the full 25 EURe, and forwarded `29.012924` USDC with a zero fee. That run +predates the reference-priced fee bands; a new run records a reference, a route, and a +fee or subsidy per the bands instead of a flat zero fee. These addresses and hashes are +evidence from that ephemeral run, not deployment pins; use the receipts and addresses +produced by each new run. ### 7.2 Start an archive-backed fork @@ -410,32 +540,38 @@ fixtures: | Constructor field | Value | |---|---:| | `MAX_ORACLE_AGE` | 52 hours | -| `SLIPPAGE_BPS` | 100 | -| `MAX_FEE_BPS` | 100 | -| `SWEEP_DELAY` | 60 days | +| `SLIPPAGE_BPS` | 60 (on the client's net after fee and subsidy) | +| `MAX_FEE_PPM` | 10000 | +| `MAX_REFERENCE_DEVIATION_BPS` | 100 | +| `RECOVERY_DELAY` | 2 hours | | `TRIGGER_DELAY` | 24 hours | -| `POOL_FEE_EURE_EURC` | 500 | -| `POOL_FEE_EURC_USDC` | 500 | +| Initial route | EURe → EURC → USDC, 500 / 500 (packed path constructor argument) | | `RECOVERY_HASH` | `bytes32(0)` | -| `MIN_SWAP_FLOOR` | `25e18` | +| `MIN_SWAP_FLOOR` | `1e18` | | `CAP_CEILING` | `50000e18` | -| Initial `minSwapAmount` | `250e18` | -| Initial `perSwapCap` | `25000e18` | +| Initial `minSwapAmount` | `1e18` | +| Initial `perSwapCap` | `10000e18` | -After deployment, register Anvil account 1 as a keeper and lower the mutable minimum to -the immutable 25 EURe floor for this exercise: +After deployment, register Anvil account 1 as a keeper: ```bash cast send "$FACTORY" "setKeeper(address,bool)" "$KEEPER" true \ --private-key "$GUARDIAN_KEY" --rpc-url http://127.0.0.1:8545 - -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. +Deploy `VortexSubsidyVault` (USDC, account 3 as treasury, the factory, 5000 ppm, 200e6) +and point the factory at it with `setSubsidyVault`. Fund it with USDC from an +impersonated mainnet holder if you want to exercise a below-floor top-up; left empty, +a below-floor fill makes the keeper defer, which is also a valid outcome to observe. + +Deploy a client clone with the launch policy (1250 / 1500) as in §1.2, passing the +refund address the backend derives for the fixture's Monerium profile ID (the account +mapping verifies it). Use a fresh salt and record the predicted address and receipt. +Read back `destination()`, `recoveryAddress()`, +`targetPpm()`, `floorPpm()`, and `FACTORY()`, then require +`factory.isForwarder(forwarder) == true` before continuing. The keeper computes its +reference from the live Coinbase ticker before each swap, so the backend needs outbound +HTTPS during the run. ### 7.4 Create the local account fixture @@ -451,8 +587,8 @@ managed-profile manager: "moneriumProfileId": "", "forwarderAddress": "", "destination": "", - "fallbackAddress": "", - "feeBps": 0 + "targetPpm": 1250, + "floorPpm": 1500 } ``` @@ -520,8 +656,11 @@ 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. +expected path is deliberately `unattr:` rather than an attributed customer deposit — and +since 2026-09-17 the keeper never converts `unattr:` rows, so to exercise a conversion +insert the matching order row by hand (or send the mint through the sandbox webhook) +before the watcher records it; the log should then show the mint, one `swap` execution +and one `forward` execution. Verify the durable records: @@ -530,29 +669,30 @@ 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, +SELECT kind, deposit_id, eure_in_raw, usdc_gross_raw, fee_raw, subsidy_raw, usdc_net_raw, destination, + reference_rate_raw, reference_source, max_subsidy_raw, route_index, tx_hash, nonce, broadcast_block_number, block_number, swap_log_index, status, error FROM monerium_conversion_executions -WHERE account_id = ''; - -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 = '' -); +WHERE account_id = '' ORDER BY created_at; ``` 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. +- One `forwarded` deposit with the real transfer hash and log index. +- One `confirmed` `swap` execution bound to it with the 25 EURe input, a recorded + reference (rate, source), the tier cap and route index 0, a fee or subsidy + consistent with the fill's position against the reference bands (`usdc_net_raw = + usdc_gross_raw - fee_raw + subsidy_raw`), non-null nonce/hash/block/swap-log-index, + destination matching the clone, and `error IS NULL`. If the vault was left empty and + the fill sat below the floor, expect a `deferring conversion` log line and no + execution row instead. +- One `confirmed` `forward` execution bound to the same deposit whose `usdc_net_raw` + equals the swap's net. +- The forwarder's EURe and USDC balances are zero. +- The destination's USDC balance increased by the forward's `usdc_net_raw` in one transfer. +- The swap receipt contains `SwapExecuted` from the clone and no transfer to the + destination; the forward receipt contains `Forwarded` and the USDC `Transfer` from the + clone to the configured destination. ### 7.7 What this exercise validates @@ -568,16 +708,15 @@ Required results: 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. +- The executor's durable path leaves a confirmed `swap` execution bound to the deposit + with its nonce, transaction hash, block number, log index, amounts and destination + recorded, followed by a confirmed `forward` execution for the summed net. +- The real contract accepts the current Chainlink EUR/USD answer, the keeper's live + Coinbase reference inside the band, and swaps successfully through whitelisted route 0 + (EURe -> EURC -> USDC on the 5-bps tiers). - Keeper authorization, the 25 EURe minimum, allowance reset, full EURe consumption, - 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. + fee-band accounting against the recorded reference, USDC accumulating on the clone, + and one forward to the immutable per-client destination work together. ### 7.8 What this exercise does not validate @@ -597,9 +736,10 @@ Required results: 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 fee-policy timelocks, route selection among several routes, a funded + vault's top-up (unless you fund it), the reference band rejection, per-swap-cap + chunking of one deposit, sub-minimum remainders, pause controls, dormancy, + permissionless triggering, or the recovery path (§2.7). - It does not test stale/invalid oracle answers, insufficient liquidity, excess price impact, slippage reverts, router failure, token transfer failure, or depeg behavior. - It does not test reorg replacement, duplicate-log replay, concurrent executors, @@ -613,3 +753,211 @@ Required results: monitor received the expected provider `403` for the fake profile, and the large-size executable-depth quote timed out once; neither monitor was part of the conversion success criterion. + +## 8. Sepolia sandbox bring-up + +Monerium's sandbox mints EURe on Ethereum Sepolia, and `api-sandbox.vortexfinance.co` +(the `vortex-sandbox` Render service, deployed from `main`) runs the keeper for it. +This section is the Sepolia counterpart of the mainnet deploy checklist in the rollout +doc. Every command below was dry-run on a local fork of Sepolia on 2026-10-06: pool, +factory, vault and clone, a payment converted in two chunks and forwarded, and the +refund leg (recover after the window, reverse swap). Set the B2B variables on the +`vortex-sandbox` service only, never in the shared "Vortex API" env group, which +production and staging both read; production stays dark. + +### 8.1 Sepolia addresses (verified on chain 2026-10-06) + +| Contract | Address | +|---|---| +| EURe (Monerium sandbox) | `0x67b34b93ac295c985e856E5B8A20D83026b580Eb` | +| EURC (Circle) | `0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4` | +| USDC (Circle) | `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238` | +| Chainlink EUR/USD (8 decimals) | `0x1a81afB8146aeFfCFc5E50e8479e826E7D55b910` | +| Uniswap v3 factory | `0x0227628f3F023bb0B980b67D528571c95c6DaC1c` | +| Uniswap SwapRouter02 | `0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E` | +| Uniswap NonfungiblePositionManager | `0x1238536071E1c677A632429e3655c799b22cDA52` | +| Mispriced EURe/USDC 5 bps pool (never whitelist) | `0xaC4D4fe930cb78b6eAC08e8Ec3cCA5Ea12059aD2` | + +The July 2026 link-test deployment (factory `0xcBE354…`) used other tokens and a +placeholder router; it is not reusable. + +### 8.2 What differs from mainnet + +- **Vortex runs the pool.** The only EURe/USDC pool with liquidity prices EURe at 0.71 + USDC (Chainlink: 1.127 on 2026-10-06), so a swap there misses the floor (Chainlink + − `SLIPPAGE_BPS`) and reverts, and every payment would end in a refund. Vortex seeds + its own 1 bps pool at the Chainlink price (§8.4). +- **No quoting.** Off mainnet the keeper does not quote routes; it swaps on the first + enabled factory route. Make the 1 bps pool the initial route and add no other. +- **No arbitrage.** Nothing pulls the pool back to the market, and each conversion moves + it a little. Re-centre it before every test session (§8.9). The mispriced 5 bps pool + is an open arbitrage against it: if its USDC disappears between sessions, re-seed. +- **Fresh keys only.** The well-known development keys (anvil's) carry delegated code on + Sepolia that sweeps any ETH sent to them. +- **No private orderflow.** `MONERIUM_B2B_PRIVATE_RPC_URL` is required only when + `DEPLOYMENT_ENV=production`. + +### 8.3 Keys and funding + +- Fresh EOAs: guardian, keeper and attestor (three distinct keys), the float wallet + (`MONERIUM_B2B_FLOAT_PRIVATE_KEY`), and a fee recipient address Vortex controls. + `MONERIUM_B2B_REFUND_SEED` is any fresh 32-byte secret (`openssl rand -hex 32`). +- Sepolia ETH: about 0.2 each for the guardian (deployments), the keeper (swaps, + forwards, recoveries) and the float (it tops up the refund wallets' gas). +- Sandbox EURe for the guardian (pool seeding) and the float (refund top-ups): link the + address to a Vortex profile in Monerium's sandbox and use "Simulate bank transfer" on + that profile's IBAN. Sandbox EURe costs nothing. +- USDC: buy it with sandbox EURe from the mispriced 5 bps pool; its price is irrelevant + when the EURe is free. 2,000 EURe bought about 1,385 USDC in the dry run. + +```bash +RPC= +EURE=0x67b34b93ac295c985e856E5B8A20D83026b580Eb +EURC=0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4 +USDC=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 +ORACLE=0x1a81afB8146aeFfCFc5E50e8479e826E7D55b910 +ROUTER=0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E +NPM=0x1238536071E1c677A632429e3655c799b22cDA52 +UNI_FACTORY=0x0227628f3F023bb0B980b67D528571c95c6DaC1c +GUARDIAN=$(cast wallet address $GUARDIAN_KEY) + +cast send $EURE "approve(address,uint256)" $ROUTER 2000ether --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $ROUTER "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "($EURE,$USDC,500,$GUARDIAN,2000000000000000000000,0,0)" --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +### 8.4 Seed the 1 bps pool at the Chainlink price + +USDC sorts before EURe, so the pool's price is EURe base units per USDC base unit, +`1e20 / answer` for a Chainlink answer with 8 decimals. The position spans ±1% (100 +ticks at the 1 bps tier's spacing of 1). 1,000 USDC and about 890 EURe keep a €100 +payment's price impact around 0.1%. + +```bash +ANSWER=$(cast call $ORACLE "latestRoundData()(uint80,int256,uint256,uint256,uint80)" --rpc-url $RPC | sed -n 2p | awk '{print $1}') +read SQRT_PRICE TICK_LOWER TICK_UPPER < <(python3 -c " +import math; a=$ANSWER +tick = math.floor(math.log(10**20 / a, 1.0001)) +print(math.isqrt(10**20 * 2**192 // a), tick - 100, tick + 100)") + +cast send $NPM "createAndInitializePoolIfNecessary(address,address,uint24,uint160)" $USDC $EURE 100 $SQRT_PRICE \ + --rpc-url $RPC --private-key $GUARDIAN_KEY +POOL=$(cast call $UNI_FACTORY "getPool(address,address,uint24)(address)" $USDC $EURE 100 --rpc-url $RPC) +cast send $USDC "approve(address,uint256)" $NPM 1000000000 --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $EURE "approve(address,uint256)" $NPM 1000ether --rpc-url $RPC --private-key $GUARDIAN_KEY +# the gas estimate is too low for a mint that moves EURe; pass a limit +cast send $NPM "mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))" \ + "($USDC,$EURE,100,$TICK_LOWER,$TICK_UPPER,1000000000,1000000000000000000000,0,0,$GUARDIAN,$(( $(date +%s) + 3600 )))" \ + --gas-limit 1500000 --rpc-url $RPC --private-key $GUARDIAN_KEY +cast call $USDC "balanceOf(address)(uint256)" $POOL --rpc-url $RPC # 1000000000 +``` + +### 8.5 Deploy the factory, register the keeper, deploy the vault + +The parameters are the ADR's (§7.3 table) except `perSwapCap`: €25 lets a €60 test +payment convert in three chunks. It is operational; `setPerSwapCap` changes it later. +The initial route is the 1 bps pool, EURe → USDC. + +From `contracts/monerium-forwarder/`, with `ATTESTOR`, `KEEPER` and `FEE_RECIPIENT` set +to the §8.3 addresses, and `FACTORY` and `VAULT` taken from forge's "Deployed to" line: + +```bash +ROUTE=$(cast concat-hex $EURE 0x000064 $USDC) # fee 100 as three bytes +forge create src/VortexForwarderFactory.sol:VortexForwarderFactory --rpc-url $RPC --private-key $GUARDIAN_KEY --broadcast \ + --constructor-args "($EURE,$EURC,$USDC,$ROUTER,$ORACLE,$ATTESTOR,$FEE_RECIPIENT,187200,60,10000,100,7200,86400,0x0000000000000000000000000000000000000000000000000000000000000000)" \ + 1000000000000000000 50000000000000000000000 1000000000000000000 25000000000000000000 $ROUTE +cast call $FACTORY "route(uint256)(bytes,bool)" 0 --rpc-url $RPC # the path above, true +cast send $FACTORY "setKeeper(address,bool)" $KEEPER true --rpc-url $RPC --private-key $GUARDIAN_KEY + +# vault: 1% per swap (the ladder's top), 50 USDC per day +forge create src/VortexSubsidyVault.sol:VortexSubsidyVault --rpc-url $RPC --private-key $GUARDIAN_KEY --broadcast \ + --constructor-args $USDC $FEE_RECIPIENT $FACTORY 10000 50000000 +cast send $FACTORY "setSubsidyVault(address)" $VAULT --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $USDC "transfer(address,uint256)" $VAULT 20000000 --rpc-url $RPC --private-key $GUARDIAN_KEY +``` + +Then generate and verify the manifest as in §1.3. Sepolia's public RPCs prune old logs, +so pass `--logs-rpc` with an endpoint that serves the full history, and commit the +result to `manifests/`. + +### 8.6 Sandbox backend configuration + +On the `vortex-sandbox` service only: + +| Variable | Value | +|---|---| +| `FLOW_VARIANT` | `mykobo` (startup refuses B2B otherwise; the sandbox's retail EUR onramp then runs on Mykobo) | +| `MONERIUM_WHITELABEL_CLIENT_ID`, `MONERIUM_WHITELABEL_CLIENT_SECRET` | SulPayments' sandbox white-label app (S11) | +| `MONERIUM_B2B_RPC_URL` | Sepolia RPC | +| `MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS` | `$FACTORY` | +| `MONERIUM_B2B_KEEPER_PRIVATE_KEY`, `MONERIUM_B2B_GUARDIAN_PRIVATE_KEY`, `MONERIUM_B2B_ATTESTOR_PRIVATE_KEY` | the three keys of §8.3 | +| `MONERIUM_B2B_REFUND_SEED`, `MONERIUM_B2B_FLOAT_PRIVATE_KEY` | §8.3 | +| `MONERIUM_B2B_AUTO_RECOVERY` | `auto`, so the refund test runs end to end | +| `MONERIUM_B2B_WEBHOOK_SECRET` | `whsec_` plus base64 of 32 random bytes: `echo "whsec_$(openssl rand -base64 32)"` | +| `MONERIUM_B2B_ENABLED` | `true`, set last | + +Register Vortex's webhook subscription on SulPayments' sandbox app with the same +secret, from `apps/api` with that app's credentials and `MONERIUM_API_URL` pointing at +Monerium's sandbox API: + +```bash +SECRET= MONERIUM_WHITELABEL_CLIENT_ID=... MONERIUM_WHITELABEL_CLIENT_SECRET=... \ +MONERIUM_API_URL=https://api.monerium.dev bun -e ' +import { MoneriumApiService } from "@vortexfi/shared"; +console.log(await MoneriumApiService.getInstance().createWebhook({ + secret: process.env.SECRET, types: ["iban.updated", "order.created", "order.updated", "profile.updated"], + url: "https://api-sandbox.vortexfinance.co/v1/monerium-b2b/webhook" }));' +``` + +Restart the service. Startup fails if a required setting is missing; once it is up, +`GET /v1/monerium-b2b/accounts` answers 401 without a key instead of 404. + +### 8.7 Partner and test clients + +1. Make SulPayments' sandbox profile a manager: + `PUT /v1/admin/managed-profile-managers/` with corridor `EU` and customer + type `business`. SulPayments then takes a key from dashboard-sandbox and registers + its webhook through `POST /v1/webhook` (`DEPOSIT_UPDATED`, `ACCOUNT_UPDATED`). +2. Per test client, once SulPayments confirms the Monerium profile is approved: §1.2 + (refund address, deploy the clone), §1.4 (map), §1.5 (automatic link and IBAN), + §1.7 (activate). Until the destination endpoint (V6) exists, SulPayments sends the + profile ID and the Sepolia destination to Vortex directly. + +### 8.8 Test payments + +SulPayments simulates each SEPA payment on the client's profile in its Monerium sandbox +app ("Simulate bank transfer"); EURe lands on the clone and the keeper takes over. + +| Test | Payment | Expected | +|---|---|---| +| Normal | €20 | One chunk, one forward. The destination receives the reference less the client's target (12.5 bps); the fee treasury the surplus over it | +| Chunked | €60 | Three chunks at the €25 cap, then one forward of their sum | +| Refund | €15 | Suspend the account before the payment (`PATCH /v1/admin/monerium-b2b/accounts//status` with `suspended`): the keeper converts nothing for a suspended account but still arms the clone's clock and runs recoveries. After two hours the deadline job marks the deposit, the keeper recovers it, and the refund leaves from the client's IBAN. Reactivate afterwards. If the simulated transfer carries no payer IBAN and name, the refund parks as `recovery_failed`; that is a finding about the sandbox simulation, closed with `PATCH /v1/admin/monerium-b2b/deposits//status` | + +`DEPOSIT_UPDATED` reports every step to SulPayments, and `GET /v1/monerium-b2b/deposits` +shows the same snapshots. + +### 8.9 Re-centre the pool before a session + +Each conversion sells EURe into the pool and pushes its price away from Chainlink. A +swap with a price limit moves it back exactly: + +```bash +ANSWER=$(cast call $ORACLE "latestRoundData()(uint80,int256,uint256,uint256,uint80)" --rpc-url $RPC | sed -n 2p | awk '{print $1}') +TARGET=$(python3 -c "import math; print(math.isqrt(10**20 * 2**192 // $ANSWER))") +CURRENT=$(cast call $POOL "slot0()(uint160,int24,uint16,uint16,uint16,uint8,bool)" --rpc-url $RPC | head -1 | awk '{print $1}') +if python3 -c "import sys; sys.exit(0 if $CURRENT > $TARGET else 1)"; then + # EURe cheaper than Chainlink: buy EURe with USDC up to the target price + cast send $USDC "approve(address,uint256)" $ROUTER 1000000000 --rpc-url $RPC --private-key $GUARDIAN_KEY + cast send $ROUTER "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "($USDC,$EURE,100,$GUARDIAN,1000000000,0,$TARGET)" --gas-limit 600000 --rpc-url $RPC --private-key $GUARDIAN_KEY +else + # EURe dearer than Chainlink: sell EURe for USDC down to the target price + cast send $EURE "approve(address,uint256)" $ROUTER 1000ether --rpc-url $RPC --private-key $GUARDIAN_KEY + cast send $ROUTER "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "($EURE,$USDC,100,$GUARDIAN,1000000000000000000000,0,$TARGET)" --gas-limit 600000 --rpc-url $RPC --private-key $GUARDIAN_KEY +fi +``` + +The router pulls only what the move needs. Chainlink's Sepolia feed must stay under the +52-hour `MAX_ORACLE_AGE`; it was four hours old when checked. diff --git a/docs/product-monerium-b2b-flow.md b/docs/product-monerium-b2b-flow.md new file mode 100644 index 000000000..46f87229b --- /dev/null +++ b/docs/product-monerium-b2b-flow.md @@ -0,0 +1,683 @@ +# Monerium B2B Onramp: End-to-End Flow + +> **Status:** living overview, draft for alignment. Last updated 2026-10-01, including +> Monerium's written answers and the call of 2026-09-30, and the KYB and sandbox +> decisions sent to SulPayments and Monerium on 2026-10-01. +> **Audience:** Vortex/SatoshiPay internally, SulPayments, and Monerium. +> **Scope:** the EUR to USDC onramp for SulPayments' business clients, as built for the +> pilot on the branch of PR #1375. It is not merged or deployed yet. Open questions carry +> an ID such as **[M2]** (Monerium), **[S1]** (SulPayments) or **[V1]** (Vortex internal) +> and are collected in [section 12](#12-open-questions), with space for the answers. +> Changes proposed but not built yet are marked **Proposed**. Facts from Monerium's +> public API spec (version 2.0.0) and guides are marked as such where Monerium has not +> confirmed them in writing. + +## Contents + +1. [In one paragraph](#1-in-one-paragraph) +2. [Who is involved](#2-who-is-involved) +3. [The flow at a glance](#3-the-flow-at-a-glance) +4. [Client onboarding](#4-client-onboarding) +5. [Payment in: SEPA to EURe](#5-payment-in-sepa-to-eure) +6. [Conversion: EURe to USDC](#6-conversion-eure-to-usdc) +7. [Delivery: one USDC transfer per payment](#7-delivery-one-usdc-transfer-per-payment) +8. [Unhappy path: full EUR refund](#8-unhappy-path-full-eur-refund) +9. [Status, reporting and support](#9-status-reporting-and-support) +10. [What Vortex can and cannot do](#10-what-vortex-can-and-cannot-do) +11. [Key parameters](#11-key-parameters) +12. [Open questions](#12-open-questions) +13. [Related documents](#13-related-documents) + +## 1. In one paragraph + +A SulPayments business client sends EUR by SEPA to its own dedicated IBAN. Monerium +mints the same amount of EURe to a smart contract that Vortex deployed for that client, +the **forwarder**. Vortex converts the EURe to USDC on chain at a price tied to a public +market reference, and sends the whole payment to the client's wallet as **one USDC +transfer**. If a payment cannot be converted within **two hours**, Vortex refunds the +**full EUR amount** to the bank account it came from. The forwarder can only ever pay +three places: the client's fixed wallet, Vortex's fee treasury, and, for a refund after +the two-hour window, the client's own refund wallet, which Vortex holds. + +## 2. Who is involved + +| Party or component | Role | +|---|---| +| **SulPayments** | Partner. Brings the business clients, owns the white-label app at Monerium in which their profiles live, performs and submits their KYB under its reliance agreement with Monerium, receives status webhooks, and hands each client its IBAN. | +| **Client** | The business that sends EUR and receives USDC in its own wallet, the **destination**. | +| **Monerium** | Licensed EURe issuer. Hosts each client's profile and IBAN in SulPayments' white-label app, mints EURe for incoming SEPA payments, and pays out EUR on redemption. | +| **Vortex / SatoshiPay** | Operator. Deploys the contracts, runs the **keeper** service that converts and forwards, runs refunds, and reports status. Uses SulPayments' white-label app credentials for everything after KYB. | +| **Forwarder contract** | One per client on Ethereum. Receives the minted EURe, swaps it, holds the USDC until the payment is complete, then forwards it. | +| **Subsidy vault** | A Vortex-funded USDC pool that tops up a swap when the market delivers less than the client's guaranteed floor. | +| **Fee treasury** | Vortex multisig that receives the conversion fee. | +| **Refund wallet** | One Vortex-held wallet per client, fixed in the client's forwarder at deployment and linked to the client's Monerium profile. A payment whose window was missed moves there, and the refund is paid out from it through the client's own IBAN. | +| **Float wallet** | A Vortex wallet that covers round-trip losses so a refund is always the exact amount, and pays the refund wallets' gas. | +| **Price sources** | Coinbase Exchange EURC-USDC market for the reference rate, Chainlink EUR/USD as the on-chain safety bound, Uniswap v3 where the swaps execute. | + +## 3. The flow at a glance + +```mermaid +flowchart LR + subgraph Bank["Banking"] + CB["Client's bank account"] + end + subgraph Mon["Monerium"] + IBAN["Client IBAN
points at the forwarder"] + RED["Redemption
EUR payout"] + end + subgraph Eth["Ethereum"] + FWD["Forwarder contract
one per client"] + UNI["Uniswap v3
whitelisted route"] + VAULT["Subsidy vault"] + TREAS["Fee treasury"] + DEST["Client wallet
fixed destination"] + REC["Client refund wallet"] + end + subgraph Vx["Vortex"] + KEEP["Keeper service"] + end + SP["SulPayments"] + + CB -- "1 SEPA EUR" --> IBAN + IBAN -- "2 mint EURe" --> FWD + KEEP -- "3 swap in chunks" --> FWD + FWD <-- "EURe to USDC" --> UNI + VAULT -. "top-up below floor" .-> FWD + FWD -. "fee above target" .-> TREAS + FWD -- "4 one USDC transfer" --> DEST + KEEP -- "status webhooks" --> SP + FWD -. "not converted in 2 h" .-> REC + REC -. "refund EUR" .-> RED + RED -. "SEPA refund" .-> CB +``` + +Solid arrows are the happy path. Dotted arrows only happen when needed: a fee or +subsidy on a swap, or the refund path. + +## 4. Client onboarding + +### 4.1 What has to be true when onboarding ends + +- The client has a **KYB-approved corporate profile** in SulPayments' white-label app at + Monerium, submitted by SulPayments under its reliance agreement. +- A **forwarder contract** exists with the client's destination wallet written into it. + The destination cannot be changed later. A new wallet means a new forwarder and moving + the IBAN, on SulPayments' written instruction. +- The forwarder is **linked** to the client's Monerium profile, and the client's **IBAN + points at the forwarder**. Monerium mints to whatever address the IBAN points at, so + this is what routes every payment through the conversion. An IBAN pointing at the + client's own wallet would deliver EURe, not USDC. +- The client's **refund wallet** is linked to the client's profile as well, so a refund + leaves from the client's own IBAN (section 8.3). +- Vortex has mapped the client under **SulPayments' partner account**, so webhooks and + API reads reach SulPayments. + +### 4.2 Proposed onboarding flow + +SulPayments owns the white-label app at Monerium and submits each client's KYB there. +Monerium returns the new profile's ID, and SulPayments passes it to Vortex together with +the client's destination wallet. Vortex does everything after KYB with the same app's +credentials. + +```mermaid +sequenceDiagram + autonumber + participant SP as SulPayments + participant M as Monerium + participant V as Vortex + participant C as Ethereum + participant CL as Client + + SP->>M: Create the client profile in SulPayments' app + M-->>SP: Profile ID + SP->>M: Company details, form and verifications + SP->>V: Destination wallet for this profile ID [V6] + V->>M: Check the profile exists in SulPayments' app + M-->>V: profile.updated, state approved + V->>C: Deploy forwarder with destination and fee policy + V->>M: Link forwarder and refund wallet to the profile + V->>M: Request the IBAN for the forwarder + M-->>V: iban.updated, IBAN issued + V->>M: Read the IBAN back from the API [V7] + V->>SP: Client active, IBAN readable through the API [S4, V4, V12] + SP->>CL: Hand over IBAN, client starts sending EUR +``` + +Notes on the flow: + +- **SulPayments' app, shared credentials.** Monerium's reliance agreement is with + SulPayments, so the client profiles live in a white-label app in SulPayments' Monerium + account. Only the app that onboarded a profile can read it or act on it, so Vortex + uses the same app's client ID and secret. Monerium's guides describe one credential + pair per app **[M2]**. Each partner gets its own white-label app, so a new partner + means a new app and a new credential pair for Vortex **[V8]**. +- **Division of work.** SulPayments creates profiles and submits KYB. Vortex links + addresses, requests the IBAN, places refunds, and registers its own webhook + subscription. SulPayments must never link addresses, request or move IBANs, place + orders, or change Vortex's webhook subscription. Closing a profile also closes its + IBAN, so SulPayments coordinates closures with Vortex **[S2]**. +- **KYB.** Companies must use Monerium's reliance route: SulPayments delivers company + details, form, verifications and files to Monerium directly. SulPayments calls + Monerium's KYB and profile endpoints itself; SatoshiPay does not proxy them (decided + 2026-10-01). Approval takes seconds when the data follows + Monerium's corporate KYB guide. Vortex never handles KYB data, which matches the + security spec. Reading a profile returns only the company name, so KYB details stay + with SulPayments. +- **Destination handover by profile ID (planned for the week of 2026-10-05).** Creating a + profile returns its ID. + SulPayments then calls a new Vortex endpoint with that ID, the destination, its own + client reference and a contact email **[V6]**. Vortex checks the profile exists in + SulPayments' app, stores the destination, and deploys the forwarder once the profile + is approved. The destination is create-only, because it is fixed in the contract; a + change means a new account on SulPayments' written instruction. Vortex rejects zero, + token and contract addresses, and exchange addresses need SulPayments' confirmation + that they do not rotate. Until the endpoint is live, SulPayments sends the Monerium + profile ID and the destination to Vortex directly, and Vortex sets the account up + manually. +- **Why the destination does not go through Monerium.** Monerium's profile API has no + field for it, linking an address needs a signature from its owner, which exchange + deposit addresses cannot give, and only the forwarder contract uses the destination. +- **IBAN order.** Monerium issues an IBAN only for an address already linked to the + profile, so the forwarder is linked first. A profile has one IBAN, which can be moved + to another linked address. +- **Webhooks.** Vortex registers its own subscription on SulPayments' app; Monerium + lists an app's subscriptions together. Monerium's guide says that list includes each + subscription's secret, while the response schema has no secret field. Either way, + Vortex reads the IBAN and the payer's IBAN back from Monerium's API instead of relying + on webhook payloads alone **[V7]**. +- **Fee.** Monerium charges €10 per corporate account under its agreement **[M3, S10]**. + +### 4.3 What is built today + +- A Vortex operator deploys the forwarder, then one admin call maps the client with its + Monerium profile ID, SulPayments' client ID, the destination and the fee policy. +- The keeper then links the forwarder and requests the IBAN automatically. The IBAN is + recorded when Monerium confirms it. +- The operator then activates the account. +- SulPayments can read the account and its IBAN through the Vortex API with its manager + key. There is no dashboard view **[V3]**. +- The backend uses one Monerium credential pair, shared with Vortex's retail EUR onramp. +- Adopting the proposal changes four things. The B2B module gets its own credentials for + SulPayments' app **[V8]**. SulPayments supplies the destination by profile ID through + the new endpoint, planned for the week of 2026-10-05 **[V6]**. Onboarding starts once the profile is approved and the + destination is registered **[V1]**. The IBAN and the payer's IBAN are read back from + Monerium's API **[V7]**. +- The keeper already links the client's refund wallet next to the forwarder, built on + 2026-10-01. + +### 4.4 Decisions behind onboarding + +- **One forwarder per client.** Each client gets its own contract, created as a cheap + clone of one shared, verified implementation. A published manifest lets anyone check + every deployed forwarder against it. +- **Fixed destination.** The destination is set at deployment and has no setter, so no + key, including Vortex's, can redirect a client's USDC. +- **Contract-signed link.** A contract cannot sign like a wallet. The forwarder + therefore proves ownership to Monerium by accepting a signature from a Vortex attestor + key, but only over Monerium's exact link message. That key has no power over the + contract's funds. + +### 4.5 Sandbox phase before production credentials + +Monerium releases the production credentials for SulPayments' white-label app once it +sees test data in SulPayments' sandbox app: approved test client profiles onboarded +through SulPayments' KYB integration, IBANs issued, and test payments processed (agreed +on the call of 2026-09-30). Monerium's sandbox runs on Ethereum Sepolia. + +SulPayments' steps **[S11]**: + +1. Create a white-label app in its Monerium sandbox account at sandbox.monerium.dev, + share the app's client ID and secret with Vortex, and onboard one or two test client + profiles through its KYB integration. +2. Get a test API key from dashboard-sandbox.vortexfinance.co, for the API at + api-sandbox.vortexfinance.co, once Vortex has set SulPayments' profile up as the + manager of its clients. Register the webhook endpoint with that key through + `POST /v1/webhook`, subscribing to `DEPOSIT_UPDATED` and `ACCOUNT_UPDATED`; the + dashboard has no webhook screen. +3. For each test profile, send Vortex the Monerium profile ID and a Sepolia destination + wallet that SulPayments controls. + +Vortex then sets up the forwarder, links it and the client's refund wallet, and requests +the IBAN. In a joint session the parties run three test payments: a normal one, a large +one that converts in several chunks, and one that is refunded. + +## 5. Payment in: SEPA to EURe + +```mermaid +sequenceDiagram + participant B as Client's bank + participant M as Monerium + participant F as Forwarder + participant V as Vortex keeper + participant SP as SulPayments + + B->>M: SEPA transfer to the client's IBAN + M-->>V: order.created webhook, EUR arrived + Note over M: Compliance review possible, or rejection before minting + M->>F: Mint EURe to the forwarder + M-->>V: order.updated webhook, order processed + V->>F: Watcher confirms the mint on chain + V->>V: Deposit minted, 2-hour clock starts + V-->>SP: DEPOSIT_RECEIVED webhook +``` + +- Vortex listens on **two channels**. Monerium's webhooks carry the order details: + amount, order ID, and the payer's IBAN and name. Vortex's own chain + watcher proves the EURe actually arrived on the forwarder. A deposit becomes eligible + for conversion only when both agree. +- Monerium confirmed that `order.created` arrives when the payment hits the IBAN and + `order.updated` when the order changes state. While an order is **pending**, it is + either minting or under compliance review, and Monerium does not tell the two apart. + Reviews happen during business hours and add time before the two-hour window starts. +- The payer's **IBAN and name are stored** from the order. They are the target of any + refund. +- The **two-hour window starts at the mint**, not at the SEPA transfer, because Vortex + cannot act before the EURe exists. +- Monerium's monitoring may **hold** a payment for review during office hours. If it + needs documents, Monerium contacts the payer directly. If it cannot mint the payment, + it returns the funds to the payer, with a reason on the order. None of this reaches + the forwarder. +- **Third-party payers** are allowed. Monerium watches transaction patterns to make + sure accounts are not misused. +- **Memo routing.** A payer can write a chain and address into the SEPA memo. If that + address is linked to the client's profile, Monerium mints there instead of to the + forwarder. Monerium's guide describes only that case, which implies an unlinked + address is ignored and the payment mints to the forwarder as usual. The only other + address linked to a client profile is the client's refund wallet, which Vortex + controls. Clients are unlikely to use this, so it stays enabled, and a mint to a + refund wallet is handled by operations **[V9]**. + +## 6. Conversion: EURe to USDC + +### 6.1 Chunks + +A payment larger than the per-swap cap of **€10,000** is converted in several chunks, a +few minutes apart. The chunks' USDC waits on the forwarder until the whole payment is +converted. Two payments are never mixed in one swap. A single client's payments are +converted one after the other, while different clients' payments convert in parallel. + +### 6.2 Price + +- **Reference rate:** the midpoint between the best bid and the best ask on the Coinbase + Exchange EURC-USDC market, read immediately before each chunk and recorded with it. A + chunk waits while that market's spread is wider than 0.5%. +- **Client target:** the reference minus 0.125%. Whatever the market delivers above the + target is Vortex's fee, capped at 1% on chain. +- **Client floor:** the reference minus 0.15%. If the market delivers less, Vortex tops + the chunk up from the subsidy vault, within limits. +- **Safety bound:** no chunk ever delivers less than the Chainlink EUR/USD rate minus + 0.6%, or it does not execute. If the Coinbase reference sits below that bound, the + client gets the bound, and Vortex pays the difference within its subsidy limits. +- The reference must also be within 1% of Chainlink, so a bad price feed cannot push a + swap far off market. + +### 6.3 Subsidy ladder + +How much Vortex is willing to top up grows with how long a chunk has waited for the +market. Waiting time counts per chunk, from when it became ready to swap. + +| Waiting time | Maximum top-up | +|---|---| +| 0 to 6 min | none | +| 6 to 8 min | 0.10% | +| 8 to 10 min | 0.20% | +| 10 to 12 min | 0.30% | +| 12 to 14 min | 0.40% | +| 14 to 16 min | 0.50% | +| from 16 min | 1.00% | + +The ladder is a keeper setting that Vortex can retune without touching the contracts. +The contract enforces the cap the keeper passes with each swap, and the vault enforces a +per-swap cap and a daily budget on top. + +### 6.4 Per-chunk decision + +```mermaid +flowchart TD + A["Chunk ready to swap"] --> B{"Coinbase spread
at most 0.5%?"} + B -- no --> W["Wait for the next cycle"] + B -- yes --> C{"Reference within 1%
of Chainlink?"} + C -- no --> W + C -- yes --> D["Quote whitelisted routes,
pick the best"] + D --> E{"Fill at or above
the client floor?"} + E -- yes --> X["Swap. Surplus above target
goes to the fee treasury"] + E -- no --> F{"Shortfall within the ladder
and the vault budget?"} + F -- yes --> Y["Swap. Vault tops up
to the floor"] + F -- no --> W + W --> G{"2 hours since the mint?"} + G -- no --> A + G -- yes --> R["Refund path, section 8"] +``` + +## 7. Delivery: one USDC transfer per payment + +- Once every chunk of a payment is converted, the keeper sends the whole USDC amount to + the client's destination in **one transfer**. +- After the transfer is 32 blocks deep, SulPayments receives **DEPOSIT_CONVERTED**. It + carries each chunk's reference rate, fee and subsidy, and the transaction hash of the + final transfer. +- **Without Vortex:** if the keeper stops for 24 hours, anyone can convert at the + Chainlink price without subsidy and forward the forwarder's USDC to the destination. A + client's funds never depend on Vortex staying online. +- **Dormancy:** after 60 days without a conversion, forwarding pauses until the + destination is re-confirmed in writing. + +## 8. Unhappy path: full EUR refund + +### 8.1 When a payment is refunded + +- It was **not converted within two hours** of the mint. Typical causes are a market + move beyond the bounds, thin liquidity, an exhausted subsidy budget, or an operational + fault. +- An operator triggers it after a **compliance decision or an incident**. + +A payment is never partly delivered. If any part cannot be converted in time, the whole +payment is refunded, including chunks that were already converted. + +### 8.2 How a refund runs + +```mermaid +sequenceDiagram + participant V as Vortex keeper + participant F as Forwarder + participant R as Client's refund wallet + participant U as Uniswap + participant FL as Float wallet + participant M as Monerium + participant B as Payer's bank + participant SP as SulPayments + + Note over F: 2 hours passed, payment not fully converted + V->>F: recover the payment's EURe and USDC + F->>R: EURe and USDC, contract refuses before 2 hours + R->>U: Swap USDC back to EURe + FL->>R: Top up to the exact original amount + R->>M: Redeem order to the payer's IBAN + M->>B: SEPA payout from the client's IBAN + M-->>V: order.updated, redemption processed + V-->>SP: DEPOSIT_RETURNED and DEPOSIT_UPDATED +``` + +- The payer gets back the **exact EUR amount**. Losses from the round trip and fees + already taken on converted chunks are Vortex's cost, paid from the float wallet. +- The contract **enforces the two-hour window**. The forwarder cannot move funds to the + refund wallet any earlier, and it can only move them to that one wallet. +- The refund leaves from the **client's own IBAN**, in the client's name, with a + reference to the original payment. +- SEPA Instant is used when the payer's bank supports it, otherwise next business day. +- Monerium requires a **supporting document** on redemptions above €15,000 and accepts + the same agreement every time, so one standing document per client can be uploaded + once and reused. Until the refund automation attaches it, refunds of €15,000 or more + stay manual **[V10]**. +- Monerium sets **no limits and charges no fees** on refunds. A refund may be reviewed + by Monerium during business hours before it is sent **[M4]**. +- Refunds run one at a time and survive a crash of the keeper midway. A refund that + fails its retries goes to **recovery failed** and is handed to Vortex operations. +- Rollout: the automation first runs in **alert mode**, where Vortex operators confirm + each refund. It switches to **automatic** after the first refund has been observed end + to end. + +### 8.3 Refund from the client's own IBAN + +A Monerium profile can have several linked addresses, any linked address can use the +profile's IBAN for outgoing payments, and an address belongs to exactly one profile. The +refund path builds on that, built on 2026-10-01: + +- Each client gets its own **refund wallet**: a plain Vortex-held wallet whose key is + derived from one Vortex secret and the client's Monerium profile ID. One secret covers + every client, and the wallet is known before the forwarder is deployed. +- The wallet is fixed in the client's forwarder at deployment as the only address a + recovery can pay, with no way to change it. Vortex refuses to map a forwarder whose + refund wallet is not the client's derived one. +- Onboarding links the wallet to the client's Monerium profile next to the forwarder. +- On a refund the forwarder sends the stuck EURe and USDC straight to that wallet, which + swaps back, receives the float's top-up, and places the redeem order. Monerium pays it + out of the client's own IBAN. +- The wallet pays gas for its own transactions; the float tops its ETH up before it + sends. +- No Vortex company profile at Monerium is needed, and different clients' refunds never + share a wallet. +- The client authorizes Vortex to send these refunds in the SulPayments terms. + +Status: agreed with Monerium on 2026-09-30 for the pilot. Later, each client may instead +name a fixed refund IBAN at onboarding, so every refund follows the same path **[V13]**. +A refund contract that only pays that fixed IBAN would then replace the plain wallet, +which means a new forwarder for existing clients. + +Alternatives considered: + +- **One Vortex recovery wallet on a Vortex/SatoshiPay company profile.** The previous + design. The payer would see SatoshiPay as the sender, and one company profile would + pay many unrelated payers. +- **Redeem signed by the forwarder contract.** The refund would also leave from the + client's IBAN, but it needs an allowlist of payer IBANs per client and a larger + contract change. + +## 9. Status, reporting and support + +### 9.1 Deposit status + +```mermaid +stateDiagram-v2 + direction LR + [*] --> pending: Monerium order + pending --> returned: Monerium rejects it + pending --> minted: EURe on the forwarder + minted --> converting: first chunk swapped + converting --> forwarded: one USDC transfer + minted --> recovering: refund path + converting --> recovering + recovering --> refunded: EUR back to payer + recovering --> recovery_failed + recovery_failed --> recovering: operator retry + forwarded --> [*] + returned --> [*] + refunded --> [*] +``` + +Statuses only move forward. A late or repeated webhook can never move a deposit back. +Monerium does not report a separate compliance-review state, so a payment under review +shows as pending. The `held` status in the API is therefore never set and will be +removed **[V11]**. + +### 9.2 What SulPayments receives + +| Event | When | Key content | +|---|---|---| +| `DEPOSIT_UPDATED` | Any change of a deposit | The full deposit snapshot: IDs, amounts, timestamps, waiting and refund reasons, every conversion chunk | +| `ACCOUNT_UPDATED` | Any change of an account, including the IBAN being issued | The account snapshot: IBAN, status, IDs, destination, fee policy | +| `DEPOSIT_RECEIVED` | EURe minted to the client's forwarder | Milestone: deposit ID, account, amount, mint transaction | +| `DEPOSIT_CONVERTED` | The USDC transfer is 32 blocks deep | Milestone: per chunk reference rate, fee and subsidy, and the forward transaction | +| `DEPOSIT_RETURNED` | The refund was processed by Monerium | Milestone: refunded amount, masked payer IBAN, redemption ID, recovery transaction | + +| API call | Returns | +|---|---| +| Accounts, all clients of the manager | Each client's account with IBAN and status, filterable by Monerium profile ID | +| Account, per client | IBAN, status, IDs, destination, forwarder address, fee policy | +| Deposits, per client | Every deposit as its full snapshot, the same shape as `DEPOSIT_UPDATED` | + +- The accounts call uses SulPayments' manager API key alone. The per-client calls add a + header naming the client's Vortex profile ID, or use a key issued to the client. +- Webhooks are signed. SulPayments verifies each one against Vortex's published public + key and deduplicates on the event ID. +- **Fallback:** the deposits call returns the current snapshot of every deposit, for + polling if a webhook is missed. +- **Reference IDs** in every snapshot: deposit, account, Vortex profile, Monerium + profile, Monerium order, SulPayments' client reference, and every transaction hash. +- Amounts come as a EUR decimal and in base units: 18 decimals for EUR and EURe, 6 for + USDC. + +### 9.3 What SulPayments asked for + +SulPayments' requirements of 2026-09-30, built on 2026-10-01: + +- The API and webhooks expose the **full lifecycle**, from deposit through conversion to + delivery, including IDs, amounts, timestamps, and hold or failure status. +- **API-first:** SulPayments' frontend fetches each sub-account's IBAN from the Vortex + backend. + +How each stage is reported: + +| Stage | Status | Reported through | +|---|---|---| +| Payment arrived at Monerium, not minted yet | pending | `DEPOSIT_UPDATED` with waiting reason `monerium_pending`. Monerium does not tell minting apart from a compliance review | +| Returned by Monerium before minting | returned | `DEPOSIT_UPDATED` with Monerium's rejection reason | +| EURe minted to the forwarder | minted | `DEPOSIT_UPDATED` with the mint time and transaction, plus `DEPOSIT_RECEIVED` | +| Conversion chunk sent and confirmed | converting | `DEPOSIT_UPDATED` with each chunk's pricing, transaction, and sent and confirmed times | +| Conversion waiting on the market | minted or converting | `DEPOSIT_UPDATED` with waiting reason and start time: `below_floor`, `reference_unavailable`, `reference_out_of_band`, `oracle_unavailable` or `no_route` | +| Delivered as one USDC transfer | forwarded | `DEPOSIT_UPDATED` with delivery time and transaction once 32 blocks deep, plus `DEPOSIT_CONVERTED` | +| Refund started | recovering | `DEPOSIT_UPDATED` with refund reason and start time: `window_missed`, `compliance`, `incident` or `operator` | +| Refunded | refunded | `DEPOSIT_UPDATED` with refund time, plus `DEPOSIT_RETURNED` | +| Refund needs an operator | recovery failed | `DEPOSIT_UPDATED` | +| Account set up, IBAN issued, status changed | | `ACCOUNT_UPDATED`, and the accounts call | + +Not included yet: the incoming payment's SEPA reference and the payer's name. Add them +when SulPayments needs them. + +### 9.4 Exceptions and escalation + +- Vortex monitors stuck payments, refunds due, failed refunds, the subsidy budget, the + float balance, IBAN changes at Monerium, and the Coinbase market status. +- Operators can pause conversion, force a refund, or correct a deposit's status through + admin endpoints. The runbook covers each case. +- A joint Slack channel with Monerium is the agreed channel for payment questions. + Named owners and the escalation path between Vortex, SulPayments and Monerium are + still to be agreed **[V5]**. + +## 10. What Vortex can and cannot do + +**Vortex can:** + +- Deploy forwarders, run conversions, and pause them. +- Choose the swap route from an on-chain whitelist. +- Change a client's fee policy within the 1% cap. Raising it takes effect only after a + 24-hour on-chain notice. Lowering it is immediate. +- Fund or limit its own subsidy budget. +- Move a payment to the client's refund wallet, only after the two-hour window, to + refund it from the client's own IBAN. + +**Vortex cannot:** + +- Redirect funds. A forwarder pays only the client's fixed destination, the fee + treasury, and, after two hours, the client's own refund wallet. +- Deliver a conversion below the Chainlink rate minus 0.6%. +- Stop a client's conversion permanently. After 24 hours anyone can complete it. +- Prevent incoming SEPA payments. Payments made during a pause wait safely as EURe and + are converted or refunded later. + +## 11. Key parameters + +| Parameter | Value | Changeable | +|---|---|---| +| Refund window | 2 hours from the mint | Fixed in the contract | +| Permissionless fallback | 24 hours | Fixed in the contract | +| Client target and floor | Reference minus 0.125% and minus 0.15% | Per client, increases need 24 h notice | +| Fee cap | 1% | Fixed in the contract | +| Safety bound against Chainlink | 0.6% | Fixed in the contract | +| Reference band against Chainlink | 1% | Fixed in the contract | +| Maximum Coinbase spread | 0.5% | Keeper code | +| Subsidy ladder | none for 6 min, rising to 1% from 16 min | Keeper setting | +| Chunk size | up to €10,000 | Operational, ceiling €50,000 | +| Minimum swap | €1 | Operational, can be raised but never below €1 | +| Manual refunds | €15,000 and above | Until the refund automation attaches the standing agreement [V10] | +| Monerium account fee | €10 per corporate account | Monerium agreement | +| Dormancy pause | 60 days without a conversion | Keeper code | +| Pilot volume | 3 to 5 clients, €50,000 per client per day | Contractual | + +## 12. Open questions + +### 12.1 Assumptions (2026-09-30) + +- Refunds run through per-client refund wallets. A fixed refund IBAN per client may + replace the dynamic payer IBAN later **[V13]**. +- SulPayments delivers KYB directly to its own white-label app and calls Monerium's KYB + endpoints itself; SatoshiPay does not proxy them. Vortex never handles KYB data. +- No SulPayments client has an existing Monerium profile. +- Monerium does not need to know or screen the client's final wallet. +- Memo routing stays enabled, because clients are unlikely to use it. +- Testing runs in SulPayments' Monerium sandbox app on Sepolia before Monerium releases + the production credentials (section 4.5). + +### 12.2 Answered by Monerium (2026-09-30) + +| Topic | Answer | +|---|---| +| Whose white-label app | A dedicated app in SulPayments' Monerium account, under SulPayments' reliance agreement. Vortex uses that app's client ID and secret. | +| Onboarding steps | Confirmed: create profile, submit details, form and verifications, wait for `profile.updated` approved, link the forwarder and the refund wallet, request the IBAN. | +| KYB route and speed | Corporates use the reliance endpoints. Approval takes seconds when the data follows Monerium's guidelines. | +| Profile visibility | Only the credentials of the app that onboarded a profile can read it. | +| Readable profile data | Only the bare minimum, such as the name. Full KYB details are not returned. | +| Payment notifications | `order.created` when the payment hits the IBAN, `order.updated` on state changes. Pending covers both minting and compliance review. | +| IBAN | Only for an address already linked to the profile. It can be moved to another linked address. | +| Batching | None. Each client is its own request. | +| Refund wallet | One address per profile, so one refund wallet per client. A redeem from it leaves from the profile's IBAN. | +| Supporting document above €15,000 | The same agreement can be reused every time. | +| Refund limits and fees | None. Some refunds are reviewed during business hours. | +| Account fee | €10 per corporate account, per the agreement. | +| Partner apps | Each partner gets its own white-label app. SulPayments delivers KYB data and files directly; Vortex could proxy those calls later as tech provider. Decided 2026-10-01: no proxy, SulPayments calls the KYB endpoints itself. | +| Production credentials | Released after Monerium sees test data in SulPayments' sandbox app: approved test profiles onboarded through SulPayments' KYB integration, IBANs issued, test payments processed (section 4.5). | +| Held and rejected payments | Monerium's monitoring holds payments for review during office hours, contacts the payer directly if it needs documents, and returns the funds if it cannot mint them. | +| Third-party payers | Allowed. Monerium watches transaction patterns so accounts are not misused. | +| Refund wallet approach | Agreed for the pilot. A fixed refund IBAN per client may replace it later. | +| Communication | A joint Slack channel with Monerium is to be set up. | +| SulPayments' onboarding at Monerium | Documents in review on 2026-09-30, onboarding starting 2026-10-01. | + +### 12.3 Monerium + +| ID | Question | Why it matters | Status | Answer | +|---|---|---|---|---| +| M2 | Can SulPayments' app have separate credentials for Vortex and SulPayments, or can linking addresses, requesting or moving IBANs, and managing webhooks be restricted to Vortex? If not, is there an audit log or notification for those actions? | Whoever holds the app credentials can redirect future mints | Partly answered 2026-09-30 | One app per partner. Separate credentials within one app not confirmed. | +| M3 | Is the €10 per corporate account billed to SulPayments or to Vortex, and is it one-off or recurring? | Commercial planning | Renegotiation ongoing | A discount for onboarding all SulPayments clients is being negotiated. | +| M4 | What triggers a review on a refund, and can refunds to the original payer be cleared in advance? | Refund timing promise | Open | | +| M7 | Written confirmation of the items agreed verbally so far: the redemption-limitation disclosure, the issuer recovery backstop, SEPA recall and fraud loss allocation, per-IBAN suspension, and advance notice of changes to the link message. | Launch gate | Open | | + +### 12.4 SulPayments + +| ID | Question or item to agree | Why it matters | Status | Answer | +|---|---|---|---|---| +| S1 | How many clients, and when? | Planning, and when the destination endpoint is needed | Open | | +| S2 | Share the white-label app's production credentials with Vortex, and agree the usage rules in section 4.2: no address links, IBAN requests or moves, orders, or changes to Vortex's webhook subscription, and profile closures coordinated with Vortex. | Protects where client payments are minted | Production credentials follow the sandbox sign-off, section 4.5 | | +| S3 | Hand over each destination through the new Vortex endpoint by Monerium profile ID. Who at SulPayments approves a destination? | The destination is fixed in the contract | Proposal to confirm | | +| S4 | How does SulPayments get each client's IBAN? | API and dashboard scope | Answered 2026-09-30 | API-first: SulPayments' frontend fetches the IBAN from the Vortex API, section 9.3. | +| S5 | Do clients always pay from their own business bank accounts, or also from third parties? | Refund target. Monerium allows third-party payers | Open | | +| S6 | Are destinations self-custody wallets or exchange deposit addresses? | Exchange addresses need an attestation that they do not rotate and accept contract transfers | Open | | +| S7 | Does the event model in section 9.3 cover the lifecycle requirement? Webhook endpoint and support contacts. | Status reporting | Built 2026-10-01, to confirm with SulPayments | SulPayments wants the full lifecycle with IDs, amounts, timestamps, and hold and failure status. | +| S8 | Is a two-hour window before a full refund right? Will clients authorize Vortex to refund from their IBAN? A Monerium review can delay a refund within business hours. | Refund terms in the agreement | Open | | +| S9 | The agreement names a "Coinbase EURC oracle". The implementation uses the Coinbase Exchange EURC-USDC bid/ask midpoint. Is that what was meant? | Pricing terms | Open | | +| S10 | Who bears Monerium's €10 per corporate account? | Commercial | Open | | +| S11 | Sandbox phase, section 4.5: create a white-label app in the Monerium sandbox and share its client ID and secret, onboard one or two test profiles through the KYB integration, get a test API key from dashboard-sandbox.vortexfinance.co and register the webhook endpoint, and send the Monerium profile ID and a Sepolia destination wallet per test profile. | Monerium releases production credentials after seeing this test data | Requested 2026-10-01 | | + +### 12.5 Vortex internal + +| ID | Question or task | Depends on | Status | +|---|---|---|---| +| V1 | Start onboarding once the profile is approved and the destination is registered, whichever comes last. | V6, V8 | Open | +| V2 | Refunds through per-client refund wallets: derived from one seed, fixed in each forwarder as its recovery address, linked at onboarding. | None | Built 2026-10-01 | +| V3 | Dashboard view for SulPayments with clients, IBANs, deposits and refunds. Optional, since SulPayments integrates API-first. | S4 | Deprioritized | +| V4 | Full lifecycle deposit events, section 9.3: snapshot event on every change, IDs, amounts, timestamps, hold and failure reasons, an account event, and the docs fix. | S7 | Built 2026-10-01 | +| V5 | Named owners per alert, and the escalation path between Vortex, SulPayments and Monerium, including the joint Slack channel with Monerium. | Meeting | Open | +| V6 | Endpoint for SulPayments to register a destination by Monerium profile ID: checks the profile exists, create-only, validated, no KYB data. | S3 | Planned to go live the week of 2026-10-05 | +| V7 | Read the IBAN and the payer's IBAN back from Monerium's API instead of trusting webhook payloads, and check in the sandbox whether listing subscriptions exposes their secrets. | None | Open | +| V8 | Separate Monerium credentials for the B2B module, apart from the retail onramp, with one app and credential pair per partner. | S2 | Open | +| V9 | Detect a mint to a refund wallet routed by payment memo, and handle it as a refund. | V2 | Open | +| V10 | Attach the standing agreement to refunds above €15,000 so they can run automatically. | V2 | Open | +| V11 | Remove the unused `held` status. | None | Open | +| V13 | Later: a fixed refund IBAN per client, given at onboarding, with a refund contract that only pays that IBAN. | V2 | Later | +| V12 | Partner account API: list all sub-accounts with IBAN and status, filterable by Monerium profile ID. | S4 | Built 2026-10-01 | + +## 13. Related documents + +For Vortex readers who need the detail behind this overview: + +- [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md): the + complete technical architecture. +- [`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md): decisions, final + parameters and accepted risks. +- [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md): launch + gates, deploy checklist and agreement inputs. +- [`operations-monerium-b2b-runbook.md`](operations-monerium-b2b-runbook.md): operator + procedures for onboarding, refunds and incidents. +- [`security-spec/05-integrations/monerium-b2b.md`](security-spec/05-integrations/monerium-b2b.md): + security invariants. +- [`api/pages/07-webhooks.md`](api/pages/07-webhooks.md): the partner webhook format and + signature check. 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 b733926a0..c8d8eb907 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. 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. +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`/`DEPOSIT_RETURNED`) 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 index 395284b0f..41be13729 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -7,7 +7,7 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each **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` +**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/refund-wallet.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 @@ -19,38 +19,43 @@ The B2B zero-touch onramp (docs/architecture-monerium-b2b-onramp.md) gives each 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). +7. **Deposit status transitions are forward-only** — provider states first: `pending → {minted, held, returned}`, `held → {minted, returned}`; then the keeper's settlement branch `minted → converting → forwarded` or the refund branch `{minted, converting} → recovering → {refunded, recovery_failed}` (`recovery_failed → recovering` is the operator's retry). `forwarded`, `returned` and `refunded` are terminal. Out-of-order or replayed webhook events can never regress a deposit status: regressive transitions are logged and ignored, and a provider `processed` replay for a deposit already past the mint is a silent no-op. Guarded by `isForwardTransition` (unit-tested). Only the keeper (a confirmed `forward`) and the admin endpoints move a deposit along the settlement and refund branches. 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). +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/targetPpm/floorPpm before persistence (the fee policy is validated as `0 <= targetPpm <= floorPpm <= 10000`, mirroring the contract; the clone has no fallback role and no destination setter). The managed child, customer entity, approved KYB mirror, and account then commit in one database transaction, so a late uniqueness conflict leaves no orphan identity records. Identical replay returns existing records; any divergence is 409, never an overwrite. A Monerium profile, forwarder, and managed profile can each back at most one account (migrations 069/071). 13. **Onboarding provider writes are exactly-once and provider reads are scoped** — the automated link (`POST /addresses`) and IBAN request (`POST /ibans`) run through the profile-scoped `financial_operations` ledger (flow `monerium-b2b-onboarding`), so crashes and retries never repeat a claimed provider call; interrupted calls reconcile by re-reading the linked addresses / issued IBANs. Every linked-address and IBAN selection requires the exact mapped profile, configured Monerium chain, and forwarder address; multiple exact IBAN matches are rejected rather than selected arbitrarily. Automation only touches accounts in `onboarding` status that have a managed-profile owner, and 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`). +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. The one manager-level read, `GET /v1/monerium-b2b/accounts`, takes the manager's own credential only (the `X-Managed-Profile-Id` header is rejected with 400 and a child credential with 403), requires an active manager allowed the EU corridor and business customers, and lists only accounts whose managed profile is an active child of that manager; its optional `moneriumProfileId` filter narrows within that set and never widens it (`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_RECEIVED` fires for any state past the mint (the keeper may already have started converting within the cycle). `DEPOSIT_CONVERTED` fires once only after the deposit is `forwarded` and its confirmed `forward` execution is `NOTIFY_CONFIRMATION_DEPTH` blocks deep; its `conversions[]` lists the deposit's confirmed chunk swaps (EURe in, net USDC, reference rate, fee and subsidy per chunk in the `execution` block), `forwardTxHash` is the single transfer to the destination and payload `usdcNetRaw` is the amount that transfer carried. `DEPOSIT_RETURNED` fires once when a deposit is `refunded`, carrying the refunded EUR amount, the payer's IBAN masked to its first and last four characters (the full IBAN never leaves the backend), Monerium's redeem order id and the `recover` transaction. Per-deposit markers prevent replay to late subscribers. `DEPOSIT_UPDATED` and `ACCOUNT_UPDATED` send the full deposit or account snapshot (the read-surface shape) whenever its SHA-256 differs from the last one sent, with an `eventId` that embeds the hash so outbox dedup absorbs a crash between enqueue and marker; a `forwarded` deposit is reported only under the same confirmation-depth gate as `DEPOSIT_CONVERTED`, and snapshots carry the payer IBAN only masked. 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. +1. **Three-way key separation, bounded fund movement** — the keeper key (`MONERIUM_B2B_KEEPER_PRIVATE_KEY`, submits `poke()`, `swap()`, `forward()` and `recover()`), 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 redirect funds: `swap` only executes a factory-whitelisted, oracle-floored swap whose USDC stays on the clone, priced against a keeper-supplied reference the contract bounds to `MAX_REFERENCE_DEVIATION_BPS` around Chainlink (invariant 7); `forward` only ever pays the clone's own `destination`; `recover` only ever pays the clone's `recoveryAddress`, the client's refund wallet fixed at deployment with no setter, and only once the clone's batch marker has been open for `RECOVERY_DELAY` (the contract, not the keeper, enforces the promised window); `setGuardianPaused` is protective-only by contract invariant (it blocks swaps and forwards, never a recovery); the attestor signs the fixed link statement. Vortex therefore holds a client's funds only on the refund path, only in that client's own refund wallet, and only for a payment the promised window was missed on (adr-0005, amendments 2026-09-17 and 2026-10-01). All three keys 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. +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 the row's kind rebuilt from what was persisted before broadcast (`expectedCalldata`: `swap(referenceRate, routeIndex, amountIn)`, `forward(amount)` or `recover(eureAmount, usdcAmount)`), and receipt emits that kind's event (`SwapExecuted`, `Forwarded`, `Recovered`) from the forwarder. A confirmed `forward` or `recover` also requires the event's amounts to equal the planned ones, else the row fails. Incomplete/ambiguous scans remain pending; only a complete scan with no exact match proves failure. This fail-closed posture can require manual reconciliation, but cannot double-convert. Keeper nonce derivation/broadcasts serialize across processes via a send advisory lock. +4. **Advisory-lock serialization** — all keeper database mutations (mint recording, action planning, execution slot check/creation with the deposit's status transition, finalization, the operator's recovery marking) run inside the shared per-forwarder `pg_advisory_xact_lock` (`withForwarderLock`), the same lock the webhook deposit processor uses. Double-send is prevented by the "any pending execution → skip" check under that lock; the chain send/wait itself intentionally runs outside a database transaction so the pending record cannot be rolled back by a crash. +5. **One deposit at a time, chunked, forwarded whole (1 deposit : N executions)** — the keeper serves the oldest chain-indexed, provider-attributed deposit that is still settling: it swaps one chunk of it per cycle with an explicit `amountIn` (`planChunk`: at most `perSwapCap`, never leaving a sub-minimum dust remainder when the last two chunks can share it; a remainder below `minSwapAmount` waits for the refund path), every `swap` execution row carries the deposit it serves, and once the chunks' EURe sum to the deposit's amount one `forward` execution pushes the sum of their nets to the destination. Deposits never share a swap, so no pro-rata attribution exists; `unattr:` rows are never converted and never surface as customer claims. Only chain-indexed deposits make an account a conversion candidate (the mint watcher scans 12-deep blocks; identity `(chain_id, tx_hash, log_index)`), and a raw forwarder balance never bypasses the watcher. Execution net is `usdcOut - fee + subsidy` from `SwapExecuted` (the subsidy lands on the clone and is forwarded with the payment); unsolicited USDC on the clone is never credited to a deposit and leaves only through the keeper's `forwardAll`. A hash match with a conflicting amount is refused: the chain log is recorded separately as unattributed, the provider row gets no chain identity, and an error alert fires. +6. **Dormancy pause is protective-only (R05)** — after 60 days (registry P5) without a confirmed conversion, the gate calls per-clone `setGuardianPaused(true)` with the guardian key (log-only when the key is unset) and records `dormant_since`; account status stays `active`. The pause can never move funds and never blocks a recovery (contract invariant); un-pause is a manual guardian operation pending partner re-confirmation mechanics (registry B5). The batch marker still arms for dormant and suspended accounts (`poke()` is pause-immune) and the keeper still recovers their marked deposits: the refund path exists precisely for payments nobody is converting any more. +8. **The refund runs one payment at a time on a dedicated wallet, and only Vortex money moves off chain** — `recovery.ts` (`MONERIUM_B2B_AUTO_RECOVERY=auto`; `alert` reports, `off` is manual) opens a `monerium_recoveries` row only for a confirmed `recover` execution of a `recovering` deposit and drives at most one such row at a time; the keeper refuses a second `recover` while one is in flight (`activeRecoveryExists`). The refund runs on the client's refund wallet, an EOA whose key is derived from `MONERIUM_B2B_REFUND_SEED` and the client's Monerium profile ID (`refund-wallet.ts`); it is linked to the client's profile at onboarding, so the redeem pays out of the client's own IBAN, and no wallet ever holds two clients' funds. Every step re-derives its remaining work from that wallet's live balances, so a transaction whose hash was lost is never repeated: a landed reverse swap shows as USDC gone, a landed top-up as the need reaching zero. The reverse swap runs on the reversed whitelisted route with a minimum output of the Chainlink value less `SLIPPAGE_BPS`; the float pays only the difference to the exact issue amount and receives any surplus. The redeem order is placed from the refund wallet with the issue order's amount, the payer's IBAN and name captured from the issue order's counterpart, and a memo `vortex-refund:` that is checked at Monerium before every placement (exactly-once). Amounts of EUR 15,000 and above, a missing payer, a rejected order, or a step failing five times park the deposit as `recovery_failed` with the phase preserved for the operator's retry. The refund seed and the float key are env-only and never logged; the seed is required whenever the module is enabled (onboarding links the derived wallet), the float key in `auto` mode, and the derived wallet must equal the clone's `recoveryAddress` before any send. Account mapping refuses a clone whose `recoveryAddress` is not the client's derived wallet, and the admin refund-address endpoint returns only the address. Before the refund wallet sends its own transactions, the float tops its ETH up to twice their cost at the current gas price (balance-derived, so a repeat sends nothing). `payer_iban`/`payer_name` are financial-record PII kept with the deposit row (the raw webhook is pruned after 30 days); they are never returned by the read API. +7. **Swaps are reference-priced, subsidy-bounded, and deferred rather than forced** (adr-0005 amendment 2026-09-15; behaviour in architecture-monerium-b2b-onramp.md) — before every swap the keeper reads the Coinbase Exchange EURC-USDC top of book and takes the bid/ask midpoint (`reference-rate.ts`; a spread above 50 bps or an inverted book defers, so a thin market never sets the reference), records price, source and time on the execution row before broadcast, and passes the rate into `swap`; the contract rejects a reference outside `MAX_REFERENCE_DEVIATION_BPS` of Chainlink, and a permissionless caller's value is ignored in favour of Chainlink with no subsidy. The keeper also passes `maxSubsidy`, its subsidy tier for the time the chunk has waited (`MONERIUM_B2B_SUBSIDY_LADDER`, `maxSubsidyBpsFor`, per-chunk clock from the mint or the previous chunk's confirmation), persisted on the row before broadcast and part of the calldata-exact recovery identity; the contract refuses a top-up above it, so the tier binds at execution whatever the fill did after the quote. The ladder is a Vortex spending policy (the client's floor never moves), bounded on chain by the vault's cap and budget. On chain the Chainlink floor `_floorOut` (`SLIPPAGE_BPS`) bounds both bands from below (amendment 2026-09-18): the fee is the surplus above `max(reference x (1 - targetPpm), oracleFloor)` capped at `MAX_FEE_PPM`, the subsidy is the shortfall below `max(reference x (1 - floorPpm), oracleFloor)`, and `SLIPPAGE_BPS` is still enforced on the client's net after both, so a reference under a stale Chainlink round costs Vortex fee and subsidy (within the tier and the vault) rather than the client, while a depeg beyond what the tier and the vault cover reverts. The subsidy comes from one shared `VortexSubsidyVault` that pays only when called by a factory-registered clone, only within a per-swap cap (ppm of the reference value) and a UTC-daily budget, can be paused, and withdraws only to the treasury; a vault that cannot cover reverts the whole swap, and the clone counts a subsidy only after verifying that exactly the shortfall landed on the clone itself (it is forwarded with the payment), so a misconfigured or hostile guardian-set vault cannot make a below-floor fill pass (`test_swap_subsidyNotDelivered_revertsTheWholeSwap`). The keeper mirrors this settlement off-chain (`projectSwap`, unit-tested against the Foundry numbers) and defers — no execution row, funds wait, marker armed — when the reference is unavailable or out of band, no route quotes, the projected subsidy exceeds the cap, the remaining budget or the balance, or the projected net would breach the floor. Routes are keeper-picked by quote among factory-validated entries (EURe, EURC and USDC only, the four Uniswap tiers, at most two hops, the immutable router); a poor pick costs Vortex subsidy or fee, never the client. Accepted consequence: the subsidy widens the sandwich-exploitable band from `SLIPPAGE_BPS` to floor plus cap, paid by the vault — private orderflow (invariant 2) and a modest cap are the mitigation, and the permissionless path keeps the plain floor. ## 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). +2. **Executable-depth check (PRD §7.4)** — QuoterV2 static quotes on every enabled factory route at `minSwapAmount` and `perSwapCap` sizes, compared against Chainlink EUR/USD (`computeQuoteImpactBps`, unit-tested against the T6 baseline); the best route decides. Settlement enforces `SLIPPAGE_BPS` on the client's net, so a raw impact above it means every keeper swap of that size draws a vault subsidy (deferring past the per-swap cap) and the permissionless path would revert; at `minSwapAmount` size that logs the error-level DEPTH BELOW FLOOR line, at `perSwapCap` size a warning (`classifyExecutableDepth`). The keeper's own `projectSwap` deferral remains the authoritative gate. Gated to chainId 1 — the QuoterV2 address is a mainnet pin. +3. **Stranded-balance monitor** — forwarders holding ≥ `MIN_SWAP_FLOOR` EURe or any USDC whose on-chain batch marker has been open longer than `RECOVERY_DELAY` (2 h, registry P3) warn — the promised window was missed and the deposit should be forwarded or recovering; past `TRIGGER_DELAY` they error (the permissionless swap/forward path is then live — a keeper-outage signal, not a fund-risk signal). 4. **Association monitor (S1 detective control)** — per active account, re-reads linked addresses and IBANs scoped to the exact mapped profile and configured chain, then error-alerts on ANY divergence from the DB record (forwarder unlinked, extra address linked, IBAN moved or unrecorded — `diffAssociation`, unit-tested). 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. +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, timelocked fee-policy changes are authorized transitions reconciled with a version bump; a destination change (the clone has no setter), proxy bytecode or registration drift is an incident. The standalone manifest verifier remains consistency evidence, not the trust root. +6. **Subsidy-vault monitor** — reads the factory's vault balance, daily budget, spend and pause state (`classifyVaultRunway`, unit-tested): paused or empty is an error (every below-floor swap defers), less than one day of budget or an exhausted day is a warning; a missing vault warns once per pass. +8. **Refund monitor** — with automated refunds configured, the oldest active recovery warns after an hour and errors after four or on a failed step (`classifyRefundQueue`, unit-tested); the float's EURe balance errors when empty and warns below 1,000 EURe. +7. **Reference-venue monitor** — probes the status of the Coinbase Exchange product the reference midpoint reads (`fetchCoinbaseProductStatus`, `classifyReferenceVenue`, unit-tested); anything but an online product with trading enabled is an error, because a delisted product keeps answering the candles endpoint with stale data and every keeper swap would defer silently (EURC-USD did exactly that after its 2024-08-29 delisting). ## Threat Vectors & Mitigations @@ -59,8 +64,16 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim | **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 | +| **Executor outruns mint indexing** | A live balance is swapped before its mint identity is settled, leaving a swap with no deposit to belong to | Conversion candidates require chain-indexed deposits; every swap is bound to one deposit before it is sent, with an explicit `amountIn` | +| **Unsolicited USDC inflates deposit reporting** | USDC that arrived outside a swap is credited to a deposit | Execution net is `SwapExecuted.usdcOut - fee + subsidy`; a deposit's forward moves exactly the sum of its chunks' nets; unsolicited USDC leaves only through the keeper's `forwardAll` and is never reported as a conversion | +| **Premature or misdirected recovery** | A compromised keeper moves a fresh payment off the clone, or to a wallet of its choosing | `recover` pays only the clone's fixed `recoveryAddress` (the client's derived refund wallet, verified at mapping), only after the clone's batch has been open for `RECOVERY_DELAY`, with explicit amounts bounded by the balances; a deposit enters the refund path only through the admin endpoint (operator) or, once automated, the deadline; the invariant suite proves no early recovery and no other exit | +| **Refund wallet or seed compromise** | A client's refund wallet is drained before the refund, or the seed leaks and exposes every client's wallet | Funds sit on a refund wallet only during that client's recovery; each wallet holds one client's in-flight refund and nothing else; one refund at a time; the seed is env-only and never logged; rotating it means new clones for every client (runbook §5 and §6) | +| **Reverse-swap sandwich or thin reverse route** | A searcher moves the pool while the refund's USDC is swapped back, or the pool is too thin | Minimum output at the Chainlink value less `SLIPPAGE_BPS`; private orderflow; a rejected or reverted swap retries with backoff and parks the refund for the operator after five attempts; the float, not the payer, absorbs slippage | +| **Double refund** | A crash between the redeem POST and its persistence places the order twice, or a lost top-up hash sends the float twice | Memo-keyed lookup at Monerium before every placement; balance-derived need (a landed top-up makes the need zero); one active recovery on an otherwise empty wallet | +| **Refund to the wrong account** | A tampered or missing counterpart sends the EUR elsewhere | The target is the issue order's own counterpart, recorded once and never overwritten by a later delivery; a deposit without it never refunds automatically (`recovery_failed`, runbook) | +| **Manipulated reference rate** | A compromised keeper (or Coinbase response) supplies a reference that inflates the fee or triggers a subsidy | Contract band check against Chainlink; fee capped at `MAX_FEE_PPM`; subsidy bounded by the vault's per-swap cap and daily budget; the floor on the client's net; a permissionless caller's reference argument is ignored in favour of Chainlink | +| **Subsidy-widened sandwich** | A searcher moves the pool so the fill lands just above floor minus cap and the vault pays the difference | Private orderflow for keeper swaps; modest per-swap cap and daily budget; the vault, not the client, absorbs the loss; no subsidy on the permissionless path | +| **Subsidy vault drain** | A contract or key tries to pull vault USDC | `pay` accepts only factory-registered clones and pays only the calling clone, within cap and budget; the clone verifies the exact delivery; withdrawals go to the immutable treasury only; guardian setters bound Vortex's own money, never client funds | | **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 | @@ -79,7 +92,7 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] `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 +- [ ] Forward-only transition guard covers all nine statuses incl. the settlement and refund branches; regressive events are dropped, not applied; a provider `processed` replay past the mint is a no-op - [ ] 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`) @@ -88,12 +101,19 @@ The monitoring pass (`monerium-b2b/monitoring.ts`, run from the worker, rate-lim - [ ] 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) +- [ ] Foundry suite covers the three fee bands, the fee cap, the reference band, the floor on the net after subsidy, vault cap/budget/pause/treasury-only withdrawal, route validation and the permissionless no-subsidy path; `projectSwap` mirrors the same numbers (`conversion-executor.test.ts`) +- [ ] Recovery calldata identity is rebuilt per kind — `swap(reference, route, amountIn, maxSubsidy)`, `forward(amount)`, `recover(eure, usdc)` — from what was persisted before broadcast (`expectedCalldata`); a row without it stays pending; forward/recover confirmations require matching event amounts +- [ ] The subsidy ladder parses and validates at startup (`parseSubsidyLadder`: starts at 0, ascends in time and bps), the tier lookup and per-chunk clock are unit-tested, `projectSwap` defers above the tier before consulting the vault, and the Foundry invariant proves the vault never pays above the caller's `maxSubsidy` +- [ ] The reference midpoint rejects a malformed ticker, an inverted or empty book and a spread above 50 bps (`reference-rate.test.ts`) - [ ] 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`) +- [ ] Execution rows compare-and-set a pre-send reservation; send any poke before persisting nonce + broadcast block immediately before the value-moving send; no elapsed-time failure exists after nonce persistence; exact recovery identity, bounded paging, chunk planning and action planning (recover-first once eligible, forward when converted, one chunk otherwise) are covered by `conversion-executor.test.ts` +- [ ] Every execution carries `kind` and `deposit_id` (migration 080); the migration refuses an execution that spanned several deposits under the former allocation join instead of guessing +- [ ] Execution `usdcNetRaw` is `SwapExecuted.usdcOut - fee + subsidy` for a chunk and the planned amount for a forward or recovery (`conversion-executor.test.ts`) +- [ ] Foundry invariants prove EURe leaves the clone only to the router or its `recoveryAddress`, USDC only to `destination`, `FEE_RECIPIENT` or its `recoveryAddress`, no recovery before `RECOVERY_DELAY`, and no chunk swap re-times an open batch - [ ] `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 +- [ ] `monitoring.ts` performs no chain writes and holds no keys; its only DB mutation is the R07 fee-policy reconciliation; quote-impact, batch stranding, association-diff, drift classification and venue status covered by `monitoring.test.ts` / `reference-rate.test.ts` +- [ ] Association-monitor alerts (S1 detective control) are error-level and reference the incident runbook; guardian fee-policy changes (R07/P11) are warn-level reconciliations, a destination change is an incident +- [ ] Admin deposit endpoints (`POST .../deposits/:id/recover`, `PATCH .../deposits/:id/status`) only ever apply forward-only transitions under the forwarder lock and refuse a deposit with a pending execution (`moneriumB2b.controller.test.ts`) +- [ ] `recovery.test.ts` proves: the phase walk, the balance-derived crash recovery (no second swap or top-up), the memo adoption instead of a second redeem order, the float-underfunded wait, the supporting-document and missing-payer parking, the five-attempt failure, one recovery at a time (`activeRecoveryExists`) and the operator retry resuming from the preserved phase +- [ ] `MONERIUM_B2B_REFUND_SEED` and `MONERIUM_B2B_FLOAT_PRIVATE_KEY` are env-only and validated as 32-byte values; the seed is required whenever the module is enabled, the float key in `auto` mode; mapping rejects a clone whose `recoveryAddress` is not the derived refund wallet, and the derived wallet equals the clone's `recoveryAddress` before any refund send diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 26f45752a..b8deb54cf 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -4,7 +4,12 @@ export enum WebhookEventType { TRANSACTION_CREATED = "TRANSACTION_CREATED", STATUS_CHANGE = "STATUS_CHANGE", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", - DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED" + DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", + DEPOSIT_RETURNED = "DEPOSIT_RETURNED", + /** Every change of a deposit: the full snapshot, as returned by the deposits endpoint. */ + DEPOSIT_UPDATED = "DEPOSIT_UPDATED", + /** Every change of an onramp account (IBAN issued, status): the full account snapshot. */ + ACCOUNT_UPDATED = "ACCOUNT_UPDATED" } /** @@ -13,13 +18,33 @@ export enum WebhookEventType { * 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 const ACCOUNT_WEBHOOK_EVENT_TYPES = [ + WebhookEventType.DEPOSIT_RECEIVED, + WebhookEventType.DEPOSIT_CONVERTED, + WebhookEventType.DEPOSIT_RETURNED, + WebhookEventType.DEPOSIT_UPDATED, + WebhookEventType.ACCOUNT_UPDATED +] as const; export enum DepositStatus { + /** Provider order placed, EURe not minted yet. */ PENDING = "pending", + /** EURe minted to the forwarder. */ MINTED = "minted", + /** Provider compliance hold before the mint. */ HELD = "held", - RETURNED = "returned" + /** The provider returned the payment before the mint. Terminal. */ + RETURNED = "returned", + /** Conversion started; chunks accumulate on the forwarder until the whole deposit is converted. */ + CONVERTING = "converting", + /** The whole converted deposit reached the destination in one transfer. Terminal. */ + FORWARDED = "forwarded", + /** The deposit could not be converted inside the promised window; Vortex is refunding the payer. */ + RECOVERING = "recovering", + /** The exact EUR amount was refunded to the payer's bank account. Terminal. */ + REFUNDED = "refunded", + /** The refund needs operator intervention. */ + RECOVERY_FAILED = "recovery_failed" } export enum TransactionStatus { @@ -100,6 +125,21 @@ export interface DepositReceivedWebhookPayload { payload: DepositWebhookPayloadBase; } +/** + * How one conversion chunk was priced (docs/architecture-monerium-b2b-onramp.md, fees + * section): the reference it was settled against, the fee Vortex took above the target, + * and the subsidy the vault paid to reach the floor. Every chunk belongs to exactly one + * deposit. + */ +export interface ConversionExecutionPricing { + /** Fee taken on the execution (6-decimal base units). */ + feeRaw: string | null; + /** Reference EUR/USD rate the execution was priced against: the Coinbase Exchange EURC-USDC bid/ask midpoint read just before the swap, in the oracle's decimals (8). */ + referenceRateRaw: string | null; + /** Subsidy the vault paid onto the forwarder for this chunk (6-decimal base units). */ + subsidyRaw: string | null; +} + export interface DepositConvertedWebhookPayload { /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ eventId: string; @@ -110,22 +150,157 @@ export interface DepositConvertedWebhookPayload { conversions: Array<{ /** EURe from this deposit consumed by this execution (18-decimal base units). */ eureInRaw: string; + /** How this chunk was priced. */ + execution: ConversionExecutionPricing; executionId: string; - /** The swap-and-forward transaction. */ + /** The chunk's swap transaction. */ txHash: string | null; - /** Net USDC from this execution attributed to this deposit (6-decimal base units). */ + /** Net USDC from this chunk after fee and subsidy (6-decimal base units). */ usdcNetRaw: string; }>; - /** Aggregate net USDC attributed to the complete deposit (6-decimal base units). */ + /** The single transaction that pushed the whole converted deposit to the destination. */ + forwardTxHash: string | null; + /** Aggregate net USDC forwarded for the complete deposit (6-decimal base units). */ usdcNetRaw: string; }; } +/** A deposit that could not be converted inside the promised window was refunded to the payer's bank account. */ +export interface DepositReturnedWebhookPayload { + /** Unique per event and stable across delivery retries — consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.DEPOSIT_RETURNED; + timestamp: string; + payload: DepositWebhookPayloadBase & { + refund: { + /** The EUR amount refunded, to the cent ("1234.56"): always the full issue amount. */ + amount: string; + /** The payer's IBAN the refund went to, masked to its first and last four characters. */ + payerIbanMasked: string; + /** Monerium's redeem order id for the refund, when known. */ + redeemOrderId: string | null; + /** The on-chain transaction that moved the deposit off the forwarding contract for the refund. */ + recoverTxHash: string | null; + }; + }; +} + +/** + * Why a deposit is waiting: `monerium_pending` until Monerium mints it (minting or a + * compliance review, which Monerium does not tell apart), otherwise the reason the + * keeper is holding the next conversion chunk. + */ +export type DepositWaitingReason = + | "monerium_pending" + | "oracle_unavailable" + | "reference_unavailable" + | "reference_out_of_band" + | "no_route" + | "below_floor"; + +/** Why a deposit entered the refund path. */ +export type DepositRefundReason = "window_missed" | "compliance" | "incident" | "operator"; + +/** One conversion chunk of a deposit. Timestamps are ISO 8601. */ +export interface DepositConversionSnapshot { + executionId: string; + status: "pending" | "confirmed"; + /** EURe converted by this chunk (18-decimal base units). */ + eureInRaw: string; + execution: ConversionExecutionPricing; + /** Net USDC from this chunk after fee and subsidy (6-decimal base units). */ + usdcNetRaw: string; + txHash: string | null; + sentAt: string; + confirmedAt: string | null; +} + +/** The full state of one deposit, as sent by DEPOSIT_UPDATED and returned by the deposits endpoint. */ +export interface DepositSnapshot { + depositId: string; + accountId: string; + /** The client's Vortex managed profile, the `X-Managed-Profile-Id` value. */ + profileId: string; + moneriumProfileId: string; + moneriumOrderId: string; + /** The partner's own client reference for this managed profile. */ + externalSubjectId: string | null; + status: DepositStatus; + currency: string; + /** EUR amount to the cent, for example "1234.56". */ + amount: string; + /** EUR amount in 18-decimal base units. */ + amountRaw: string; + /** The on-chain mint transaction, when observed. */ + txHash: string | null; + /** When Vortex first saw the payment. */ + receivedAt: string; + mintedAt: string | null; + /** Present while the deposit waits, before the mint or between conversion chunks. */ + waiting: { reason: DepositWaitingReason; since: string } | null; + /** Monerium's reason when it returned the payment before minting. */ + rejectedReason: string | null; + conversions: DepositConversionSnapshot[]; + /** Net USDC of the confirmed chunks (6-decimal base units). */ + usdcNetRaw: string; + /** The single transfer of the whole converted deposit to the destination. */ + forwardTxHash: string | null; + deliveredAt: string | null; + /** Present once the deposit entered the refund path. */ + refund: { + reason: DepositRefundReason | null; + /** The EUR amount refunded, to the cent: always the full issue amount. */ + amount: string | null; + payerIbanMasked: string | null; + recoverTxHash: string | null; + redeemOrderId: string | null; + startedAt: string | null; + refundedAt: string | null; + } | null; +} + +/** The state of one onramp account, as sent by ACCOUNT_UPDATED and returned by the account endpoints. */ +export interface AccountSnapshot { + accountId: string; + /** The client's Vortex managed profile, the `X-Managed-Profile-Id` value. */ + profileId: string | null; + moneriumProfileId: string; + externalSubjectId: string | null; + status: string; + /** Null until Monerium issued it. */ + iban: string | null; + destination: string; + forwarderAddress: string; + targetPpm: number; + floorPpm: number; + dormantSince: string | null; + createdAt: string; +} + +export interface DepositUpdatedWebhookPayload { + /** Unique per snapshot and stable across delivery retries: consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.DEPOSIT_UPDATED; + timestamp: string; + payload: DepositSnapshot; +} + +export interface AccountUpdatedWebhookPayload { + /** Unique per snapshot and stable across delivery retries: consumers deduplicate on it. */ + eventId: string; + eventType: WebhookEventType.ACCOUNT_UPDATED; + timestamp: string; + payload: AccountSnapshot; +} + export type WebhookPayload = | TransactionCreatedWebhookPayload | StatusChangeWebhookPayload | DepositReceivedWebhookPayload - | DepositConvertedWebhookPayload; + | DepositConvertedWebhookPayload + | DepositReturnedWebhookPayload + | DepositUpdatedWebhookPayload + | AccountUpdatedWebhookPayload; export interface WebhookDeliveryAttempt { webhookId: string;