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..0726f88cb 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.test.ts @@ -5,6 +5,10 @@ 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"; @@ -17,7 +21,6 @@ const ADMIN_HEADERS = { Authorization: "Bearer test-admin-secret", "Content-Type const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; -const FALLBACK = "0x3333333333333333333333333333333333333333"; const FACTORY = "0x4444444444444444444444444444444444444444"; describe("monerium b2b account mapping admin route", () => { @@ -68,7 +71,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 +124,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 +151,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 +191,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); @@ -206,17 +206,25 @@ describe("monerium b2b account mapping admin route", () => { const expected = { destination: DESTINATION.toLowerCase(), factory: FACTORY.toLowerCase(), - fallbackAddress: FALLBACK.toLowerCase(), - feeBps: 0 + floorPpm: 1500, + targetPpm: 1250 + }; + const matching = { + destination: DESTINATION, + factory: FACTORY, + floorPpm: 1500, + isForwarder: true, + 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, targetPpm: 1_000 })).toContain("targetPpm"); + expect(forwarderConfigMismatch(expected, { ...matching, floorPpm: 2_000 })).toContain("floorPpm"); }); it("rejects invalid input and unknown managers", async () => { @@ -225,10 +233,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 +300,64 @@ 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 marked = await recover(deposit.id); + expect(marked.status).toBe(200); + expect(await marked.json()).toMatchObject({ deposit: { depositId: deposit.id, status: "recovering" } }); + expect((await MoneriumFiatDeposit.findByPk(deposit.id))?.status).toBe(MoneriumFiatDepositStatus.Recovering); + + // 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 patchStatus(deposit.id, "recovering")).status).toBe(409); + expect((await MoneriumFiatDeposit.findByPk(deposit.id))?.status).toBe(MoneriumFiatDepositStatus.Refunded); + + 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 29804085a..e8c844318 100644 --- a/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts +++ b/apps/api/src/api/controllers/admin/moneriumB2b.controller.ts @@ -2,8 +2,11 @@ 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 { 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 { isForwardTransition, withForwarderLock } from "../../services/monerium-b2b/deposit-processor"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -13,11 +16,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 +32,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") ) { res.status(httpStatus.BAD_REQUEST).json({ error: { code: "MONERIUM_B2B_INVALID_INPUT", message: - "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; feeBps must be a number when present", + "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, and destination are required; targetPpm and floorPpm must be numbers when present", status: httpStatus.BAD_REQUEST } }); @@ -47,11 +50,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) { @@ -150,3 +153,112 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st }); } } + +/** + * 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)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { code: "MONERIUM_B2B_INVALID_INPUT", message: "depositId must be a UUID", status: httpStatus.BAD_REQUEST } + }); + return; + } + const refusal = await markDepositForRecovery(req.params.depositId); + if (refusal === "deposit not found") { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "MONERIUM_B2B_DEPOSIT_NOT_FOUND", message: "Monerium deposit not found", status: httpStatus.NOT_FOUND } + }); + return; + } + if (refusal) { + res.status(httpStatus.CONFLICT).json({ + error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", message: refusal, status: httpStatus.CONFLICT } + }); + 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); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to mark the deposit for recovery", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +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)) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "MONERIUM_B2B_INVALID_INPUT", + message: `depositId must be a UUID and status must be one of ${OPERATOR_DEPOSIT_STATUSES.join(", ")}`, + status: httpStatus.BAD_REQUEST + } + }); + return; + } + const deposit = await MoneriumFiatDeposit.findByPk(req.params.depositId); + if (!deposit) { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "MONERIUM_B2B_DEPOSIT_NOT_FOUND", message: "Monerium deposit not found", status: httpStatus.NOT_FOUND } + }); + return; + } + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) { + res.status(httpStatus.NOT_FOUND).json({ + error: { code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND", message: "Monerium account not found", status: httpStatus.NOT_FOUND } + }); + return; + } + const targetStatus = status as MoneriumFiatDepositStatus; + const outcome = await withForwarderLock(account.forwarderAddress, async transaction => { + const current = await MoneriumFiatDeposit.findByPk(deposit.id, { transaction }); + if (!current) return "missing"; + if (targetStatus === current.status) return "same"; + if (!isForwardTransition(current.status, targetStatus)) + return `Monerium deposit cannot transition from ${current.status} to ${targetStatus}`; + await current.update({ status: targetStatus }, { transaction }); + return "updated"; + }); + if (outcome !== "updated" && outcome !== "same") { + res.status(httpStatus.CONFLICT).json({ + error: { code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", message: outcome, status: httpStatus.CONFLICT } + }); + return; + } + res.status(httpStatus.OK).json({ deposit: { depositId: deposit.id, status: targetStatus } }); + } catch (error) { + logger.error("Error updating Monerium B2B deposit status:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update Monerium B2B deposit status", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index 7edfe4737..dc4c008c6 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -4,12 +4,16 @@ import { Op } from "sequelize"; import logger from "../../config/logger"; import { config } from "../../config/vars"; import MoneriumAccount from "../../models/moneriumAccount.model"; -import MoneriumConversionExecution from "../../models/moneriumConversionExecution.model"; -import MoneriumDepositAllocation from "../../models/moneriumDepositAllocation.model"; +import MoneriumConversionExecution, { + MoneriumConversionExecutionKind, + MoneriumConversionExecutionStatus +} from "../../models/moneriumConversionExecution.model"; import MoneriumFiatDeposit from "../../models/moneriumFiatDeposit.model"; +import MoneriumRecovery from "../../models/moneriumRecovery.model"; import { APIError } from "../errors/api-error"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; +import { executionPricing } from "../services/monerium-b2b/manager-events"; import { UNATTRIBUTED_ORDER_PREFIX } from "../services/monerium-b2b/mint-watcher"; import { MONERIUM_ID_HEADER, @@ -97,11 +101,11 @@ export const getMoneriumB2bAccount = async (req: Request, res: Response, next: N createdAt: account.createdAt, destination: account.destination, dormantSince: account.dormantSince, - fallbackAddress: account.fallbackAddress, - feeBps: account.feeBps, + floorPpm: account.floorPpm, forwarderAddress: account.forwarderAddress, iban: account.iban, - status: account.status + status: account.status, + targetPpm: account.targetPpm } }); } catch (error) { @@ -113,8 +117,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 { @@ -138,43 +142,65 @@ 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({ + const executions = rows.length + ? await MoneriumConversionExecution.findAll({ order: [["created_at", "ASC"]], - where: { depositId: rows.map(row => row.id) } + where: { depositId: rows.map(row => row.id), status: { [Op.ne]: MoneriumConversionExecutionStatus.Failed } } }) : []; - 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); + const recoveries = rows.length ? await MoneriumRecovery.findAll({ where: { depositId: rows.map(row => row.id) } }) : []; + const recoveryByDeposit = new Map(recoveries.map(recovery => [recovery.depositId, recovery])); + const executionsByDeposit = new Map(); + for (const execution of executions) { + const grouped = executionsByDeposit.get(execution.depositId as string) ?? []; + grouped.push(execution); + executionsByDeposit.set(execution.depositId as string, grouped); } res.status(httpStatus.OK).json({ deposits: rows.map(row => { - const depositAllocations = allocationsByDeposit.get(row.id) ?? []; + const depositExecutions = executionsByDeposit.get(row.id) ?? []; + const swaps = depositExecutions.filter(execution => execution.kind === MoneriumConversionExecutionKind.Swap); + const forward = depositExecutions.find( + execution => + execution.kind === MoneriumConversionExecutionKind.Forward && + execution.status === MoneriumConversionExecutionStatus.Confirmed + ); + const recover = depositExecutions.find( + execution => + execution.kind === MoneriumConversionExecutionKind.Recover && + execution.status === MoneriumConversionExecutionStatus.Confirmed + ); + const recovery = recoveryByDeposit.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 - }; - }), + conversions: swaps.map(execution => ({ + eureInRaw: execution.eureInRaw, + execution: executionPricing(execution), + executionId: execution.id, + status: execution.status, + txHash: execution.txHash, + usdcNetRaw: execution.usdcNetRaw ?? "0" + })), createdAt: row.createdAt, currency: row.currency, depositId: row.id, + forwardTxHash: forward?.txHash ?? null, + // Present once the deposit entered the refund path: what was (or is being) refunded. + refund: + recovery || recover + ? { + amount: recovery?.refundAmount ?? null, + recoverTxHash: recover?.txHash ?? null, + redeemOrderId: recovery?.redeemOrderId ?? null + } + : null, status: row.status, txHash: row.txHash, - usdcNetRaw: depositAllocations.reduce((sum, allocation) => sum + BigInt(allocation.usdcNetRaw), 0n).toString() + usdcNetRaw: swaps + .filter(execution => execution.status === MoneriumConversionExecutionStatus.Confirmed) + .reduce((sum, execution) => sum + BigInt(execution.usdcNetRaw ?? "0"), 0n) + .toString() }; }), pagination: { limit, offset, total: count } 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..16f383c2a 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,10 @@ import { Router } from "express"; -import { patchMoneriumB2bAccountStatus, postMoneriumB2bAccount } from "../../../controllers/admin/moneriumB2b.controller"; +import { + patchMoneriumB2bAccountStatus, + patchMoneriumB2bDepositStatus, + postMoneriumB2bAccount, + postMoneriumB2bDepositRecovery +} from "../../../controllers/admin/moneriumB2b.controller"; import { adminAuth } from "../../../middlewares/adminAuth"; const router: Router = Router({ mergeParams: true }); @@ -13,4 +18,10 @@ router.post("/accounts", postMoneriumB2bAccount); // Operator lifecycle transitions (activate after the penny test, 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 recovery 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/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts index 93d3d8a36..b815f1625 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -25,11 +25,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 +50,42 @@ 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 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; + 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 +96,11 @@ 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.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 +116,8 @@ export function forwarderConfigMismatch( async function verifyForwarderOnChain( forwarderAddress: string, destination: string, - fallbackAddress: string, - feeBps: number + targetPpm: number, + floorPpm: number ): Promise { if (!config.moneriumB2b.rpcUrl) { return; @@ -106,12 +131,12 @@ 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, 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: "targetPpm" }), + client.readContract({ abi: forwarderConfigAbi, address, functionName: "floorPpm" }), client.readContract({ abi: forwarderConfigAbi, address, functionName: "FACTORY" }) ]); const isForwarder = await client.readContract({ @@ -123,9 +148,9 @@ async function verifyForwarderOnChain( onchain = { destination: onchainDestination, factory, - fallbackAddress: onchainFallback, - feeBps: onchainFeeBps, - isForwarder + floorPpm: onchainFloorPpm, + isForwarder, + targetPpm: onchainTargetPpm }; } catch (error) { throw new MoneriumB2bProvisioningError( @@ -135,7 +160,7 @@ async function verifyForwarderOnChain( }` ); } - const mismatch = forwarderConfigMismatch({ destination, factory: trustedFactory, fallbackAddress, feeBps }, onchain); + const mismatch = forwarderConfigMismatch({ destination, factory: trustedFactory, floorPpm, targetPpm }, onchain); if (mismatch) { throw new MoneriumB2bProvisioningError( "MONERIUM_B2B_ACCOUNT_CONFLICT", @@ -223,14 +248,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 +276,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, targetPpm, floorPpm); let result: { account: { created: boolean; row: MoneriumAccount }; managedProfile: ProvisionManagedProfileResult }; try { @@ -282,7 +310,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 +343,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..d695df0fb 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). +// Standalone event items for getLogs-based crash recovery, one per keeper transaction +// kind (must stay in sync with the entries in forwarderAbi below). 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,12 +69,76 @@ 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: "referenceRate", type: "uint256" }, + { name: "routeIndex", type: "uint256" }, + { name: "amountIn", type: "uint256" }, + { name: "maxSubsidy", type: "uint256" } + ], + name: "swap", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, + { + inputs: [{ name: "amount", type: "uint256" }], + name: "forward", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, + { inputs: [], name: "forwardAll", outputs: [], stateMutability: "nonpayable", type: "function" }, + { + inputs: [ + { name: "eureAmount", type: "uint256" }, + { name: "usdcAmount", type: "uint256" } + ], + name: "recover", + outputs: [], + stateMutability: "nonpayable", + type: "function" + }, { inputs: [{ name: "paused", type: "bool" }], name: "setGuardianPaused", @@ -78,13 +146,30 @@ export const forwarderAbi = [ stateMutability: "nonpayable", type: "function" }, - { inputs: [], name: "strandedSince", outputs: [{ name: "", type: "uint64" }], stateMutability: "view", 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: "RECOVERY_WALLET", 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" }, { anonymous: false, - inputs: [{ indexed: false, name: "strandedSince", type: "uint64" }], + inputs: [{ indexed: false, name: "batchOpenedAt", type: "uint64" }], name: "Poked", type: "event" }, @@ -92,14 +177,35 @@ export const forwarderAbi = [ anonymous: false, inputs: [ { indexed: true, name: "caller", type: "address" }, + { indexed: false, name: "routeIndex", type: "uint256" }, { indexed: false, name: "eureIn", type: "uint256" }, { indexed: false, name: "usdcOut", type: "uint256" }, + { indexed: false, name: "referenceRate", type: "uint256" }, { indexed: false, name: "fee", type: "uint256" }, - { indexed: false, name: "forwarded", type: "uint256" } + { indexed: false, name: "subsidy", type: "uint256" } ], name: "SwapExecuted", type: "event" }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "caller", type: "address" }, + { indexed: false, name: "amount", type: "uint256" } + ], + name: "Forwarded", + type: "event" + }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "caller", type: "address" }, + { indexed: false, name: "eureAmount", type: "uint256" }, + { indexed: false, name: "usdcAmount", type: "uint256" } + ], + name: "Recovered", + type: "event" + }, { anonymous: false, inputs: [{ indexed: false, name: "paused", type: "bool" }], @@ -111,9 +217,41 @@ export const forwarderAbi = [ 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; @@ -121,6 +259,8 @@ export type KeeperWalletClient = WalletClient; let publicClientCache: PublicClient | null = null; let keeperClientCache: KeeperWalletClient | null = null; let guardianClientCache: KeeperWalletClient | null = null; +let recoveryClientCache: KeeperWalletClient | null = null; +let floatClientCache: KeeperWalletClient | null = null; let privateRpcWarned = false; export function isKeeperChainConfigured(): boolean { @@ -197,6 +337,38 @@ export function getGuardianWalletClient(): KeeperWalletClient | null { return guardianClientCache; } +/** + * Recovery-wallet client (MONERIUM_B2B_RECOVERY_PRIVATE_KEY): the immutable + * RECOVERY_WALLET's key, which signs the refund's reverse swap and the Monerium redeem + * message. Null when unset — the refund path then runs manually per the runbook. + */ +export function getRecoveryWalletClient(): KeeperWalletClient | null { + if (!config.moneriumB2b.recoveryPrivateKey) { + return null; + } + if (!recoveryClientCache) { + recoveryClientCache = createWalletClient({ + account: privateKeyToAccount(config.moneriumB2b.recoveryPrivateKey as Hex), + transport: http(submissionRpcUrl()) + }); + } + return recoveryClientCache; +} + +/** 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 { + if (!config.moneriumB2b.floatPrivateKey) { + return null; + } + if (!floatClientCache) { + floatClientCache = createWalletClient({ + account: privateKeyToAccount(config.moneriumB2b.floatPrivateKey as Hex), + transport: http(submissionRpcUrl()) + }); + } + return floatClientCache; +} + // ------------------------------------------------------------------ cached chain lookups let chainIdCache: number | null = null; @@ -208,13 +380,23 @@ 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; + recoveryWallet: 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, 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 +406,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" + | "RECOVERY_WALLET" + | "ROUTER" + >( + functionName: T + ) => client.readContract({ abi: forwarderAbi, address: forwarderAddress, functionName }); + const [ + eure, + factory, + usdc, + oracle, + oracleDecimals, + slippageBps, + maxFeePpm, + maxReferenceDeviationBps, + recoveryDelay, + recoveryWallet, + 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("RECOVERY_WALLET"), + read("ROUTER") ]); - const immutables = { eure, factory }; + const immutables: ForwarderImmutables = { + eure, + factory, + maxFeePpm: Number(maxFeePpm), + maxReferenceDeviationBps: Number(maxReferenceDeviationBps), + oracle, + oracleDecimals: Number(oracleDecimals), + recoveryDelaySeconds: Number(recoveryDelay), + recoveryWallet, + 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..37e270e74 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; +} - 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); +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({ convertedEureRaw: 90n * EUR, remainingEureRaw: 10n * EUR, usdcNetRaw: 97n * USDC }); }); - 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("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("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("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("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("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("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("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("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("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("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 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,284 @@ 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, + recoveryWallet: "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({ 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({ 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({ 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({ 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, projection: null, 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({ kind: "defer", reason: "no enabled swap route could be quoted" }); + }); + + it("picks the route with the highest quote and projects its settlement", async () => { + arrange(); + expect(await price()).toEqual({ + kind: "ready", + maxSubsidyRaw: 5_700_000n, // 50 bps of the 1140 USDC reference value + projection: { defer: null, fee: 425_000n, net: 1_138_575_000n, subsidy: 0n }, + 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({ 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..a2b9cc008 100644 --- a/apps/api/src/api/services/monerium-b2b/conversion-executor.ts +++ b/apps/api/src/api/services/monerium-b2b/conversion-executor.ts @@ -4,30 +4,44 @@ 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,7 +56,7 @@ 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. */ +/** How long one cycle waits for the receipt before deferring to the next cycle. */ const RECEIPT_TIMEOUT_MS = 3 * 60_000; /** @@ -55,6 +69,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. */ +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 +95,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 +132,335 @@ 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 { + /** Confirmed chunk swaps of the deposit, oldest first. */ + swaps: MoneriumConversionExecution[]; + /** When the newest confirmed chunk settled: the next chunk's clock starts here. */ + lastSwapAt: Date | null; + convertedEureRaw: bigint; + 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 { convertedEureRaw, lastSwapAt, remainingEureRaw: remainingEureRaw < 0n ? 0n : remainingEureRaw, swaps, 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 + +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 +477,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 +539,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 +553,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 +574,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 +591,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 +599,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 +607,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 +673,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 +722,200 @@ async function prepareExecutionSlot(account: MoneriumAccount, transaction: Trans return { attempt: failedSince.length + 1, kind: "proceed" }; } +// ------------------------------------------------------------------ pricing + +export type PlannedSwap = + | { kind: "defer"; reason: string } + | { + kind: "ready"; + /** The tier cap in USDC (6 decimals): the `maxSubsidy` argument of the swap. */ + maxSubsidyRaw: bigint; + projection: SwapProjection | null; + reference: ReferenceQuote; + routeIndex: number; + }; + +function deferSwap(reason: string): PlannedSwap { + return { 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(`Chainlink EUR/USD answered ${oracleRaw}`); + } + + let reference: ReferenceQuote; + try { + reference = await fetchCoinbaseReference(immutables.oracleDecimals); + } catch (error) { + return deferSwap(`reference rate unavailable: ${errorText(error)}`); + } + if (!isWithinReferenceBand(reference.rateRaw, oracleRaw, immutables.maxReferenceDeviationBps)) { + return deferSwap( + `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("the factory has no enabled swap route"); + } + if ((await getChainId()) !== 1) { + return { kind: "ready", maxSubsidyRaw, projection: null, reference, routeIndex: routes[0].index }; + } + const quotes = await quoteRoutes(routes, amountIn); + if (quotes.length === 0) { + return deferSwap("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( + `${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, projection, 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 +923,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 +937,95 @@ 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}` + ); + if (pokeNeeded) { + await sendPoke(forwarder); + } + return; + } + } + 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 +1033,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 +1074,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 +1101,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 +1111,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 +1140,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 +1241,38 @@ 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): 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({ 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..2bddfb883 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,26 @@ describe("parseOrderEvent", () => { orderId: ORDER_ID, profileId: PROFILE_ID, state: "processed", + payerIban: null, + payerName: 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 +213,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 +295,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 +304,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 +313,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 +352,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 +361,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 +372,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 +407,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 +416,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 +426,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,12 +454,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-first-seen-returned", payload: orderEvent("rejected", { meta: { placedAt: "2026-08-26T00:00:00Z", txHashes: ["0xmint"] } }) @@ -452,7 +463,7 @@ describe("order-event inbox processing (end to end)", () => { 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, @@ -460,7 +471,7 @@ describe("order-event inbox processing (end to end)", () => { 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..4f996251b 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 { @@ -47,7 +49,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 +156,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 +251,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 +276,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(); 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..6758b9dbc 100644 --- a/apps/api/src/api/services/monerium-b2b/manager-events.ts +++ b/apps/api/src/api/services/monerium-b2b/manager-events.ts @@ -1,15 +1,21 @@ -import { DepositStatus, type DepositWebhookPayloadBase, WebhookEventType, type WebhookPayload } from "@vortexfi/shared"; +import { + type ConversionExecutionPricing, + DepositStatus, + type DepositWebhookPayloadBase, + WebhookEventType, + type WebhookPayload +} from "@vortexfi/shared"; import { Op } from "sequelize"; -import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import ManagedProfile from "../../../models/managedProfile.model"; import MoneriumAccount from "../../../models/moneriumAccount.model"; import MoneriumConversionExecution, { + 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 +35,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, @@ -75,7 +96,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 +133,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 +152,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 +174,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 +190,71 @@ 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 ?? refundAmountFromRaw(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); + } + } +} + +/** The issue amount to the cent, for a refund closed by hand before a recovery row recorded it. */ +function refundAmountFromRaw(amountRaw: string): string { + const cents = BigInt(amountRaw) / 10n ** 16n; + return `${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`; +} + /** * 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. + * 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(); } 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..3743c34b5 100644 --- a/apps/api/src/api/services/monerium-b2b/onboarding.test.ts +++ b/apps/api/src/api/services/monerium-b2b/onboarding.test.ts @@ -12,7 +12,6 @@ import { advanceOnboardingAccounts, type OnboardingDeps } from "./onboarding"; const FORWARDER = "0x1111111111111111111111111111111111111111"; const DESTINATION = "0x2222222222222222222222222222222222222222"; -const FALLBACK = "0x3333333333333333333333333333333333333333"; const MONERIUM_PROFILE = "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"; const IBAN = "EE08 7224 5745 6244 9516"; const ETHEREUM_CHAIN = { getChainId: async () => 1 }; @@ -76,8 +75,6 @@ async function createMappedAccount(overrides: Partial { 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/recovery.test.ts b/apps/api/src/api/services/monerium-b2b/recovery.test.ts new file mode 100644 index 000000000..afa7f60d6 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/recovery.test.ts @@ -0,0 +1,461 @@ +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("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..1ef4f8a80 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/recovery.ts @@ -0,0 +1,620 @@ +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, + getRecoveryWalletClient, + KeeperWalletClient, + moneriumChainForChainId, + readEnabledRoutes, + swapRouter02Abi +} from "./chain"; +import { markDepositForRecovery } from "./conversion-executor"; +import { isForwardTransition, withForwarderLock } from "./deposit-processor"; +import { UNATTRIBUTED_ORDER_PREFIX } from "./mint-watcher"; + +/** + * 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: reverse-swap the USDC on the dedicated recovery wallet, 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, and mark the + * deposit `refunded` when Monerium processed it. + * + * Crash safety rests on the recovery 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; +const MAX_ATTEMPTS = 5; +const RECEIPT_TIMEOUT_MS = 3 * 60_000; +const EURE_DECIMALS = 18; +const USDC_DECIMALS = 6; +const BPS = 10_000n; + +// ------------------------------------------------------------------ pure helpers + +/** Reverses a packed Uniswap V3 path (token, fee, token[, fee, token]) so the same pools run the other way. */ +export function reversePath(path: Hex): Hex { + const bytes = path.slice(2); + if (bytes.length !== 86 && bytes.length !== 132) { + throw new Error(`unexpected packed path length ${bytes.length / 2}`); + } + const tokens: string[] = []; + const fees: string[] = []; + let offset = 0; + while (offset < bytes.length) { + tokens.push(bytes.slice(offset, offset + 40)); + offset += 40; + if (offset < bytes.length) { + fees.push(bytes.slice(offset, offset + 6)); + offset += 6; + } + } + tokens.reverse(); + fees.reverse(); + let out = "0x"; + tokens.forEach((token, index) => { + out += token; + if (index < fees.length) out += fees[index]; + }); + return out as Hex; +} + +/** + * 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 recovery 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). */ + 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 two wallet keys. */ +export async function liveRecoveryDeps(forwarder: Address): Promise { + const client = getPublicClient(); + const immutables = await getForwarderImmutables(forwarder); + const recovery = requireClient(getRecoveryWalletClient(), "MONERIUM_B2B_RECOVERY_PRIVATE_KEY"); + const float = requireClient(getFloatWalletClient(), "MONERIUM_B2B_FLOAT_PRIVATE_KEY"); + if (recovery.account.address.toLowerCase() !== immutables.recoveryWallet.toLowerCase()) { + throw new Error("MONERIUM_B2B_RECOVERY_PRIVATE_KEY does not control the implementation's RECOVERY_WALLET"); + } + const balance = (token: Address, address: Address) => + client.readContract({ abi: erc20Abi, address: token, args: [address], functionName: "balanceOf" }); + const wallets = { float, recovery }; + 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]; + 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) { + 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); + 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 the recovery 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; +} + +export async function setDepositStatus(deposit: MoneriumFiatDeposit, status: MoneriumFiatDepositStatus): Promise { + const account = await MoneriumAccount.findByPk(deposit.accountId); + if (!account) return; + await withForwarderLock(account.forwarderAddress, async transaction => { + const current = await MoneriumFiatDeposit.findByPk(deposit.id, { transaction }); + if (current && isForwardTransition(current.status, status)) { + await current.update({ status }, { transaction }); + } + }); +} + +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; + } + await recovery.update({ + floatTopupRaw: topUp.toString(), + floatTopupTxHash: hash, + phase: MoneriumRecoveryPhase.ToppingUp + }); + 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({ 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; + } +} + +function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 500); +} + +/** + * 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 wallet, one refund at a time). + */ +export async function runRecoveryOrchestrator( + depsFor: (forwarder: Address) => 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.forwarderAddress as Address); + 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..1d4320290 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "bun:test"; +import { + classifyReferenceVenue, + computeMid, + fetchCoinbaseProductStatus, + fetchCoinbaseReference, + isWithinReferenceBand, + parseTicker, + spreadBps +} 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. + +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("top of book", () => { + it("parses a ticker and rejects anything that is not two positive decimals", () => { + expect(parseTicker({ ask: "1.1475", bid: "1.1471", price: "1.1472", volume: "12.5" })).toEqual({ ask: "1.1475", bid: "1.1471" }); + expect(() => parseTicker({ ask: "1.1475" })).toThrow("malformed"); + expect(() => parseTicker({ ask: "abc", bid: "1.1471" })).toThrow("malformed"); + expect(() => parseTicker(null)).toThrow("malformed"); + }); + + it("computes the midpoint at the oracle's decimals and the spread in bps", () => { + expect(computeMid({ ask: "1.1475", bid: "1.1471" }, 8)).toBe(114_730_000n); + expect(spreadBps({ ask: "1.1475", bid: "1.1471" }, 8)).toBe(3); + expect(spreadBps({ ask: "1.1530", bid: "1.1470" }, 8)).toBe(52); + expect(() => spreadBps({ ask: "1.1470", bid: "1.1475" }, 8)).toThrow("inverted"); + expect(() => spreadBps({ ask: "1.0", bid: "0" }, 8)).toThrow("inverted or empty"); + }); +}); + +describe("fetchCoinbaseReference", () => { + const ok = (body: unknown) => async () => ({ json: async () => body, ok: true, status: 200 }); + + it("reads the ticker midpoint as the reference", async () => { + const quote = await fetchCoinbaseReference(8, ok({ ask: "1.1475", bid: "1.1471" }), 1_800_000_000_000); + expect(quote).toEqual({ + price: "1.1473", + rateRaw: 114_730_000n, + source: "coinbase-exchange:EURC-USDC:mid", + time: new Date(1_800_000_000_000) + }); + }); + + it("defers on a thin book, an inverted book, a bad status or a malformed body", async () => { + await expect(fetchCoinbaseReference(8, ok({ ask: "1.1530", bid: "1.1470" }))).rejects.toThrow("spread of 52 bps exceeds 50 bps"); + await expect(fetchCoinbaseReference(8, ok({ ask: "1.1470", bid: "1.1475" }))).rejects.toThrow("inverted"); + await expect(fetchCoinbaseReference(8, async () => ({ json: async () => null, ok: false, status: 503 }))).rejects.toThrow("503"); + await expect(fetchCoinbaseReference(8, ok({ price: "1.1472" }))).rejects.toThrow("malformed"); + }); + + it("requests the EURC-USDC ticker", async () => { + let requested = ""; + await fetchCoinbaseReference(8, async url => { + requested = url; + return { json: async () => ({ ask: "1.1475", bid: "1.1471" }), ok: true, status: 200 }; + }); + expect(requested).toBe("https://api.exchange.coinbase.com/products/EURC-USDC/ticker"); + }); +}); + +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 fetchImpl = async (url: string) => { + expect(url).toBe("https://api.exchange.coinbase.com/products/EURC-USDC"); + return { json: async () => ({ id: "EURC-USDC", status: "online", trading_disabled: false }), ok: true, status: 200 }; + }; + expect(await fetchCoinbaseProductStatus(fetchImpl)).toEqual({ status: "online", tradingDisabled: false }); + await expect( + fetchCoinbaseProductStatus(async () => ({ json: async () => ({ status: "online" }), ok: true, status: 200 })) + ).rejects.toThrow("malformed"); + await expect(fetchCoinbaseProductStatus(async () => ({ json: async () => null, ok: false, status: 503 }))).rejects.toThrow( + "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..9659a3664 --- /dev/null +++ b/apps/api/src/api/services/monerium-b2b/reference-rate.ts @@ -0,0 +1,132 @@ +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; +} + +export interface TopOfBook { + ask: string; + bid: string; +} + +/** Extracts the top of book from a Coinbase ticker response; anything but two positive decimals throws. */ +export function parseTicker(body: unknown): TopOfBook { + const ticker = body as { ask?: unknown; bid?: unknown } | null; + const bid = ticker?.bid; + const ask = ticker?.ask; + if (typeof bid !== "string" || typeof ask !== "string" || !/^\d+(\.\d+)?$/.test(bid) || !/^\d+(\.\d+)?$/.test(ask)) { + throw new Error("Coinbase ticker response is malformed"); + } + return { ask, bid }; +} + +/** Spread of the top of book in bps of the midpoint (floored). */ +export function spreadBps(book: TopOfBook, decimals: number): number { + const bid = parseUnits(book.bid, decimals); + const ask = parseUnits(book.ask, decimals); + if (bid <= 0n || ask < bid) { + throw new Error(`Coinbase top of book is inverted or empty (bid ${book.bid}, ask ${book.ask})`); + } + const mid = (bid + ask) / 2n; + return Number(((ask - bid) * 10_000n) / mid); +} + +/** The bid/ask midpoint scaled to `decimals`, floored to the unit. */ +export function computeMid(book: TopOfBook, decimals: number): bigint { + return (parseUnits(book.bid, decimals) + parseUnits(book.ask, decimals)) / 2n; +} + +export type FetchLike = ( + url: string, + init?: { signal?: AbortSignal } +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +/** Reads the ticker and returns the midpoint. Any failure throws; the caller defers. */ +export async function fetchCoinbaseReference( + decimals: number, + fetchImpl: FetchLike = fetch, + nowMs: number = Date.now() +): Promise { + const response = await fetchImpl(COINBASE_EURC_TICKER_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Coinbase ticker responded ${response.status}`); + } + const book = parseTicker(await response.json()); + const spread = spreadBps(book, decimals); + if (spread > MAX_SPREAD_BPS) { + throw new Error(`Coinbase ${COINBASE_REFERENCE_PRODUCT} spread of ${spread} bps exceeds ${MAX_SPREAD_BPS} bps`); + } + const rateRaw = computeMid(book, decimals); + if (rateRaw <= 0n) { + throw new Error(`Coinbase ${COINBASE_REFERENCE_PRODUCT} midpoint is zero`); + } + return { price: formatUnits(rateRaw, decimals), rateRaw, source: COINBASE_REFERENCE_SOURCE, time: new Date(nowMs) }; +} + +// ------------------------------------------------------------------ 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(fetchImpl: FetchLike = fetch): Promise { + const response = await fetchImpl(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/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 204ce6c61..8dbb4c720 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -2,17 +2,20 @@ import { CronJob } from "cron"; import { QueryTypes } from "sequelize"; import sequelize from "../../config/database"; import logger from "../../config/logger"; +import { config } from "../../config/vars"; import { MoneriumFiatDepositStatus } from "../../models/moneriumFiatDeposit.model"; import { isKeeperChainConfigured } from "../services/monerium-b2b/chain"; -import { reconcileConfirmedExecutionAllocations, runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; +import { runConversionExecutor } from "../services/monerium-b2b/conversion-executor"; import { processMoneriumWebhookInbox, pruneProcessedWebhookEvents } from "../services/monerium-b2b/deposit-processor"; import { runDormancyGate } from "../services/monerium-b2b/dormancy"; import { emitMoneriumDepositEvents } from "../services/monerium-b2b/manager-events"; import { runMintWatcher } from "../services/monerium-b2b/mint-watcher"; import { runMonitoringPass } from "../services/monerium-b2b/monitoring"; import { advanceOnboardingAccounts } from "../services/monerium-b2b/onboarding"; +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 -> @@ -60,7 +63,6 @@ class MoneriumB2bWorker { } } else { const mintedAccountIds = await runMintWatcher(); - await reconcileConfirmedExecutionAllocations(); const candidateIds = await this.conversionCandidates(mintedAccountIds); for (const accountId of candidateIds) { try { @@ -71,6 +73,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 @@ -92,21 +104,28 @@ 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: [ + MoneriumFiatDepositStatus.Minted, + MoneriumFiatDepositStatus.Converting, + MoneriumFiatDepositStatus.Recovering + ] + }, + type: QueryTypes.SELECT + } ); for (const row of outstanding) { candidates.add(row.accountId); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 8fcd437bc..862cf28a9 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -225,12 +225,28 @@ 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; + /** Key of the immutable RECOVERY_WALLET: signs the reverse swap and the Monerium redeem message. */ + recoveryPrivateKey: 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; @@ -278,6 +294,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 || "", amplitudeWss: process.env.AMPLITUDE_WSS || "wss://rpc-amplitude.pendulumchain.tech", @@ -346,16 +388,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), + recoveryPrivateKey: process.env.MONERIUM_B2B_RECOVERY_PRIVATE_KEY, rpcUrl: process.env.MONERIUM_B2B_RPC_URL, + subsidyLadder: parseSubsidyLadder(process.env.MONERIUM_B2B_SUBSIDY_LADDER), webhookSecret: process.env.MONERIUM_B2B_WEBHOOK_SECRET || "" }, mykobo: { @@ -480,10 +530,30 @@ if (config.moneriumB2b.enabled) { ) { throw new Error("MONERIUM_B2B_FORWARDER_FACTORY_ADDRESS must be a valid EVM address"); } + if (config.moneriumB2b.autoRecovery === "auto") { + const missingRecovery: string[] = []; + if (!config.moneriumB2b.recoveryPrivateKey) missingRecovery.push("MONERIUM_B2B_RECOVERY_PRIVATE_KEY"); + if (!config.moneriumB2b.floatPrivateKey) missingRecovery.push("MONERIUM_B2B_FLOAT_PRIVATE_KEY"); + if (missingRecovery.length > 0) { + throw new Error(`MONERIUM_B2B_AUTO_RECOVERY=auto requires ${missingRecovery.join(", ")}`); + } + } + 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], + ...(config.moneriumB2b.recoveryPrivateKey + ? ([["MONERIUM_B2B_RECOVERY_PRIVATE_KEY", config.moneriumB2b.recoveryPrivateKey]] as const) + : []), + ...(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`); 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..2207d52b8 --- /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 (a Coinbase VWAP; the window +// length is recorded so the rate can be recomputed from public candles) and may draw a +// subsidy from the vault (docs/architecture-monerium-b2b-onramp.md, fees section). The +// reference and the chosen route 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_window_seconds", { + allowNull: true, + type: DataTypes.INTEGER + }); + 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) + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + 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_window_seconds"); + 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..b8d376b2c --- /dev/null +++ b/apps/api/src/database/migrations/081-monerium-recovery-automation.ts @@ -0,0 +1,49 @@ +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. +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.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", "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/082-add-monerium-deposit-returned-marker.ts b/apps/api/src/database/migrations/082-add-monerium-deposit-returned-marker.ts new file mode 100644 index 000000000..8f0a054d9 --- /dev/null +++ b/apps/api/src/database/migrations/082-add-monerium-deposit-returned-marker.ts @@ -0,0 +1,11 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Emission marker for the DEPOSIT_RETURNED manager event, like the received/converted +// markers: fires exactly once per refunded deposit, never replays to late subscribers. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_fiat_deposits", "returned_event_at", { allowNull: true, type: DataTypes.DATE }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_fiat_deposits", "returned_event_at"); +} diff --git a/apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts b/apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts new file mode 100644 index 000000000..b880ed7dd --- /dev/null +++ b/apps/api/src/database/migrations/083-add-conversion-max-subsidy.ts @@ -0,0 +1,15 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// The keeper's subsidy tier for a chunk swap (docs/architecture-monerium-b2b-onramp.md, +// fees section): passed into `swap(reference, route, amountIn, maxSubsidy)` and +// persisted before broadcast, so the calldata-exact crash recovery can rebuild it. +export async function up(queryInterface: QueryInterface): Promise { + 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"); +} diff --git a/apps/api/src/database/migrations/084-drop-conversion-reference-window.ts b/apps/api/src/database/migrations/084-drop-conversion-reference-window.ts new file mode 100644 index 000000000..552001797 --- /dev/null +++ b/apps/api/src/database/migrations/084-drop-conversion-reference-window.ts @@ -0,0 +1,14 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// The reference is the Coinbase bid/ask midpoint (spot) since adr-0005's 2026-09-18 +// amendment; the averaging window of the former VWAP has nothing left to record. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("monerium_conversion_executions", "reference_window_seconds"); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("monerium_conversion_executions", "reference_window_seconds", { + allowNull: true, + type: DataTypes.INTEGER + }); +} 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..bc177176f 100644 --- a/apps/api/src/models/moneriumAccount.model.ts +++ b/apps/api/src/models/moneriumAccount.model.ts @@ -20,8 +20,8 @@ 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; @@ -31,7 +31,16 @@ export interface MoneriumAccountAttributes { type MoneriumAccountCreationAttributes = Optional< MoneriumAccountAttributes, - "id" | "vortexProfileId" | "iban" | "configVersion" | "status" | "dormantSince" | "createdAt" | "updatedAt" + | "id" + | "vortexProfileId" + | "iban" + | "targetPpm" + | "floorPpm" + | "configVersion" + | "status" + | "dormantSince" + | "createdAt" + | "updatedAt" >; class MoneriumAccount @@ -44,8 +53,8 @@ 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; @@ -76,15 +85,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: { @@ -113,6 +119,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..84f54b3c8 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,14 @@ 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; createdAt: Date; updatedAt: Date; } @@ -38,8 +60,12 @@ type MoneriumFiatDepositCreationAttributes = Optional< | "logIndex" | "blockHash" | "blockNumber" + | "mintedAt" + | "payerIban" + | "payerName" | "receivedEventAt" | "convertedEventAt" + | "returnedEventAt" | "createdAt" | "updatedAt" >; @@ -59,8 +85,12 @@ 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 createdAt: Date; declare updatedAt: Date; } @@ -120,17 +150,37 @@ MoneriumFiatDeposit.init( 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 }, + returnedEventAt: { + allowNull: true, + field: "returned_event_at", + type: DataTypes.DATE + }, status: { allowNull: false, defaultValue: MoneriumFiatDepositStatus.Pending, 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/tests/monerium-b2b-account-read.integration.test.ts b/apps/api/src/tests/monerium-b2b-account-read.integration.test.ts index cbcaef1e9..6932ec8cd 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"], 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"]); expect(body.quoteId).toBeNull(); // The transaction-family requirement still holds at the same HTTP surface. diff --git a/contracts/monerium-forwarder/README.md b/contracts/monerium-forwarder/README.md index 0d7517c65..53b1bfc77 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 immutable Vortex +recovery wallet 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/script/manifest-core.ts b/contracts/monerium-forwarder/script/manifest-core.ts index da1707f7f..a66d6f99f 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 = 4; export const MANIFEST_PURPOSE = "Consistency evidence for a VortexForwarder deployment (Monerium B2B onramp). " + @@ -30,17 +30,20 @@ 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, 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 targetPpm() view returns (uint32)", + "function floorPpm() view returns (uint32)" ]); export const implementationAbi = parseAbi([ @@ -53,13 +56,13 @@ export const implementationAbi = parseAbi([ "function FACTORY() view returns (address)", "function ATTESTOR() view returns (address)", "function FEE_RECIPIENT() view returns (address)", + "function RECOVERY_WALLET() 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,42 +79,39 @@ 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; + RECOVERY_WALLET: string; 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 has no setter (a client wallet + * change means a new clone, runbook §5) and factory registration never changes. + * Mismatch = incident. + */ immutables: { + destination: string; isForwarder: boolean; }; /** keccak256 of the clone's runtime code; must equal the EIP-1167 code for `implementation.address`. */ @@ -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,11 @@ export async function readCoreState(client: PublicClient, factoryAddress: Addres feeRecipient, maxOracleAge, slippageBps, - maxFeeBps, - sweepDelay, + maxFeePpm, + maxReferenceDeviationBps, + recoveryWallet, + recoveryDelay, triggerDelay, - poolFeeEureEurc, - poolFeeEurcUsdc, linkHash191, recoveryHash, linkMessage @@ -328,11 +350,11 @@ 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_WALLET"), + 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 +373,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 +389,16 @@ 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, + RECOVERY_WALLET: getAddress(recoveryWallet), ROUTER: getAddress(router), SLIPPAGE_BPS: Number(slippageBps), - SWEEP_DELAY: sweepDelay.toString(), TRIGGER_DELAY: triggerDelay.toString(), USDC: getAddress(usdc) }, @@ -392,28 +416,26 @@ export async function readForwarderEntry( ): Promise { const factory = getAddress(factoryAddress); const forwarder = getAddress(forwarderAddress); - const [destination, fallbackAddress, feeBps, isForwarder, forwarderCodeHash] = await Promise.all([ + const [destination, 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, "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: { + destination: getAddress(destination), isForwarder }, runtimeBytecodeHash: forwarderCodeHash diff --git a/contracts/monerium-forwarder/script/verify-manifest.test.ts b/contracts/monerium-forwarder/script/verify-manifest.test.ts index 420901354..cfde5a441 100644 --- a/contracts/monerium-forwarder/script/verify-manifest.test.ts +++ b/contracts/monerium-forwarder/script/verify-manifest.test.ts @@ -1,14 +1,39 @@ 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.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..929625d70 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(address to, 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 the immutable Vortex RECOVERY_WALLET, 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 its own 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."; @@ -73,17 +85,23 @@ contract VortexForwarder { IVortexForwarderFactory public immutable FACTORY; address public immutable ATTESTOR; // signs the Monerium link attestation address public immutable FEE_RECIPIENT; + /// @dev The only address a recovery can move funds to: a Vortex wallet linked to a + /// Vortex company profile at Monerium, from which the bank refund is redeemed. + address public immutable RECOVERY_WALLET; 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 RECOVERY_WALLET — 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. @@ -100,13 +118,13 @@ contract VortexForwarder { address oracle; address attestor; address feeRecipient; + address recoveryWallet; uint256 maxOracleAge; uint16 slippageBps; - uint16 maxFeeBps; - uint256 sweepDelay; + uint32 maxFeePpm; + uint16 maxReferenceDeviationBps; + uint256 recoveryDelay; uint256 triggerDelay; - uint24 poolFeeEureEurc; - uint24 poolFeeEurcUsdc; bytes32 recoveryHash; } @@ -115,64 +133,86 @@ 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 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, 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 constructor(ImmutableConfig memory cfg) { + // A zero recovery wallet would make `recover` burn client funds. + if (cfg.recoveryWallet == address(0)) revert ZeroAddress(); EURE = IERC20(cfg.eure); EURC = IERC20(cfg.eurc); USDC = IERC20(cfg.usdc); @@ -182,13 +222,13 @@ contract VortexForwarder { FACTORY = IVortexForwarderFactory(msg.sender); ATTESTOR = cfg.attestor; FEE_RECIPIENT = cfg.feeRecipient; + RECOVERY_WALLET = cfg.recoveryWallet; 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 +246,35 @@ contract VortexForwarder { _reentrancyGuard = 0; } - modifier onlyFallback() { - if (msg.sender != fallbackAddress) revert NotFallbackAddress(); + modifier onlyGuardian() { + if (msg.sender != FACTORY.guardian()) revert NotGuardian(); + _; + } + + modifier onlyKeeper() { + if (!_privileged()) revert NotKeeper(); _; } - modifier onlyGuardian() { - if (msg.sender != FACTORY.guardian()) revert NotGuardian(); + 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_, uint32 targetPpm_, uint32 floorPpm_) external { if (msg.sender != address(FACTORY)) revert NotFactory(); if (initialized) revert AlreadyInitialized(); _validateConfigAddress(destination_); - _validateConfigAddress(fallbackAddress_); - if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh(); + _validateFeePolicy(targetPpm_, floorPpm_); initialized = true; destination = destination_; - fallbackAddress = fallbackAddress_; - feeBps = feeBps_; - emit Initialized(destination_, fallbackAddress_, feeBps_); + targetPpm = targetPpm_; + floorPpm = floorPpm_; + emit Initialized(destination_, targetPpm_, floorPpm_); } // -------------------------------------------------------------- EIP-1271 @@ -269,201 +313,313 @@ 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) _requireBatchAge(TRIGGER_DELAY, NotAuthorizedYet.selector); + + 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(address(this), 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()) _requireBatchAge(TRIGGER_DELAY, NotAuthorizedYet.selector); + 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 RECOVERY_WALLET 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 { + _requireBatchAge(RECOVERY_DELAY, DelayNotElapsed.selector); + 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, RECOVERY_WALLET, eureAmount); + _transfer(USDC, RECOVERY_WALLET, 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 Reverts with `err` unless the batch marker is armed and older than `delay`. + function _requireBatchAge(uint256 delay, bytes4 err) internal view { + if (batchOpenedAt == 0 || block.timestamp - batchOpenedAt < delay) { + // solhint-disable-next-line no-inline-assembly + assembly { + mstore(0, err) + revert(0, 4) + } + } + } + + /// @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 ( account == address(EURE) || account == address(EURC) || account == address(USDC) - || account == address(ROUTER) || account == address(this) + || account == address(ROUTER) || account == address(this) || account == RECOVERY_WALLET ) revert InvalidConfigAddress(); } diff --git a/contracts/monerium-forwarder/src/VortexForwarderFactory.sol b/contracts/monerium-forwarder/src/VortexForwarderFactory.sol index 0e12fde06..217c4a51d 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,34 @@ 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, 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 +65,7 @@ contract VortexForwarderFactory { error NotPendingGuardian(); error OutOfBounds(); error CloneFailed(); + error InvalidRoute(); modifier onlyGuardian() { if (msg.sender != guardian) revert NotGuardian(); @@ -50,14 +77,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 +97,15 @@ 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) + function deployForwarder(address destination, 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, targetPpm, floorPpm); isForwarder[forwarder] = true; - emit ForwarderDeployed(forwarder, destination, fallbackAddress, feeBps, salt); + emit ForwarderDeployed(forwarder, destination, targetPpm, floorPpm, salt); } function predictAddress(bytes32 salt) external view returns (address) { @@ -101,6 +133,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 +188,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..513a1cc1d --- /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, address indexed to, 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 `to` on behalf of 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(address to, 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(to, amount); + emit SubsidyPaid(msg.sender, to, 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..385bfd77f 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; @@ -53,22 +59,34 @@ contract VortexForwarderForkTest is Test { oracle: CHAINLINK_EUR_USD, attestor: attestor, feeRecipient: makeAddr("feeRecipient"), + recoveryWallet: recoveryWallet, 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, 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 +103,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 +127,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..8401f4f00 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); @@ -46,23 +65,32 @@ contract ForwarderHandler is Test { oracle: address(oracle), attestor: vm.addr(0xA11CE), feeRecipient: feeRecipient, + recoveryWallet: recoveryWallet, 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, INITIAL_TARGET_PPM, INITIAL_FLOOR_PPM, bytes32(uint256(1))) + ); + ghostExpectedTargetPpm = INITIAL_TARGET_PPM; + ghostExpectedFloorPpm = INITIAL_FLOOR_PPM; } // ------------------------------------------------------------- actions @@ -81,82 +109,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 +238,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..75e525efc 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(address, 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,53 @@ 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, 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, + recoveryWallet: recoveryWallet, + 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 +201,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 +247,10 @@ 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, fallbackAddr, 0, bytes32(uint256(8)))); + VortexForwarder fwd2 = + VortexForwarder(f2.deployForwarder(destination, TARGET_PPM, FLOOR_PPM, 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); @@ -252,7 +285,7 @@ contract VortexForwarderTest is Test { function test_linkSignature_rejectsCrossCloneReplay() public { VortexForwarder other = - VortexForwarder(factory.deployForwarder(destination, fallbackAddr, 0, bytes32(uint256(2)))); + VortexForwarder(factory.deployForwarder(destination, 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 +295,362 @@ contract VortexForwarderTest is Test { function test_initialize_onlyFactory_andOnce() public { vm.expectRevert(VortexForwarder.NotFactory.selector); - fwd.initialize(rando, rando, 0); + fwd.initialize(rando, 0, 0); vm.prank(address(factory)); vm.expectRevert(VortexForwarder.AlreadyInitialized.selector); - fwd.initialize(rando, rando, 0); + fwd.initialize(rando, 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, 0, 0); + } + + function test_deploy_rejectsRecoveryWalletAsDestination() public { + vm.expectRevert(VortexForwarder.InvalidConfigAddress.selector); + factory.deployForwarder(recoveryWallet, TARGET_PPM, FLOOR_PPM, bytes32(uint256(3))); } - // ---------------------------------------------------------------- swap + function test_implementation_rejectsZeroRecoveryWallet() public { + VortexForwarder.ImmutableConfig memory cfg = _config(address(router), bytes32(0)); + cfg.recoveryWallet = address(0); + vm.expectRevert(VortexForwarder.ZeroAddress.selector); + new VortexForwarderFactory(cfg, 1e18, 50_000e18, 25e18, 10_000e18, _route(500, 500)); + } + + // ---------------------------------------------------------------- swap + forward - function test_swapAndForward_happyPath_forwardsToDestination() public { + function test_swap_keepsUsdcOnTheClone_untilForward() public { _fund(1_000e18); - // minOut = 1000 * 1.14 * 0.99 = 1128.6 USDC - router.setNextOut(1_130e6); - vm.prank(keeper); - fwd.swapAndForward(); - assertEq(usdc.balanceOf(destination), 1_130e6); + 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.swapAndForward(); - vm.prank(fallbackAddr); - fwd.setClientPaused(false); + fwd.swap(REF, 0, 1_000e18, NO_CAP); + vm.expectRevert(VortexForwarder.Paused.selector); + 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, 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 +664,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, TARGET_PPM, FLOOR_PPM, salt); assertEq(predicted, deployed); } @@ -533,90 +679,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); + } + + 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); + } + + /// 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); } - // ------------------------------------------------------- fee timelock (P11) + /// 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"); - function test_setFeeBps_onlyGuardianAndCapped() public { + // 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, 2_000, 1_500, bytes32(uint256(9))); // target above floor + vm.expectRevert(VortexForwarder.InvalidFeePolicy.selector); + factory.deployForwarder(destination, 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..bf863588d --- /dev/null +++ b/contracts/monerium-forwarder/test/VortexSubsidyVault.t.sol @@ -0,0 +1,130 @@ +// 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 destination = makeAddr("destination"); + 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(destination, 1e6, REFERENCE_OUT); + + factory.register(forwarder, false); + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.NotForwarder.selector); + vault.pay(destination, 1e6, REFERENCE_OUT); + } + + function test_pay_transfersAndCountsAgainstTheDay() public { + vm.prank(forwarder); + vault.pay(destination, 30e6, REFERENCE_OUT); + assertEq(usdc.balanceOf(destination), 30e6); + assertEq(vault.spentToday(), 30e6); + assertEq(vault.currentDay(), block.timestamp / 1 days); + } + + function test_pay_enforcesPerSwapCap() public { + vm.prank(forwarder); + vault.pay(destination, 50e6, REFERENCE_OUT); // exactly the cap is fine + vm.prank(forwarder); + vm.expectRevert(VortexSubsidyVault.SubsidyCapExceeded.selector); + vault.pay(destination, 50e6 + 1, REFERENCE_OUT); + } + + function test_pay_enforcesDailyBudget_andResetsNextDay() public { + vm.startPrank(forwarder); + vault.pay(destination, 50e6, REFERENCE_OUT); + vault.pay(destination, 50e6, REFERENCE_OUT); // budget fully used + vm.expectRevert(VortexSubsidyVault.BudgetExhausted.selector); + vault.pay(destination, 1, REFERENCE_OUT); + + vm.warp((block.timestamp / 1 days + 1) * 1 days); // next UTC day + vault.pay(destination, 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(destination, 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(destination, 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(destination, 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 c9fdfc620..bb192e6cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | | [`proposal-monerium-consumer-onramp.md`](proposal-monerium-consumer-onramp.md) | Phase-2 proposal for the consumer (Safe + passkey) Monerium onramp; the B2B variant shipped | +| [`proposal-monerium-b2b-settlement-and-recovery.md`](proposal-monerium-b2b-settlement-and-recovery.md) | Draft plan (2026-09-17) to rework PR #1375: whole-deposit USDC forwarding and automatic exact-amount refund recovery via a Vortex-held recovery wallet | | [`proposal-api-driven-kyc-kyb.md`](proposal-api-driven-kyc-kyb.md) | Proposal for API-driven verification using preserved provider-specific workflows | | [`proposal-sumsub-kyc-token-sharing.md`](proposal-sumsub-kyc-token-sharing.md) | Implemented and enabled in code on the branch; production readiness still awaits provider, legal, and sandbox confirmation | diff --git a/docs/adr-0005-monerium-b2b-onramp.md b/docs/adr-0005-monerium-b2b-onramp.md index b582acc8f..af3b91865 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,38 @@ 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 Vortex recovery wallet — see the second + amendment. 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 Vortex recovery 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 Vortex recovery wallet and Vortex redeems from there (amendment + 2026-09-17), 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 immutable recovery wallet and only + once the clone's batch has been open for `RECOVERY_DELAY` (amendment 2026-09-17); 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 +83,184 @@ 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 the destination that clone + passes (its own immutable one), 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 is +[`proposal-monerium-b2b-settlement-and-recovery.md`](proposal-monerium-b2b-settlement-and-recovery.md)): + +- **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. Approach A of the proposal + (no 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.** `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. + ## 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) | +| 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 | | 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) | | 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 | @@ -129,14 +296,30 @@ example (oversized-deposit allocation). - **CEX destination rotation.** Not verifiable on-chain; carried contractually (B5) with penny test, dormancy gate, and minimum-forward diligence. Silent-loss risk converts to a pause via the dormancy gate. -- **Fallback-key loss + broken destination** — ordinary self-custody residual, borne - by the client (terms; do not overpromise exits — R11). +- **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 the penny test and 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 recovery 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 recovery 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 +328,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/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 3a9a82016..e057c9379 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -2864,15 +2864,16 @@ 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 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; /** @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"]; @@ -2880,33 +2881,51 @@ export interface components { MoneriumB2bDeposit: { /** @description Deposit amount in 18-decimal base units of the deposit currency. */ amountRaw: string; - /** @description Conversion portions allocated to this deposit, oldest first. Empty while the deposit awaits conversion; multiple entries are returned when a per-swap cap splits the deposit. */ + /** @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. */ + /** @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. * @enum {string} */ status: "pending" | "confirmed" | "failed"; - /** @description The swap-and-forward transaction hash. */ + /** @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; depositId: string; + /** @description The single transaction that delivered the whole converted deposit to the destination; null until the deposit is forwarded. */ + forwardTxHash: string | null; + /** @description Present once the deposit entered the refund path (it could not be converted within the promised window): the EUR amount refunded to the payer once known, Monerium's redeem order id, and the transaction that moved the deposit off the forwarding contract. Null otherwise. */ + refund: { + /** @description The EUR amount refunded, to the cent; null until the refund order is placed. */ + amount: string | null; + recoverTxHash: string | null; + redeemOrderId: string | null; + } | null; /** - * @description Deposit status (forward-only). + * @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; }; MoneriumB2bDepositsResponse: { diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 67ff4cf38..1ce5ff5c3 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2517,11 +2517,8 @@ "format": "date-time", "type": ["string", "null"] }, - "fallbackAddress": { - "description": "The client's self-custodied recovery address.", - "type": "string" - }, - "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": { @@ -2535,6 +2532,10 @@ "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": [ @@ -2542,11 +2543,11 @@ "createdAt", "destination", "dormantSince", - "fallbackAddress", - "feeBps", + "floorPpm", "forwarderAddress", "iban", - "status" + "status", + "targetPpm" ], "type": "object" }, @@ -2566,13 +2567,32 @@ "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": { "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" }, @@ -2582,15 +2602,15 @@ "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": ["eureInRaw", "execution", "executionId", "status", "txHash", "usdcNetRaw"], "type": "object" }, "type": "array" @@ -2605,9 +2625,40 @@ "depositId": { "type": "string" }, + "forwardTxHash": { + "description": "The single transaction that delivered the whole converted deposit to the destination; null until the deposit is forwarded.", + "type": ["string", "null"] + }, + "refund": { + "description": "Present once the deposit entered the refund path (it could not be converted within the promised window): the EUR amount refunded to the payer once known, Monerium's redeem 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; null until the refund order is placed.", + "type": ["string", "null"] + }, + "recoverTxHash": { + "type": ["string", "null"] + }, + "redeemOrderId": { + "type": ["string", "null"] + } + }, + "required": ["amount", "recoverTxHash", "redeemOrderId"], + "type": ["object", "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": { @@ -2615,11 +2666,22 @@ "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" } }, - "required": ["amountRaw", "conversions", "createdAt", "currency", "depositId", "status", "txHash", "usdcNetRaw"], + "required": [ + "amountRaw", + "conversions", + "createdAt", + "currency", + "depositId", + "forwardTxHash", + "refund", + "status", + "txHash", + "usdcNetRaw" + ], "type": "object" }, "MoneriumB2bDepositsResponse": { @@ -11018,7 +11080,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.", + "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\"] must be requested explicitly, cannot be mixed with transaction events, require a profile-scoped secret credential, and take no quoteId/sessionId.", "type": "string" }, "type": "array" diff --git a/docs/api/pages/07-webhooks.md b/docs/api/pages/07-webhooks.md index 0522f7cc2..c960703a8 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. +- **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. ## Security Model @@ -113,7 +113,7 @@ Managers whose business clients hold EUR onramp accounts can subscribe to deposi ```json { "url": "https://manager.example.com/vortex/deposits", - "events": ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED"] + "events": ["DEPOSIT_RECEIVED", "DEPOSIT_CONVERTED", "DEPOSIT_RETURNED"] } ``` @@ -142,7 +142,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 { @@ -155,28 +155,64 @@ 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 carries the pricing of the whole execution, identical on every deposit it consumed: `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). A deposit's own net already includes its share of 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 Monerium's order for the outgoing SEPA transfer, and `recoverTxHash` the transaction that moved the deposit off the forwarding contract. ### Delivery Semantics diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 4335fb2a4..6d1421225 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. +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 fee policy (`targetPpm` and `floorPpm`, parts per million below the reference rate), and every conversion listed on a deposit carries the execution's reference rate, fee and subsidy, as documented for the [`DEPOSIT_CONVERTED` webhook](https://api-docs.vortexfinance.co/webhooks). ## 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 `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 f2187e929..5a9b51f52 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -12,7 +12,7 @@ A diff here means: check backward compatibility for live integrations, and keep ## packages/shared — partner wire contract (`src/endpoints`) ```text -ACCOUNT_WEBHOOK_EVENT_TYPES: readonly [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED] +ACCOUNT_WEBHOOK_EVENT_TYPES: readonly [WebhookEventType.DEPOSIT_RECEIVED, WebhookEventType.DEPOSIT_CONVERTED, WebhookEventType.DEPOSIT_RETURNED] AcceptedRecipientInvite: { id: string; @@ -382,6 +382,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; @@ -442,15 +448,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; @@ -465,13 +477,35 @@ 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; +} + +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; } -DepositStatus: enum DepositStatus { HELD = "held", MINTED = "minted", PENDING = "pending", RETURNED = "returned" } +DepositStatus: enum DepositStatus { CONVERTING = "converting", FORWARDED = "forwarded", HELD = "held", MINTED = "minted", PENDING = "pending", RECOVERING = "recovering", RECOVERY_FAILED = "recovery_failed", REFUNDED = "refunded", RETURNED = "returned" } DepositWebhookPayloadBase: { accountId: string; @@ -479,7 +513,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; } @@ -1663,7 +1697,7 @@ RegisterRampResponse: { } RegisterWebhookRequest: { - events?: Array; + events?: Array; quoteId?: string; sessionId?: string; url: string; @@ -1671,7 +1705,7 @@ RegisterWebhookRequest: { RegisterWebhookResponse: { createdAt: string; - events: Array; + events: Array; id: string; isActive: boolean; quoteId: null | string; @@ -2431,15 +2465,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; @@ -2452,10 +2492,30 @@ 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.STATUS_CHANGE; @@ -2483,7 +2543,7 @@ 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 { DEPOSIT_CONVERTED = "DEPOSIT_CONVERTED", DEPOSIT_RECEIVED = "DEPOSIT_RECEIVED", DEPOSIT_RETURNED = "DEPOSIT_RETURNED", STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } WebhookPayload: { eventId: string; @@ -2494,15 +2554,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; @@ -2515,10 +2581,30 @@ 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.STATUS_CHANGE; diff --git a/docs/architecture-monerium-b2b-onramp.md b/docs/architecture-monerium-b2b-onramp.md index a8a922f8a..4c27a081a 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 +Vortex recovery wallet (`recover`, keeper-only, contract-gated by `RECOVERY_DELAY`) and +refunded to the payer's bank account — 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["Vortex recovery 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_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 immutable Vortex recovery wallet, 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,7 +120,7 @@ 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->>C: deployForwarder(destination, 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 @@ -113,9 +136,9 @@ 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) 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 @@ -132,7 +155,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 +165,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 +235,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 +252,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) @@ -235,68 +274,154 @@ 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 €120k deposit at a + €25k 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 + immutable `RECOVERY_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 recovery + wallet up to exactly the issue amount (or a surplus is swept back to the float), and a + Monerium redeem order from the recovery wallet returns the exact amount to the payer's + IBAN (`payer_iban` / `payer_name`, captured from the issue order's counterpart). 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 dedicated recovery 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 +431,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 +463,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 +472,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 +484,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 +520,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..53831434c 100644 --- a/docs/operations-monerium-b2b-rollout.md +++ b/docs/operations-monerium-b2b-rollout.md @@ -11,6 +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 — + **re-approval needed (2026-09-17):** the fallback is now a Vortex-held recovery + wallet linked to a Vortex/SatoshiPay company profile, and that one profile refunds + many client corporates by SEPA; ask alongside whether `supportingDocumentId` is + required for a return-to-originator above EUR 15,000 and what outgoing limits apply — 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 @@ -32,13 +36,15 @@ 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) @@ -47,23 +53,35 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control 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` €25k 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, the recovery wallet address (a dedicated linked address on the Vortex + company profile — onboard that profile in the whitelabel app first), + €25 floor/€50k ceiling, initial 5 bps/5 bps route); set operational `minSwapAmount` + €250 and `perSwapCap` €25k; 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. + `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 → @@ -85,11 +103,12 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control ## 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.) @@ -99,41 +118,76 @@ attestations per customer, 3–5 clients at **€50k/client/day** (paper control 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. + deposit, and never sending unconverted EURe to the destination. 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. + > 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 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. + 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 its own + recovery wallet in order to refund it. What Vortex cannot do: redirect funds. The + contract can pay only the client's payout address, Vortex's fee treasury, and the + fixed Vortex recovery 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 +198,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 | +| Recovery wallet + float wallet | Ops ↔ Monerium | Onboard a Vortex/SatoshiPay company profile in the whitelabel app; link one dedicated address as `RECOVERY_WALLET` (immutable at implementation deploy) and one as the EURe float; fund the float; keys into the keeper's KMS before recovery is automated | +| Refund automation | Ops | Implemented (`recovery.ts`): ship with `MONERIUM_B2B_AUTO_RECOVERY=alert`, observe one sandbox refund end to end, then `auto` with the recovery and float keys 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..d61d51bfc 100644 --- a/docs/operations-monerium-b2b-runbook.md +++ b/docs/operations-monerium-b2b-runbook.md @@ -9,18 +9,25 @@ 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 + immutable Vortex recovery wallet 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 recovery 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 Vortex recovery 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 @@ -28,7 +35,8 @@ Deploy → manifest → verify → map → (automated: link + IBAN) → penny te 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,11 +48,12 @@ 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 recovery address: the recovery wallet is Vortex's, immutable in the + implementation. The destination has no setter — a client wallet change is a new clone, + §5 — so get it right and penny-test it.) +- `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 @@ -52,8 +61,8 @@ Monerium profile UUID at hand; the partner configured as a managed-profile manag ```bash # predict, then deploy (guardian-only); salt = any unused bytes32, convention: client index cast call $FACTORY "predictAddress(bytes32)(address)" $SALT --rpc-url $RPC -cast send $FACTORY "deployForwarder(address,address,uint16,bytes32)" \ - $DESTINATION $FALLBACK $FEE_BPS $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY +cast send $FACTORY "deployForwarder(address,uint32,uint32,bytes32)" \ + $DESTINATION $TARGET_PPM $FLOOR_PPM $SALT --rpc-url $RPC --private-key $GUARDIAN_KEY ``` The clone is initialized atomically in the deploy tx (`ForwarderDeployed` event). @@ -89,8 +98,8 @@ POST /v1/admin/monerium-b2b/accounts (Authorization: Bearer $ADMIN_SECRET "moneriumProfileId": "", "forwarderAddress": "", "destination": "", - "fallbackAddress": "", - "feeBps": 0 + "targetPpm": 1250, + "floorPpm": 1500 } ``` @@ -148,9 +157,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 +180,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 +194,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 the + recovery 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. + paused; residual balances leave through the refund path. 7. **Unpause / decommission** only contracts confirmed unaffected. ### 2.5 Whitelabel-credential compromise (S1) @@ -199,19 +215,117 @@ 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 €25k per-swap cap a top-up at the ladder's top is +about 285 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 +recovery wallet (`RECOVERY_WALLET()` on the implementation) is a linked address of the +Vortex company profile at Monerium, its key and the EURe float wallet's key are in the +operator's custody, and the float holds EURe. + +**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_RECOVERY_PRIVATE_KEY` (must control the +implementation's `RECOVERY_WALLET`) and `MONERIUM_B2B_FLOAT_PRIVATE_KEY` set — 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 wallet). + +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 recovery wallet 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 recovery + 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 recovery wallet` EURe + from the float wallet to the recovery 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 recovery 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 recovery key; attach `supportingDocumentId` above EUR 15,000 (G1 item + 1 asks whether returns are exempt). 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 +338,17 @@ 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 CEX destinations re-run the penny test. Archive the +confirmation. **Un-pause (both steps, always):** @@ -256,7 +373,7 @@ 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 @@ -272,12 +389,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 Vortex recovery 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 | +| Recovery wallet | Holds recovered payments between `recover` and the bank refund; can redeem EURe from the Vortex company profile to any IBAN | Move any balance to a fresh linked address, rotate the key, redeploy the implementation (the address is immutable) before the next recovery; reconcile open recoveries against the ops ledger | +| 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 +427,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 +437,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,12 +529,13 @@ 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 | +| `RECOVERY_WALLET` | a local EOA (immutable; a zero address is refused) | | `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` | | `CAP_CEILING` | `50000e18` | @@ -433,9 +553,17 @@ 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. Use a fresh salt +and record the predicted address and receipt. Read back `destination()`, +`targetPpm()`, `floorPpm()`, and `FACTORY()`, then require +`factory.isForwarder(forwarder) == true` before continuing. The keeper computes its +reference from live Coinbase candles before each swap, so the backend needs outbound +HTTPS during the run. ### 7.4 Create the local account fixture @@ -451,8 +579,8 @@ managed-profile manager: "moneriumProfileId": "", "forwarderAddress": "", "destination": "", - "fallbackAddress": "", - "feeBps": 0 + "targetPpm": 1250, + "floorPpm": 1500 } ``` @@ -520,8 +648,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 +661,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 +700,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 +728,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, diff --git a/docs/proposal-monerium-b2b-settlement-and-recovery.md b/docs/proposal-monerium-b2b-settlement-and-recovery.md new file mode 100644 index 000000000..8777a772b --- /dev/null +++ b/docs/proposal-monerium-b2b-settlement-and-recovery.md @@ -0,0 +1,404 @@ +# Proposal: whole-deposit settlement and automatic refund recovery (Monerium B2B onramp) + +**Status:** accepted 2026-09-17 with every recommendation in §8; phases 0 and 1 are +implemented on PR #1375 (`feat/monerium-forwarder-fee-subsidy`), phases 2 and 3 are in +progress. The decisions live in the second amendment of +[`adr-0005-monerium-b2b-onramp.md`](adr-0005-monerium-b2b-onramp.md); the behaviour in +[`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md). This document +stays as the design rationale (the approaches compared, the feasibility findings) until the +remaining phases land, then it is deleted. + +## 1. What product asked for + +1. **One pay-in, one pay-out.** The keeper keeps swapping in `perSwapCap` chunks, but the + chunks are an implementation detail: USDC accumulates on chain and the client's + destination receives **one transfer for the whole bank payment** once every chunk is + converted. Partner (SulPayments) bookkeeping then maps one SEPA credit to one USDC + transfer. +2. **Automatic refund when the promise is missed.** If a bank payment is not fully + converted inside the promised window, Vortex returns the **exact EUR amount** to the + payer's bank account: EURe and any chunk-swapped USDC move to a Vortex recovery + wallet, USDC is swapped back to EURe, a separate subsidy wallet covers the slippage + residue, and a Monerium redeem order pays the source IBAN. +3. **Custody for recovery is agreed commercially.** The forwarder's fallback address + becomes a Vortex-controlled wallet. This reverses three 2026-09-15 decisions in + ADR-0005: "no payment bouncing", "no Vortex-triggered sweep to the fallback", and the + client-chosen self-custodied fallback (Tier A). + +**Assumptions used below** (each is an open decision in §8): + +- The window is **2 hours** (the brief says "1 hour" once and "2 hours" twice). +- The clock starts at the **EURe mint block timestamp** (on-chain, verifiable; the + provider `processedAt` is within minutes of it). +- "Exact amount" = the `amount` string of the Monerium issue order, to the cent. + +## 2. What this changes in the trust model (say it once, plainly) + +Today's invariant is *"Vortex keys can trigger, never move or redirect"*. After this +change it becomes: + +> Vortex keys can move a client's funds **only** to a fixed Vortex recovery wallet, +> **only** after `RECOVERY_DELAY` has elapsed since the batch opened, and the contract can +> still never send anywhere else (destination, treasury fee, recovery wallet, router). + +Consequences that are not engineering: G1 item 1 (Monerium accepted the attestor pattern +*"conditional on fallback capability"*) must be re-approved for a Vortex-held fallback; +G2 must re-scope custody (the ADR's "non-custody" argument is gone for the recovery +path); rollout terms §2 and §6 ("every exit target is client-controlled") are rewritten; +the client loses the self-custody exit that no Vortex failure could block. What survives: +the permissionless swap-and-forward after `TRIGGER_DELAY` (a Vortex outage still cannot +trap funds on chain). + +## 3. Part A — whole-deposit forwarding: approaches + +### A0. The rung we cannot stop at (keeper-only) + +Add `amountIn` to `swapAndForward` and let the keeper swap **one deposit per execution**. +This alone removes deposit merging (two deposits within a minute no longer share a swap) +and is a two-line contract change. It does **not** solve chunking: the mainnet EURe pools +carry roughly €25k within ~14 bps (T6 baseline), so a €100k ticket as one swap breaches +the 40 bps floor and defers forever. Chunking stays, so accumulation is needed. A0 is +nevertheless the first step of both approaches below. + +### A. Accumulate on the clone (recommended) + +The clone keeps the USDC it swaps and forwards it in one explicit transfer. + +```solidity +function poke() external; // arms batchOpenedAt +function swap(uint256 referenceRate, uint256 routeIndex, uint256 amountIn) external; +function forward(uint256 amount) external; // keeper/guardian → destination +function forwardAll() external; // anyone, after TRIGGER_DELAY → destination +function recover(uint256 eureAmount, uint256 usdcAmount) external; // keeper/guardian, after RECOVERY_DELAY → RECOVERY_WALLET +``` + +- `swap` = today's `swapAndForward` minus the final transfer: fee bands, oracle floor on + the net, route whitelist unchanged. The **subsidy is paid to the clone**, not to the + destination (`pay(address(this), …)`, delta-checked on the clone's own balance). + `amountIn` is explicit (`minSwapAmount ≤ amountIn ≤ min(balance, perSwapCap)`), so the + keeper decides which deposit a chunk belongs to. Permissionless callers after + `TRIGGER_DELAY` keep today's semantics (Chainlink reference, no subsidy, amount clamped). +- `forward(amount)` transfers exactly `amount` USDC to `destination`. The keeper calls + it with `Σ chunk nets (+ subsidies)` of one deposit once every chunk is confirmed. + `forwardAll()` is the liveness fallback (Vortex dead for 24 h ⇒ anyone can push the + whole balance to the destination; batches may merge on that path — documented). +- Marker: `strandedSince` becomes `batchOpenedAt` — armed by `poke()`/`swap()` when + EURe ≥ `MIN_SWAP_FLOOR` or USDC > 0 and the marker is 0; **never re-armed by a partial + swap** (today's re-arm on a cap remainder would restart the 2 h clock); cleared only + when EURe < floor **and** USDC == 0 after `forward`/`recover`. +- Removed: `fallbackAddress`, `onlyFallback` (`setDestination`, `setFallbackAddress`, + `setClientPaused`, `sweep`), `sweepStrandedEure`, `SWEEP_DELAY`, `clientPaused`. + `guardianPaused` stays protective: it blocks `swap`/`forward`, never `recover` (an + incident is exactly when pause-then-recover is wanted). +- Events: `SwapExecuted` loses `forwarded`; new `Forwarded(caller, amount)` and + `Recovered(caller, eure, usdc)`. + +Pros: no new contract, per-client isolation unchanged (a bug in one clone never touches +another client's USDC), USDC never leaves the client's own contract until it goes to the +destination, the smallest audit delta (~40 lines net after deletions), one fewer ERC20 +transfer per chunk than B. Cons: the clone becomes stateful across deposits — when two +deposits are in flight their USDC is one fungible balance on chain and the 1:1 ledger +lives in the database plus the `Forwarded` amounts (a mislabelled keeper cannot steal, +only misattribute between the same client's deposits); R09 unsolicited USDC now waits +for the next `forward`/`forwardAll` instead of riding along with a swap. + +*Variant A′ (optional hardening):* keep a `mapping(bytes32 batchId => uint256)` in the +clone and make `swap`/`forward` batch-keyed, so the chain itself proves each forwarded +amount equals that batch's chunk sum. Adds ~25 lines and a keeper-chosen key; buys an +on-chain audit trail but not protection (the keeper picks the key either way). Skip +unless the partner asks for on-chain per-payment proofs. + +### B. Shared settlement escrow + +The clone swaps as today but sends the USDC (net + subsidy) to one shared +`VortexSettlement` contract, credited under `(clone, batchId)`; the keeper releases a +batch to `clone.destination()` when it is complete. + +```solidity +// VortexForwarder +function swapAndForward(uint256 referenceRate, uint256 routeIndex, uint256 amountIn, bytes32 batchId) external; +function recoverEure(uint256 amount) external; // still needed for the EURe leg +// VortexSettlement (shared, immutable in the implementation) +function credit(bytes32 batchId, uint256 amount) external; // clones only (factory.isForwarder) +function release(address clone, bytes32 batchId) external; // keeper; anyone after TRIGGER_DELAY +function recover(address clone, bytes32 batchId) external; // keeper, after RECOVERY_DELAY → RECOVERY_WALLET +``` + +Pros: explicit per-payment ledger and events on chain (`Credited`/`Released` per +batch), batches never mix even at the contract level, the clone's swap path is nearly +untouched. Cons: a **new contract in audit scope (~150 lines)** and a concentration risk +(one contract holds every client's in-flight USDC — a bug there hits all clients, where +A's blast radius is one clone); an extra transfer per chunk; the clone **still** needs a +recover function for the EURe remainder, so B does not avoid the clone changes, it adds +to them; two places to gate on delays and pause; the destination is read from the clone +at release time (fine, but one more cross-contract assumption for the auditor). + +### Comparison + +| | A (clone accumulates) | B (shared escrow) | +|---|---|---| +| New contracts | none | one | +| Audit delta | ~40 lines net in the clone | clone changes **plus** the escrow | +| Blast radius of a bug | one client | all clients' in-flight USDC | +| On-chain per-payment proof | no (DB + `Forwarded` amounts); A′ adds it | yes | +| Gas per chunk | unchanged | +1 ERC20 transfer | +| Custody narrative | USDC stays on the client's contract | USDC pooled in a Vortex contract | +| Backend accounting | `deposit_id` on executions, one `forward` tx | same, plus batch keys | + +**Recommendation: A.** It is the smallest change that meets the requirement, keeps the +per-client isolation the whole design is built on, and the partner's bookkeeping needs +one USDC transfer with the exact amount, which A delivers. Reconsider A′ only if the +partner wants chain-native per-payment proofs. + +### Backend for Part A (either approach) + +- **Executor becomes 1 deposit : N executions.** Pick the oldest minted, chain-indexed + deposit that is not fully converted and not in recovery; `amountIn = min(remaining, + perSwapCap)`; `swap(...)`. Execution rows get `deposit_id` and a `kind` + (`swap | forward | recover | reverse_swap | topup`) so the existing crash-safe send + pipeline (nonce persisted before broadcast, calldata-exact recovery scan) serves every + keeper transaction instead of being duplicated per kind. +- **Delete the N:M attribution.** With the deposit chosen before the swap, the R04 + cursor-gated snapshot allocation, `monerium_deposit_allocations`, `selectDepositsForExecution` and `allocateUsdcProRata` have no job left (~250 lines plus tests). The + mint watcher stays: a chain-indexed mint is still what makes a deposit convertible. +- **Forward step.** When every `swap` execution of a deposit is confirmed, create a + `forward` execution for `Σ(usdcOut − fee + subsidy)` and send it. `DEPOSIT_CONVERTED` + fires after the forward is 32 blocks deep and carries `forwardTxHash` and + `usdcForwardedRaw`; `conversions[]` stays for transparency. +- **Deposit status** gains `converting` (first chunk sent), `forwarded` (terminal), + `recovering`, `refunded` (terminal), `recovery_failed` (manual), still forward-only. +- **Below-minimum deposits** (< `minSwapAmount`, €250) can no longer merge with the next + deposit. Decision §8 D5; the lean default is to refund them through the recovery path + (no loss, one SEPA fee). +- Monitors: drop the sweep-imminent note; the stranded monitor reads `batchOpenedAt` + and also warns on USDC that sits unforwarded past N minutes. + +## 4. Part B — recovery: design and feasibility + +### 4.1 Roles and wallets + +| Wallet | Holds | Key | Purpose | +|---|---|---|---| +| Recovery wallet(s) `RECOVERY_WALLET` | EURe + USDC only during a recovery | Vortex, keeper-class KMS | receives `recover()`, signs the reverse swap and the Monerium redeem | +| Float wallet | EURe float | Vortex | pays the slippage residue so the redeem is exact; its outflow **is** the loss ledger | +| Treasury / `FEE_RECIPIENT` | fees, surplus | Safe | receives reverse-swap surplus and sweeps | + +**One recovery wallet under a Vortex/SatoshiPay company profile (D3, decided +2026-09-17).** A Monerium redeem burns EURe from a *linked* address of a profile. Product +prefers to return the money from a Vortex/SatoshiPay corporate account linked in +Monerium, so the recovery wallet is one EOA linked to that profile, `RECOVERY_WALLET` is +one immutable in the implementation, and no per-client linking or HD derivation is +needed. Implications to carry, none of them technical blockers: + +- **Payer of record.** Every refund is a SEPA credit from SatoshiPay's Monerium account + to a third party, not a return from the client's own profile. Monerium supports + outgoing third-party payments (partners page: "IBANs support both incoming and + outgoing third-party payments"; the `Counterpart` schema exists to identify the + recipient, not to restrict it), but the pattern "one corporate profile paying many + unrelated corporates" needs Monerium compliance sign-off alongside G1 item 1, and G2 + must scope it (SatoshiPay executing payments on behalf of clients). +- **Segregation.** Recovered client EURe sits on SatoshiPay's profile until redeemed. + Use a dedicated profile, or at least a dedicated linked address that holds nothing + but in-flight recoveries, so balances never commingle with SatoshiPay's own funds; the + float wallet is a second dedicated address. +- **Onboarding.** That company profile must be KYB-approved in the whitelabel app (the + whitelabel credentials can only place orders for profiles of that app). +- **Per-order rules apply to the company profile.** `supportingDocumentId` above + €15,000 (M3), any outgoing limits (M5), and the client's bank statement shows + Monerium/SatoshiPay as the sender, so the memo must carry the original payment + reference for the client's reconciliation. +- **Audit trail.** Monerium sees no link between the refund and the client's profile; + the recovery row plus the memo are the only join. + +### 4.2 On-chain primitive + +`recover(eureAmount, usdcAmount)` on the clone (Part A): keeper/guardian only, requires +`batchOpenedAt != 0 && now − batchOpenedAt ≥ RECOVERY_DELAY` (immutable, 2 h), sends to +`RECOVERY_WALLET`, emits `Recovered`. Explicit amounts, because another deposit's EURe +or USDC may be sitting on the clone. The on-chain delay is a coarse lower bound (it +counts from the first arrival of the batch, not per deposit); the keeper enforces the +per-deposit deadline exactly. If the keeper poked late (outage), the chain blocks +recovery for up to 2 h after the poke — acceptable. + +### 4.3 Orchestration (backend state machine, one row per recovered deposit) + +``` +deadline hit ──► recovering.moving recover(eure, usdc) on the clone [execution kind=recover] + ──► recovering.swapping reverse swap USDC→EURe from the recovery wallet [kind=reverse_swap] + ──► recovering.topping float sends (deposit − EURe held) to the recovery wallet [kind=topup] + ──► recovering.redeeming POST /orders kind=redeem, exact amount, source IBAN [financial_operations, exactly-once] + ──► refunded order.updated processed (webhook inbox already exists) + ──► recovery_failed any step exhausted its retries → runbook, alert +``` + +- **Trigger.** Per deposit: `mint block time + RECOVERY_DEADLINE` and the deposit is not + `forwarded`. Also operator-triggered via an admin endpoint for compliance/incident + cases. The account is flagged `recovering` so the executor stops chunking it; recovery + waits for any pending execution to settle before sending `recover` (both run under the + existing per-forwarder advisory lock). +- **Reverse swap.** `exactInput` of all recovered USDC over the reversed whitelisted + route (USDC → EURC → EURe), `minOut` from the keeper's reference with the same 40 bps + tolerance, through the private orderflow RPC. One code path: a shortfall is topped up + by the float, a surplus stays on the recovery wallet and is swept to the treasury. +- **Redeem.** The shared client already has `createRedemptionOrder` and + `buildMoneriumSepaRedemptionMessage` ("Send EUR {amount} to {iban} at {minute}", must + be within five minutes, signed by the recovery key). `amount` = the issue order's + amount string; `counterpart.identifier.iban` = the payer IBAN of the issue order; + `counterpart.details` = payer name/country from the same order; `memo` references the + original payment. The inbox/deposit processor is extended to accept `kind: "redeem"` + events for recovery-wallet addresses. +- **Ledger** (recovery row): EURe recovered, USDC recovered, EURe from reverse swap, + float top-up (= the subsidy figure product wants), surplus, fees already collected on + the deposit's chunks (offset, D9), redeem order id, timestamps per phase. +- **Rollout:** `MONERIUM_B2B_AUTO_RECOVERY=off | alert | auto`. `alert` computes and + logs the recovery plan for every breached deadline and an operator runs it via the + admin endpoint; `auto` executes it. Start in `alert`. + +### 4.4 Leaner alternative for the USDC leg (D4) + +**R2: no reverse swap in the critical path.** The float pays `deposit − EURe recovered` +in full, the redeem goes out immediately, and the recovered USDC is swept to the +subsidy vault (which needs USDC anyway) or sold back by a treasury job at leisure. +Recovery shrinks to `recover` → `topup` → redeem (three steps, no DEX interaction under +time pressure, no MEV exposure, no reverse-route liquidity dependency). Cost: the float +must be sized for the largest in-flight ticket, and the loss ledger becomes an internal +FX trade (float out in EURe, treasury in USDC) rather than a pure residue figure. Product +explicitly wants the residue-only subsidy ledger, so **R1 (reverse swap) is the plan and +R2 is the fallback** if the reverse route proves unreliable in the fork exercise. + +### 4.5 Feasibility: what is confirmed and what must be asked + +Confirmed in code/docs: + +- Monerium redeem orders to a SEPA IBAN exist, are signed with the message format the + shared client already builds, accept EOA signatures, and the API client is in place + (`packages/shared/src/services/monerium`). Orders ≥ €15,000 require + `supportingDocumentId`. SEPA Instant is used when the payer's bank supports it, else + next business day. +- The issue-order webhook already lands in the durable inbox. Monerium's OpenAPI spec + (`docs.monerium.com/redocusaurus/api.yaml`, `CounterpartResponse`) defines the + counterpart of **issue orders** as `identifier.iban` (or a generic `BankAccount`) plus + `details.name` (sender name, required) and an optional `details.address`; there is no + country and no first/last split. The refund order therefore uses `identifier.iban` + from the issue order, `details.companyName = name` and `details.country` from the IBAN + country prefix (corporate clients; individual payers need a name split). +- The keeper's crash-safe send pipeline and the exactly-once `financial_operations` + ledger are reusable for every recovery step. + +Must be verified with Monerium / in the sandbox before committing the contract shape: + +| # | Question | Decides | +|---|---|---| +| M1 | Does Monerium accept the Vortex-held fallback and one SatoshiPay profile refunding many client corporates (re-approval of G1 item 1)? (Address↔profile uniqueness is moot: one wallet under the company profile.) | G1, G2 | +| M2 | ~~Payer IBAN and name on issue orders~~ **Answered by the spec** (`CounterpartResponse`, "Issue orders": `identifier.iban`, `details.name`, optional `details.address`). Remaining: capture one real sandbox SEPA order to confirm the webhook carries the same object. | refund target derivation | +| M3 | Is `supportingDocumentId` required for a return-to-originator ≥ €15k, or can it be waived / auto-satisfied (e.g. the original payment confirmation)? | whether large-ticket refunds can be automated | +| M4 | Redeem to an IBAN that is not the profile holder's own (third-party payer) under a corporate profile; memo/reference conventions Monerium wants on a return; any native return facility (none documented). | D10, compliance | +| M5 | Outgoing limits, fees, cut-offs on redemptions. | promise wording | +| M6 | (Only if we ever drop the recovery wallet) EIP-1271 redeem from the clone itself. | R0 alternative | + +Verdict: **feasible**, with M3 as the one item that can block full automation. +If M3 is a hard requirement, refunds ≥ €15k stay `alert` mode with an operator upload, +which is still a big improvement over today (funds wait indefinitely). + +### 4.6 Failure modes + +| Failure | Behaviour | +|---|---| +| `recover` reverts (delay not elapsed, paused? no — recover ignores pause) | retry next cycle; alert after N | +| reverse route thin / quote below tolerance | retry with backoff up to 30 min, then fall back to R2 for this recovery (float pays all) | +| float empty | phase stalls at `topping`, error alert (new float-runway monitor); nothing is lost | +| Monerium rejects the order (compliance, document) | `recovery_failed`, runbook; funds sit on the recovery wallet | +| crash mid-step | every chain step is an execution row with nonce-before-broadcast; the redeem is a claimed `financial_operations` row | +| deposit swapped 100 % but `forward` not confirmed at the deadline | forward completes; the deadline applies to the last swap (D7) | +| second deposit lands during the first one's recovery | explicit amounts in `recover`; the second deposit keeps converting on its own timeline | + +## 5. Consequences product must see before saying yes + +1. **A deferral becomes a refund.** Today a weekend Chainlink gap makes the keeper + *defer* and the client waits. With a 2 h promise every deferral longer than 2 h is a + bank bounce. The drift replay (Coinbase EURC-USDC five-minute VWAP vs Chainlink EUR/USD, + 2025-09 → 2026-09) gives, at `SLIPPAGE_BPS = 40`, ~80 h/year of floor-cause deferral across 10 of + 52 weekends, episodes up to 29 h; at 60 bps it is ~0.2 h/year. **Decide + `SLIPPAGE_BPS` (60 recommended) before the immutable deploy**, or the refund path + fires on ordinary weekends. Independent of that, the 2025-10-10 depeg weekend (~48 h + out of band) would have refunded everything — correct behaviour, but say so in terms. +2. **The reference venue bug must be fixed first.** `reference-rate.ts` reads Coinbase + `EURC-USD`, which is delisted; today that means every swap defers, which under this + proposal means every deposit is refunded. Switch to `EURC-USDC` (pending decision). +3. **Fees on a refunded deposit.** Chunk fees already went to `FEE_RECIPIENT`; the float + still refunds the full amount. Net them in the ledger (D9); no on-chain claw-back. +4. **Gas and float.** One extra transaction per deposit (`forward`, ~70k gas) and four + per recovery. Float sizing = max concurrent tickets × slippage residue under R1 + (small), or × full ticket under R2. +5. **Trust and terms.** §2 above; the partner agreement's "Vortex cannot move funds" + language and the Monerium G1 approval both change. + +## 6. Change inventory + +**Contracts** (`contracts/monerium-forwarder`, Approach A): `VortexForwarder` as in §3.A; +`VortexForwarderFactory.deployForwarder` drops `fallbackAddress` (adds `recoveryWallet` if +per-clone); `ImmutableConfig` gains `recoveryWallet` (if shared) and `recoveryDelay`, +loses `sweepDelay`; `VortexSubsidyVault.pay` unchanged (the clone passes itself as `to`); +manifest v4 (`manifest-core.ts`, `verify-manifest.ts`). Tests: rewrite the sweep/fallback +tests into forward/recover/gating/pause tests; invariants "USDC leaves only to +destination or recovery wallet", "EURe leaves only to router or recovery wallet", +"recover impossible before `RECOVERY_DELAY`", "partial swap never resets `batchOpenedAt`"; +fork exercise (runbook §7) extended with a forward and a recovery. + +**Backend** (`apps/api`): migrations — `monerium_accounts` drop `fallback_address`, add +`recovery_wallet` (+ derivation index), `monerium_conversion_executions` add `kind`, +`deposit_id`, drop `monerium_deposit_allocations`, new `monerium_recoveries`, deposit +status enum extension; executor (1:N, `forward` step, `kind`-aware calldata expectations); +delete allocation code; recovery orchestrator + recovery/float signers (`chain.ts`); +deposit processor accepts redeem events; manager events (`DEPOSIT_CONVERTED` gains the +forward tx, new `DEPOSIT_RETURNED`); admin endpoints (trigger/list recoveries); monitors +(float runway, recovery-stuck, association monitor covers the recovery address); +config/env (`MONERIUM_B2B_RECOVERY_*`, `MONERIUM_B2B_FLOAT_PRIVATE_KEY`, +`MONERIUM_B2B_RECOVERY_DEADLINE_MINUTES`, `MONERIUM_B2B_AUTO_RECOVERY`); provisioning +reads back `recoveryWallet` instead of `fallbackAddress`. + +**Shared/API contract**: `WebhookEventType.DEPOSIT_RETURNED`, payload types, `DepositStatus` +values; OpenAPI json/d.ts, `wire-contract.snapshot.md`, `docs/api/pages/07-webhooks.md` +and `14-managed-profiles.md`. + +**Docs**: ADR-0005 amendment 2 (decisions flipped, custody accepted, D-list outcomes, +registry rows P3→`RECOVERY_DELAY`, B5, new rows for float/recovery keys); +`architecture-monerium-b2b-onramp.md` (new sequence + lifecycle diagrams, fees section: +subsidy to the clone); `security-spec/05-integrations/monerium-b2b.md` (invariants 12, +keeper 1/3/4/5/6, monitoring 3/5, threat vectors: Vortex custody path, recovery-key +compromise); rollout (G1 re-approval + M1–M5, terms 2/6 rewrite, ledger); +runbook (§2 recovery operations, float operations, §3 triage rows, §5 destination +rotation now = new clone, §6 recovery/float keys, §7 fork exercise). + +**Kept from PR #1375 unchanged:** fee bands, reference VWAP, route whitelist, subsidy +vault and its limits, keeper deferral logic, crash recovery, monitors 1/2/4/6, the +managed-profile wiring, the durable inbox/outbox. + +## 7. Phasing and verification + +| Phase | Scope | Verify | +|---|---|---| +| 0 — prerequisites on PR #1375 | reference venue → `EURC-USDC`; decide `SLIPPAGE_BPS`; answers to M1–M3 (sandbox SEPA simulation covers M2) | forge + api suites green; sandbox order payload captured | +| 1 — whole-deposit settlement | Approach A contracts incl. the `recover()` primitive; executor 1:N + `forward`; delete N:M attribution; `DEPOSIT_CONVERTED` with forward tx; deposit statuses; manual recovery runbook + admin trigger (operator executes the four steps by hand) | forge unit/invariant/fork; api executor + manager-events tests; fork exercise §7 with a €60k deposit → 3 chunks → 1 forward | +| 2 — automated recovery | orchestrator in `alert` mode, then `auto`; recovery/float signers; redeem-event processing; float + recovery monitors | api state-machine tests with mocked chain; sandbox end-to-end refund (M2/M3 permitting); fork exercise recovery leg | +| 3 — partner surface | `DEPOSIT_RETURNED`, read API fields, OpenAPI/wire snapshot, docs pages | `bun docs:api:check`, `wire-contract:check`, integration test | + +Estimated shape: phase 1 is net-negative in backend lines (attribution deleted) and +~+150/−120 in Solidity; phase 2 is the bulk of new code (~800–1,000 lines incl. tests). + +## 8. Decisions needed + +| # | Decision | Recommendation | +|---|---|---| +| D1 | Window: 1 h or 2 h (immutable `RECOVERY_DELAY`, plus `RECOVERY_DEADLINE` config) | 2 h | +| D2 | Clock start: mint block time vs provider `processedAt` | mint block time | +| D3 | Recovery wallet: per client under the client's profile vs one wallet under a Vortex/SatoshiPay company profile | **decided: company profile** (§4.1) | +| D4 | USDC leg: R1 reverse swap (residue-only subsidy ledger) vs R2 float absorbs | R1, R2 as automatic fallback when the reverse route fails | +| D5 | Deposits below `minSwapAmount`: refund, merge with the next deposit, or lower the minimum | refund | +| D6 | Destination rotation without a client fallback key: new clone (runbook §5) vs guardian `setDestination` behind the 24 h timelock | new clone; add the setter only when a client asks | +| D7 | Deadline semantics when all chunks are swapped but not forwarded | forward completes; deadline gates the last swap | +| D8 | Refund on market-caused deferral (weekend drift, depeg) vs pause the clock while out of band | refund, with `SLIPPAGE_BPS = 60`; the promise must say so | +| D9 | Chunk fees on a refunded deposit | keep in treasury, net in the ledger | +| D10 | Third-party payer: refund to source IBAN always | yes (SEPA return semantics) | +| D11 | Approach A vs B (vs A′) | A | +| D12 | Rollout: ship phase 1 with manual recovery, automate in phase 2 | yes | 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..5f3361fff 100644 --- a/docs/security-spec/05-integrations/monerium-b2b.md +++ b/docs/security-spec/05-integrations/monerium-b2b.md @@ -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`). +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. 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 immutable `RECOVERY_WALLET`, 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 custody of a client's funds only on the refund path, only in its own recovery wallet, and only for a payment the promised window was missed on (adr-0005, amendment 2026-09-17). 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`). Every step re-derives its remaining work from the recovery 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 recovery 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 recovery and float keys are env-only, never logged, required at startup in `auto` mode, and the recovery key must control the implementation's `RECOVERY_WALLET` (checked before any send). `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 immutable `RECOVERY_WALLET`, 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 | +| **Recovery wallet compromise** | The Vortex wallet that receives recovered funds is drained before the refund | Funds sit there only during a recovery; the wallet is a dedicated linked address on a Vortex company profile at Monerium holding nothing else; one refund at a time; the key is env-only and must match the immutable `RECOVERY_WALLET` | +| **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, within cap and budget; the clone passes its own immutable destination and 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 `RECOVERY_WALLET`, USDC only to `destination`, `FEE_RECIPIENT` or `RECOVERY_WALLET`, 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_RECOVERY_PRIVATE_KEY` and `MONERIUM_B2B_FLOAT_PRIVATE_KEY` are env-only, validated as 32-byte keys, required only in `auto` mode, and the recovery key's address equals the implementation's `RECOVERY_WALLET` before any send diff --git a/packages/shared/src/endpoints/webhook.endpoints.ts b/packages/shared/src/endpoints/webhook.endpoints.ts index 26f45752a..e91ced25c 100644 --- a/packages/shared/src/endpoints/webhook.endpoints.ts +++ b/packages/shared/src/endpoints/webhook.endpoints.ts @@ -4,7 +4,8 @@ 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" } /** @@ -13,13 +14,31 @@ 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 +] 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 +119,21 @@ export interface DepositReceivedWebhookPayload { payload: DepositWebhookPayloadBase; } +/** + * How a whole execution was priced (docs/architecture-monerium-b2b-onramp.md, fees section): + * the partner reference it was settled against, the fee Vortex took above the target + * band, and the subsidy the vault paid to reach the floor. Totals for the execution, + * not per deposit; a deposit's own share is its `usdcNetRaw`. + */ +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 paid by the vault straight to the destination (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 +144,47 @@ export interface DepositConvertedWebhookPayload { conversions: Array<{ /** EURe from this deposit consumed by this execution (18-decimal base units). */ eureInRaw: string; + /** Execution-level pricing shared by every deposit portion the execution consumed. */ + execution: ConversionExecutionPricing; executionId: string; /** The swap-and-forward transaction. */ txHash: string | null; /** Net USDC from this execution attributed to this deposit (6-decimal base units). */ usdcNetRaw: string; }>; - /** Aggregate net USDC attributed to the complete deposit (6-decimal base units). */ + /** 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; + }; + }; +} + export type WebhookPayload = | TransactionCreatedWebhookPayload | StatusChangeWebhookPayload | DepositReceivedWebhookPayload - | DepositConvertedWebhookPayload; + | DepositConvertedWebhookPayload + | DepositReturnedWebhookPayload; export interface WebhookDeliveryAttempt { webhookId: string;